현재 진행중인 프로젝트에서, 항공권 예매시 동시성 문제를 해결하기 위해 여러 방법을 테스트 하던 중, 너무 오랜 시간이 소요되는 것을 발견하였습니다.
테스트 환경
테스트는 동시성 문제를 일으키기 위해 어느정도의 부하가 필요했기 때문에 부하 테스트 툴인 k6 를 사용하였습니다.
스크립트
현재 프로젝트에서는 로그인한 사용자만 예매가 가능했기 때문에, 우선 로그인을 진행한 후 예매를 진행하는 방식으로 스크립트를 구성하였습니다.
약 400 RPS(로그인 + 예매) 를 고정적으로 유지하기 위해 constant-arrival-rate executor 및 이를 유지하기 위한 vuser 의 수를 200 보다 큰 값인 300 정도로 설정하였습니다.
import http from "k6/http";
import { sleep, check } from "k6";
import { Counter } from "k6/metrics";
export const options = {
scenarios: {
constant_rate_test: {
executor: "constant-arrival-rate",
rate: 400,
timeUnit: "1s",
duration: "10s",
preAllocatedVUs: 300,
maxVUs: 300,
},
},
};
// 응답 코드 별 갯수를 세기 위한 값
let status_201 = new Counter("status_201");
let status_409 = new Counter("status_409");
let status_503 = new Counter("status_503");
let status_500 = new Counter("status_500");
export default function () {
const headers = { "Content-Type": "application/json" };
// 1에서 300 사이의 랜덤한 숫자 생성
const randomNumber = Math.floor(Math.random() * 300) + 1;
// 로그인 아이디, 비밀번호 생성
const loginId = `loginId${randomNumber}`;
const password = `password${randomNumber}!`;
// 1. 로그인 요청
const loginRes = http.post(
"http://localhost:8080/login",
JSON.stringify({
loginId: loginId,
password: password,
}),
{
headers: headers,
}
);
// 로그인 요청 성공 체크
check(loginRes, {
"login succeeded": (r) => r.status === 200,
});
// 2. 쿠키를 통해 세션 값이 주어지지만, 이는 k6 에서 자동으로 처리하므로
// 별도의 설정 없이 이후의 요청에서 사용됨
// 단, 제대로 받았는지 검증을 위해 체크를 추가함
const cookies = loginRes.cookies;
const jsessionid = cookies["JSESSIONID"];
check(loginRes, {
"JSESSIONID cookie received": (r) => jsessionid !== undefined,
});
// 3. 항공권 예매 요청
const res = http.post(
"http://localhost:8080/booking/flightsInfo/272845",
JSON.stringify({
headCount: 1,
}),
{
headers: headers,
}
);
// 요청에 대한 응답 코드 카운트
if (res.status == 201) {
status_201.add(1);
} else if (res.status == 409) {
status_409.add(1);
} else if (res.status == 500) {
status_500.add(1);
} else if (res.status == 503) {
status_503.add(1);
}
sleep(1); // 대기 시간 추가
}
결과
테스트 결과, 예상했던 400 RPS 에 현저히 떨어지는 약 107 RPS 을 기록하였고, dropped_iteration 을 통해 약 3346 건의 요청이 누락된 것을 확인하였습니다. 결과를 좀 더 살펴보니 http_req_duration(총 요청 처리 시간) 과 http_req_waiting(응답 대기 시간) 를 보았을 때 오랜 시간이 걸린 것을 확인할 수 있었습니다. 즉, 서버 측의 처리량이 낮아 발생한 것을 확인하였습니다.
원인 분석
현재 요청은 로그인 요청과 항공권 예매 요청 2가지로 분류되었기 때문에, 어느 부분에서 성능 저하가 일어나는지 확인해야 했습니다.
이를 위해, 로그인 요청만 되도록 스크립트를 수정 후, 다시 테스트를 진행했습니다.
결과
기존과 크게 다르지 않은 결과를 보여주었고, 이를 통해 로그인 처리 과정에서 문제가 있음을 확인하였습니다.
로그인 과정
public void login(LoginApplicationRequest applicationRequest, String sessionId) {
MemberDomainResponse domainResponse = memberService.getMemberByLoginId(applicationRequest.loginId()).orElseThrow(
() -> new ResourceNotFoundException("member")
);
if(!passwordEncoder.matches(domainResponse.password(), applicationRequest.password())){
throw new PasswordNotMatchException();
}
MemberSessionInfo sessionInfo = mapper.domainResponseToDto(domainResponse, sessionId);
memberSessionService.createAndSaveSession(sessionInfo);
}
로그인 과정은 다음 단계으로 이루어 졌습니다.
1. DB 에서 회원 정보 조회 2. 회원 정보를 바탕으로 비밀번호 일치 여부 검사 3. 로그인 정보를 세션 저장소(Redis) 에 저장
이 단계들 중, 어느 단계에서 문제가 있는지 확인하기 위해 1 / 1 - 2 단계만을 남겨두고 테스트해보았습니다.
그 결과, 1 단계만 남겼을 때는 문제가 되었던 지연 현상이 나타나지 않았고, 1 - 2 단계를 남겼을 때 기존과 동일한 지연 현상이 일어났음을 확인하였습니다. 즉, 2 단계에서 문제가 있었던 것이었습니다.
2 단계 - 회원 정보를 바탕으로 비밀번호 일치 여부 검사
현재 passwordEncoder 는 인터페이스로, 구현체로는 Spring Security 에서 BCrypt 암호화를 지원하기 위해 사용중인 jbcrypt 라이브러리를 사용하여 구현한 상태였습니다. 해당 라이브러리에서 오랜 시간이 걸릴 수 있는 부분을 찾기 위해 검색을 해보았고, 그 원인을 찾았습니다.
원인
오랜 시간이 걸리는 메서드 자체는 BCrypt 라이브러리의 checkpw 메서드였지만, 근복적인 원인은 암호화 과정에 있었습니다. 암호화 과정은 다음 메서드를 사용하여 진행하였습니다.