본문 바로가기
won2dev-log
HomeArchiveTagsCategoriesAboutProjects
HomeArchiveTagsCategoriesAboutProjects
won2dev-logwon2dev-logwon2dev-log

비전공 개발자의 로그 | won2dev-log

Navigation
  • Home
  • Archive
  • About
  • Projects
Categories
  • Docs
  • TIL
  • Project
  • Automation
  • Git · GitHub
더보기
Tags
  • TIL
  • Java
  • Spring
  • Backend
  • n8n
더보기
About

기록을 거름 삼아 공유는 성장을 만든다.

LicensePrivacy
© won2dev 2026. All rights reserved.
Home›TIL›Refresh Token 재발급에서 Race Condition을 마주쳤다
TIL

Refresh Token 재발급에서 Race Condition을 마주쳤다

won2dev·2026년 07월 04일
#TIL#JMeter
코드 리뷰 중에 우연히 발견했다. 누가 지적한 것도 아니었고, 직접 터진 것도 아니었다. 근데 보면 볼수록 이건 그냥 넘기면 안 되는 거였다.

뭐가 문제였나

Refresh Token 재발급 흐름이 이렇게 되어 있었다.

plain text
1. Redis에서 저장된 refresh token 조회 (GET)
2. 요청으로 들어온 토큰과 비교
3. 일치하면 새 토큰 발급
4. Redis에 새 토큰으로 덮어쓰기 (SET)
1. Redis에서 저장된 refresh token 조회 (GET)
2. 요청으로 들어온 토큰과 비교
3. 일치하면 새 토큰 발급
4. Redis에 새 토큰으로 덮어쓰기 (SET)

Refresh Token Rotation 구조다. 토큰을 한 번 쓰면 새 토큰으로 교체하고, 이전 토큰은 쓸 수 없게 만드는 방식이다. 탈취된 토큰이 재사용되면 감지할 수 있어야 한다는 게 핵심이다.

근데 여기서 GET과 SET 사이에 원자성이 없었다.


실제로 재현해봤다

JMeter로 직접 확인했다. 로그인 한 번으로 refresh token을 발급받고, 그 토큰을 동일하게 100개 스레드가 정확히 같은 순간에 /api/v1/auth/refresh로 던지도록 구성했다.

이미지
상태200 성공401 실패
수정 전1090

정상이라면 1개만 성공해야 하는데 10개가 성공했다.

원인은 명확했다. 여러 스레드가 Redis에서 같은 저장값을 동시에 읽고, 전부 검증을 통과한 뒤, 각자 새 토큰을 발급받은 거다. GET과 SET 사이 그 짧은 틈에 스레드 10개가 동시에 끼어든 것이다.

탈취된 토큰이 재사용됐을 때 감지해야 하는데, 이 구조에서는 재사용 감지 자체가 불가능했다.

분산 락 vs 원자성 — 이번엔 왜 원자성이었나

솔직히 이 부분이 제일 헷갈렸다. 지금도 완전히 확실하게 정리됐다고 하기는 어렵다.

처음에 분산 락 얘기가 나왔다. Redisson 같은 걸로 락을 걸면 되는 거 아닌가.

분산 락은 "이 작업을 동시에 하나만 실행하겠다"는 제어다. 락을 획득한 쪽만 진입하고, 나머지는 대기하거나 실패한다. 공유 자원을 여러 서버에서 접근할 때 순서를 강제하는 용도다.

원자성은 다르다. "이 비교와 교체를 쪼갤 수 없는 하나의 동작으로 처리하겠다"는 거다. 락처럼 대기 개념이 없고, 비교 결과에 따라 교체가 되거나 안 되거나 둘 중 하나다.

둘 다 동시성 문제를 다루는데 왜 다른 거냐고 하면 — 락은 순서를 만드는 거고, 원자성은 쪼개지지 않게 하는 거라고 이해했다. 근데 솔직히 말하면 경계가 아직도 흐릿하게 느껴질 때가 있다.

이번 케이스에서 필요한 건 "현재 저장된 값이 요청으로 들어온 값과 일치하면 새 값으로 교체"라는 compare-and-set이었다. 락으로 순서를 만들 게 아니라, 비교와 교체 자체를 쪼개지지 않게 하면 됐다.

synchronized는 단일 인스턴스 내 메모리에서만 유효하다. MSA에서 user-service가 여러 인스턴스로 뜨는 구조라면 처음부터 효과가 없다.

Redisson 분산 락은 락 획득/해제 오버헤드가 있고 새 의존성 추가가 필요했다. 이 상황에서는 과한 해결책이었다.


Lua 스크립트로 해결한 이유

Redis는 싱글 스레드로 명령을 처리한다. 그래서 단일 명령 하나는 자체적으로 원자적이다. 문제는 GET과 SET을 따로 날리면 그 사이가 원자적이지 않다는 거다.

여러 명령을 하나의 Lua 스크립트로 묶어 보내면 Redis가 그걸 하나의 단위로 처리한다. 중간에 다른 명령이 끼어들 수 없다. 별도 의존성 없이 Spring Data Redis의 RedisScript만으로 구현할 수 있었다.

Lua자체는 처음이었는데, 구조를 보니 흐름은 따라갈 수(?) 있었다.

lua
local current = redis.call('GET', KEYS[1])

if current == false then
    return 0
end

if current ~= ARGV[1] then
    return -1
end

redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3])
return 1
local current = redis.call('GET', KEYS[1])

if current == false then
    return 0
end

if current ~= ARGV[1] then
    return -1
end

redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3])
return 1

저장된 값이 없으면 0, 요청 토큰과 다르면 -1, 일치하면 새 값으로 교체하고 1 반환. 이 세 가지가 하나의 트랜잭션처럼 처리된다.

AuthService.refresh()를 "검증 → 무조건 재발급"에서 "검증 → 원자적 교체 시도 → 실패 시 거부"로 바꿨다. 교체가 실패하면 이미 다른 요청이 먼저 교체했다는 뜻이니, 재사용 시도로 간주하고 REFRESH_TOKEN_REUSED 에러를 던진다.


결과

동일한 조건으로 다시 JMeter를 돌렸다.

이미지
상태200 성공401 실패
Before (원자성 없음)1090
After (원자적 CAS 적용)199

100개 중 정확히 1개만 성공했다.


정리

코드 리뷰에서 우연히 잡은 버그인데, 보안 관점에서 꽤 중요한 부분이었다. Refresh Token Rotation은 탈취 감지가 핵심인데, 원자성이 없으면 재사용 감지 자체가 불가능하다.

그리고 이번에 분산 락이랑 원자성이 비슷해 보여도 쓰임이 다르다는 걸 확실히 알게 됐다. 락은 순서를 만드는 거고, 원자성은 쪼개지지 않게 하는 거다.

동시 요청 시나리오에 대한 통합 테스트가 있었다면 배포 전에 잡혔을 거다. 다음엔 그걸 미리 만들어두는 게 맞다.


참고 자료

  • Redis + Lua Script를 활용한 분산락 — mozzi-devlog
  • PR #130 — fix: 유저서비스 Refresh Token 동시성 버그 개선
공유하기
다음 글 GraalVM Native Image가 MSA에서 주목받는 이유

목차

  • 뭐가 문제였나
  • 실제로 재현해봤다
  • 분산 락 vs 원자성 — 이번엔 왜 원자성이었나
  • Lua 스크립트로 해결한 이유
  • 결과
  • 정리

카테고리

TIL

태그

#TIL#JMeter

최근 글

GraalVM Native Image가 MSA에서 주목받는 이유스크롤바 하나 고쳤는데, 원인은 아직도 모른다AI 에이전트에게 부하테스트를 맡겼더니, 테스트 환경을 직접 만들고 버그까지 찾아냈다JWT는 Stateless가 장점인데, 왜 Redis까지 붙였나Keycloak을 처음 쓰면서 배운 것들 — 그게 뭔지도 모르고 시작했다