Project : RainMind 개발일지 - 12 [1번째 최적화 수행 기록(수치화) - 1st : 회원가입 & 로그인 기능]
회원가입과 로그인 기능 테스트시 사용한 스크립트와 PromQL 쿼리, 데이터 수치화를 기록한다.
회원가입 기능
-
문제 제시: 수백명의 유저가 지속적으로 회원가입을 수행할 때 처리 속도가 낮아지는 병목 현상 발생.
-
시나리오: 문제 파악을 위해 100명의 유저가 3분동안 지속적으로 회원가입 로직을 수행하는 스크립트를 작성.
-
사용 스크립트:
import http from 'k6/http';
import { check, sleep } from 'k6';
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
import { Counter } from 'k6/metrics';
export const options = {
duration: '3m',
vus: 100
};
const BASE_URL = __ENV.BASE_URL;
const created = new Counter('created_201');
const dup = new Counter('dup_409');
export default function() {
const nickname = `user_${uuidv4().substring(0, 8)}_${__VU}_${__ITER}`;
const res = http.post(
`${BASE_URL}/v1/auth/user/register`,
JSON.stringify({
nickname,
password: 'password12345678',
region_name: 'Seoul'
}),
{
headers: {
'Content-Type': 'application/json'
}
}
);
check(res, {
'register, or duplicate': (r) => r.status == 201 || r.status == 409
});
if(res.status == 201) created.add(1);
if(res.status == 409) dup.add(1);
}
- 측정 지표: 평균 응답시간, p(90) / p(95) / max 응답시간, RPS(Request Per Second)
(결과)
| avg | p(90) | p(95) | max | RPS(req / s) |
|---|---|---|---|---|
| 3.68s | 3.91s | 3.96s | 6.14s | 27.044926 |
특이사항: http_req_waiting 즉 대부분의 시간을 HTTP request 이후 서버로부터 응답을 받을때까지 기다리는데 사용함.
-
병목 현상에 대한 가설: (1) DB 커넥션 풀 부족에 따른 스레드 대기 현상, (2) 회원가입 시 비밀번호 암호화(무거운 연산 수행)에 따른 작업시간 증가
-
가설 검증 (1) - DB 커넥션 풀 증가: application.yaml 파일에 hikari.maximum-pool-size를 default = 10에서 50으로 custom 설정.
(결과)
| avg | p(90) | p(95) | max | RPS(req / s) |
|---|---|---|---|---|
| 3.93s | 4.19s | 4.33s | 6.46s | 25.325949 |
여전히 CPU 사용률이 90% 대이며, 커넥션 풀의 수를 늘였음에도 불구하고 변화가 없어 CPU가 수행하는 무거운 연산에 의해 응답이 지연된 것이라 판단하여 다음 실험 수행.
- 가설 검증 (2) - 비밀번호 암호화 연산 감소: UserSignUpService.kt의 gensalt() 인자로 4를 전달(default = 10).
(결과)
| avg | p(90) | p(95) | max | RPS(req / s) |
|---|---|---|---|---|
| 153.89ms | 344.7ms | 415.77ms | 1.44s | 648.711365 |
CPU 사용률은 80% 대로 소폭 감소, 그러나 RPS가 약 25배 증가 및 p(95)는 약 10배 개선되어 그만큼 요청을 더욱 처리할 수 있었던 것으로 판단됨.
BCrypt는 gensalt()에 주는 인자의 2의 거듭제곱 만큼 키 확장(BCrypt 내부적으로 가진 거대한 숫자의 테이블을 계속 변경)이 수행되어, CPU가 의도적으로 많은 연산을 하도록 설계되었다. 따라서, 해당 인자를 늘이면 보안은 견고해지나 속도가 느려지고, 해당 인자를 줄이면 보안이 약해진다.
따라서, 속도를 중요시한다면 인자를 조금 줄이거나, 보안이 중요하다면 현행 유지를 하면 될 것으로 판단됨. 아니면 Argon2와 같은 대체 알고리즘을 고려해볼 수 있을것이다.
로그인 기능
-
문제 제시: 악의적인 유저가 임의의 아이디 및 비밀번호로 무작위 공격을 시도하여 서버의 CPU 자원을 낭비(특히 BCrypt 연산)하여 서버를 공격할 수 있음.
-
해결: 외부 저장소 Redis를 사용하여, 최근 1분간 로그인 시도를 수행중인 IP의 로그인 시도 횟수를 기록함. 이후 일정량 이상 요청이 오면, 해당 IP를 짧은 시간(3분) 차단하여 CPU 자원을 보호한다. DB table을 하나 더 추가하는 방안도 고려하였으나, 악의적인 공격으로 인해 DB 커넥션 풀이 고갈될 수 있으므로 외부 저장소를 사용함.
-
시나리오: VU = 50 ~ 200 범위 안에서, 로그인 요청을 할 수 있는한 최대한 보내는 스크립트 작성(rate는 높은 상한(10000)으로 제한)
-
사용 스크립트:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
scenarios: {
login_attack: {
executor: 'constant-arrival-rate',
rate: 10000,
timeUnit: '1s',
duration: '3m',
preAllocatedVUs: 50,
maxVUs: 200,
},
},
};
const BASE_URL = __ENV.BASE_URL;
export default function () {
const id = Math.floor(Math.random() * 200);
const nickname = `seed_${id}`;
const password = 'wrong_password';
const res = http.post(
`${BASE_URL}/v1/auth/user/login`,
JSON.stringify({
nickname,
password
}),
{
headers: {
'Content-Type': 'application/json'
}
}
);
check(res, {
'blocked(429) or unauthorized(401/404)': (r) => r.status === 429 || r.status === 401 || r.status === 404,
});
sleep(0.01);
}
- 측정 지표: (1) 응답 시간(평균, p(90), p(95), max) 및 RPS, (2) max_over_time(hikaricp_connections_active[1m]) = 실제 DB를 사용중인 커넥션의 수
결과 - Redis 차단 캐시 도입 이전
-
Grafana:
-
k6 콘솔 결과:
-
표로 정리:
| avg | p(90) | p(95) | max | RPS(req / s) | active DB connection(개 / min) |
|---|---|---|---|---|---|
| 223.88ms | 455.8ms | 602.69ms | 1.42s | 851.823838 | 약 3.83 |
결과 - Redis 차단 캐시 도입 이후
-
Grafana:
-
k6 콘솔 결과:
시간이 지나며 429 요청이 많아지므로, 방어 로직은 정상 작동함을 확인할 수 있다.
- 표로 정리:
| avg | p(90) | p(95) | max | RPS(req / s) | active DB connection(개 / min) |
|---|---|---|---|---|---|
| 64.09ms | 123.08ms | 180.17ms | 3.01s | 2669.172776 | 1.08 |
또한, 평균적인 DB 커넥션 풀 사용 개수가 약 3.54배 개선되었으며, p(90) 및 p(95) 또한 각각 3.7배 / 3.35배 개선되었다.
이는 Redis 방어 로직으로 서버 내부 CPU 자원 및 DB 커넥션 풀을 보호했음을 보여준다.
그러나, 실세계에서는 단순 IP만으로 차단하는 것은 부적절하다. 왜냐하면, 카페와 같은 공공 장소에서는 대부분이 공유기와 같은 AP를 사용하므로, 외부에서 보았을 때는 해당 AP를 사용하는 사용자의 IP는 모두 동일하다. 즉 정상적인 이용자들까지 차단해버리는 위험이 존재한다.
따라서, 단순 IP만으로 차단하는 것은 위험하며, 비정상적으로 접속이 많은 IP를 ‘의심 IP’등으로 기억한 다음 캡챠와 같은 인증을 도입하거나, 비정상적으로 많이 입력되는 아이디 / 비밀번호 패턴을 차단하는 등 보다 세분화된 방법이 필요할 것이다.