본문 바로가기

개발 일기

Redis의 원자성(Atomicity)으로 로그인 API의 동시성과 성능을 한 번에 잡다

 

 

“로그인 한 번 하는데 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는 캐시나 세션 용’으로만 생각합니다.

하지만 싱글 스레드 원자성에 집중하면
락 없이도 강력한 동시성 제어를
가볍고 빠르게 구현할 수 있습니다.

저는 이 경험을 통해
기존 설계의 한계를 고치고
최적의 도구를 ‘그 특성 그대로’ 살려 쓰는 법을 배웠습니다

 


읽어주셔서 감사합니다!
궁금한 점은 언제든 댓글 남겨 주세요.