“로그인 한 번 하는데 5초 넘게 걸려?”
개발자라면 누구나 한 번쯤 들어본 무서운 말입니다.
저 역시 Datadog 대시보드를 보다가 SignIn API가 때때로 5~10초씩이나 걸리는 상황을 발견했습니다
특히 에러가 나는 경우엔 대기 시간이 더 길어져, 사용자 경험이 크게 떨어졌죠.
1. 원인 추적: 그놈의 낙관적 락
로그인을 찬찬히 뜯어보니,
JWT 토큰의 조회/삭제/삽입 과정에서 데이터베이스를 자주 탐색하고 있었습니다
JPA에서 흔히 쓰는 낙관적 락(Optimistic Lock) 은
레코드 버전 충돌이 일어날 때
“에이, 설마 동시에 같은 걸 바꾸겠어?” 라는 가정 하에 동작합니다.
하지만 그 가정을 깨는 상황이 오면 ObjectOptimisticLockingFailureException 이라는 예외가 발생합니다.
저희 서비스 또한 ObjectOptimisticLockingFailureException 예외가 무더기로 쏟아지고 있었습니다.
이로 인해 처리량은 급감, 응답 속도는 5초를 훌쩍 넘기는 일도 빈번했습니다.
실제 k6 부하테스트 결과 (50명 × 30초)
- 처리량: 4회 (0.13 iter/s)
- 평균 응답: 4.57초
- 실패율: 100%
- 에러 로그: JPA 낙관적 락 예외 반복 발생
(base) aristo@AriSto-2 OptimisticLock % k6 run optimistic_like_k6.js
/\ |‾‾| /‾‾/ /‾‾/
/\ / \ | |/ / / /
/ \/ \ | ( / ‾‾\
/ \ | |\ \ | (‾) |
/ __________ \ |__| \__\ \_____/ .io
execution: local
script: optimistic_like_k6.js
output: -
scenarios: (100.00%) 1 scenario, 50 max VUs, 30s max duration (incl. graceful stop):
* optimistic_lock_test: 50 looping VUs for 30s
✗ is status 200
↳ 0% — ✓ 0 / ✗ 4
✓ no optimistic lock
checks................................: 50.00% ✓ 4 ✗ 4
✗ { scenario:optimistic_lock_test }...: 50.00% ✓ 4 ✗ 4
data_received.........................: 3.9 kB 129 B/s
data_sent.............................: 11 kB 374 B/s
http_req_blocked......................: avg=907µs min=599µs med=633.5µs max=1.76ms p(90)=1.43ms p(95)=1.59ms
http_req_connecting...................: avg=814.75µs min=582µs med=600µs max=1.47ms p(90)=1.21ms p(95)=1.34ms
http_req_duration.....................: avg=4.57s min=4.57s med=4.57s max=4.57s p(90)=4.57s p(95)=4.57s
http_req_failed.......................: 100.00% ✓ 4 ✗ 0
✗ { scenario:optimistic_lock_test }...: 100.00% ✓ 4 ✗ 0
http_req_receiving....................: avg=15.2ms min=11.44ms med=13.13ms max=23.09ms p(90)=20.51ms p(95)=21.8ms
http_req_sending......................: avg=613µs min=37µs med=409µs max=1.59ms p(90)=1.31ms p(95)=1.45ms
http_req_tls_handshaking..............: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting......................: avg=4.55s min=4.55s med=4.56s max=4.56s p(90)=4.56s p(95)=4.56s
http_reqs.............................: 4 0.133309/s
iteration_duration....................: avg=4.67s min=4.67s med=4.67s max=4.67s p(90)=4.67s p(95)=4.67s
iterations............................: 4 0.133309/s
vus...................................: 50 min=50 max=50
vus_max...............................: 50 min=50 max=50
running (30.0s), 00/50 VUs, 4 complete and 50 interrupted iterations
optimistic_lock_test ✓ [======================================] 50 VUs 30s
2. 기존 방식의 한계
로그인 처리 로직을 살펴보니
- 사용자 인증
- RefreshToken 삭제
- 새 토큰 발급 및 저장
이 모든 단계가 DB에 여러 번 접근하며 처리되고 있었습니다.
그 결과, 여러 스레드나 인스턴스에서 같은 사용자가 동시에 로그인할 경우
refresh_token 테이블에 대해 delete와 insert가 동시에 실행되어
“중복 PK” 충돌이 필연적으로 발생하게 됩니다.
/**
* 로그인 처리.
*/
@Transactional
public UserJwtInfoRes signIn(final UserSignInReq req) {
UserJpaEntity user = userRepository.findByEmail(req.email())
.orElseThrow(() -> new UnauthorizedException(FailureCode.INVALID_USER_CREDENTIALS));
if (!passwordEncoder.matches(req.password(), user.getPassword())) {
throw new UnauthorizedException(FailureCode.PASSWORD_MISMATCH);
}
refreshTokenRepository.deleteByUserId(user.getId());
Token issuedToken = jwtProvider.issueToken(user.getId(), user.getEmail(), now);
if(req.firebaseToken() != null){
saveFirebaseToken(user.getId(), req.firebaseToken(), req.deviceType(), req.deviceId());
}
return UserJwtInfoRes.of(user.getId(), issuedToken.accessToken(), issuedToken.refreshToken());
}
낙관적 락으로 유발되는 문제 요약
- 잦은 롤백(rollback)으로 인한 오버헤드
- 처리량 급감
- 에러 로그 남발
- 사용자 경험 저하
3. Redis의 싱글 스레드 원자성: 의외의 해결책
Redis는 모든 명령을 싱글 스레드로 순차 처리합니다.
즉, 여러 요청이 거의 동시에 들어오더라도,
delete(key)와 set(key, value) 명령이 서로 뒤섞이지 않고
항상 하나씩, 순서대로 처리됩니다.
이 구조를 활용해 RefreshToken 관리 로직을
“delete 후 set”의 단일 키 연산으로 바꿨습니다.
그 결과, 한 번에 오직 하나의 토큰만 남게 되고,
race condition이나 JPA 낙관적 락 충돌이 자연스럽게 사라졌습니다.
4. 새 부하테스트 결과
동일한 시나리오로 재검증!
Before (DB/JPA) | After (Redis) |
처리량: 4회(0.13 iter/s) | 538회(17.9 iter/s) |
평균 응답: 4.57초 | 2.47초 |
실패율: 100% | 0% |
- 평균 응답 시간: 2.47초, 중앙값 0.68초
- 낙관적 락 예외 발생: 0%
- 동시성 차단(409 응답): 54.5%
낙관적 락 예외는 사라지고, 응답 속도와 처리량 모두 획기적으로 개선되었습니다!
동일 조건(k6 50 VU × 30s) 부하테스트 결과:
- 전체 처리량: 538회(17.9 iter/s)
- 평균 응답 시간: 2.47초, 중앙값 0.68초
- 낙관적 락 예외 발생: 0%
- 409 응답률(동시성 차단): 54.5%
scenarios: (100.00%) 1 scenario, 50 max VUs, 30s max duration (incl. graceful stop):
* optimistic_lock_test: 50 looping VUs for 30s
✗ is status 200
↳ 0% — ✓ 0 / ✗ 538
✓ no optimistic lock
checks................................: 50.00% ✓ 538 ✗ 538
✗ { scenario:optimistic_lock_test }...: 50.00% ✓ 538 ✗ 538
data_received.........................: 369 kB 12 kB/s
data_sent.............................: 122 kB 4.1 kB/s
http_req_blocked......................: avg=49.38µs min=3µs med=7µs max=1.5ms p(90)=48.8µs p(95)=245.89µs
http_req_connecting...................: avg=29.26µs min=0s med=0s max=827µs p(90)=0s p(95)=227.44µs
http_req_duration.....................: avg=2.47s min=8.18ms med=680.23ms max=9.53s p(90)=5.46s p(95)=5.89s
{ expected_response:true }..........: avg=5.28s min=1.48s med=5.15s max=9.53s p(90)=6.05s p(95)=7.38s
http_req_failed.......................: 54.46% ✓ 293 ✗ 245
✗ { scenario:optimistic_lock_test }...: 54.46% ✓ 293 ✗ 245
http_req_receiving....................: avg=2.99ms min=34µs med=135.5µs max=58.36ms p(90)=9.67ms p(95)=14.2ms
http_req_sending......................: avg=49.77µs min=7µs med=36µs max=726µs p(90)=62µs p(95)=85.14µs
http_req_tls_handshaking..............: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting......................: avg=2.47s min=7.82ms med=680.1ms max=9.53s p(90)=5.45s p(95)=5.88s
http_reqs.............................: 538 17.930057/s
iteration_duration....................: avg=2.58s min=108.73ms med=955.72ms max=9.63s p(90)=5.56s p(95)=5.99s
iterations............................: 537 17.89673/s
vus...................................: 50 min=50 max=50
vus_max...............................: 50 min=50 max=50
5. 핵심 용어 정리
- 낙관적 락(Optimistic Lock): 충돌 시 예외 발생, 롤백 필요
- 원자성(Atomicity): 여러 동시 명령이 '끼어들 수 없음'을 보장하는 특성
- Race Condition: 여러 프로세스가 같은 데이터에 동시에 접근해 비정상 상태가 되는 문제
- k6 부하테스트: 실제 동시 접속/요청 환경을 시뮬레이션하는 오픈소스 툴
이번 개선의 핵심은
단순히 Redis 캐시로 DB 부하와 응답 시간을 줄인 것뿐만 아니라
Redis의 ‘싱글 스레드 원자성’을 활용해
race condition, 즉 동시성 충돌 문제까지 자연스럽게 해결할 수 있었다는 점입니다.
대부분의 백엔드 개발자는
‘Redis는 캐시나 세션 용’으로만 생각합니다.
하지만 싱글 스레드 원자성에 집중하면
락 없이도 강력한 동시성 제어를
가볍고 빠르게 구현할 수 있습니다.
저는 이 경험을 통해
기존 설계의 한계를 고치고
최적의 도구를 ‘그 특성 그대로’ 살려 쓰는 법을 배웠습니다
읽어주셔서 감사합니다!
궁금한 점은 언제든 댓글 남겨 주세요.
'개발 일기' 카테고리의 다른 글
🔒 Redisson 분산락으로 사용자 매칭 중복 생성 문제 해결기 (1) | 2025.05.18 |
---|