코드 리뷰 중에 우연히 발견했다. 누가 지적한 것도 아니었고, 직접 터진 것도 아니었다. 근데 보면 볼수록 이건 그냥 넘기면 안 되는 거였다.
Refresh Token 재발급 흐름이 이렇게 되어 있었다.
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 실패 |
|---|---|---|
| 수정 전 | 10 | 90 |
정상이라면 1개만 성공해야 하는데 10개가 성공했다.
원인은 명확했다. 여러 스레드가 Redis에서 같은 저장값을 동시에 읽고, 전부 검증을 통과한 뒤, 각자 새 토큰을 발급받은 거다. GET과 SET 사이 그 짧은 틈에 스레드 10개가 동시에 끼어든 것이다.
탈취된 토큰이 재사용됐을 때 감지해야 하는데, 이 구조에서는 재사용 감지 자체가 불가능했다.
솔직히 이 부분이 제일 헷갈렸다. 지금도 완전히 확실하게 정리됐다고 하기는 어렵다.
처음에 분산 락 얘기가 나왔다. Redisson 같은 걸로 락을 걸면 되는 거 아닌가.
분산 락은 "이 작업을 동시에 하나만 실행하겠다"는 제어다. 락을 획득한 쪽만 진입하고, 나머지는 대기하거나 실패한다. 공유 자원을 여러 서버에서 접근할 때 순서를 강제하는 용도다.
원자성은 다르다. "이 비교와 교체를 쪼갤 수 없는 하나의 동작으로 처리하겠다"는 거다. 락처럼 대기 개념이 없고, 비교 결과에 따라 교체가 되거나 안 되거나 둘 중 하나다.
둘 다 동시성 문제를 다루는데 왜 다른 거냐고 하면 — 락은 순서를 만드는 거고, 원자성은 쪼개지지 않게 하는 거라고 이해했다. 근데 솔직히 말하면 경계가 아직도 흐릿하게 느껴질 때가 있다.
이번 케이스에서 필요한 건 "현재 저장된 값이 요청으로 들어온 값과 일치하면 새 값으로 교체"라는 compare-and-set이었다. 락으로 순서를 만들 게 아니라, 비교와 교체 자체를 쪼개지지 않게 하면 됐다.
synchronized는 단일 인스턴스 내 메모리에서만 유효하다. MSA에서 user-service가 여러 인스턴스로 뜨는 구조라면 처음부터 효과가 없다.
Redisson 분산 락은 락 획득/해제 오버헤드가 있고 새 의존성 추가가 필요했다. 이 상황에서는 과한 해결책이었다.
Redis는 싱글 스레드로 명령을 처리한다. 그래서 단일 명령 하나는 자체적으로 원자적이다. 문제는 GET과 SET을 따로 날리면 그 사이가 원자적이지 않다는 거다.
여러 명령을 하나의 Lua 스크립트로 묶어 보내면 Redis가 그걸 하나의 단위로 처리한다. 중간에 다른 명령이 끼어들 수 없다. 별도 의존성 없이 Spring Data Redis의 RedisScript만으로 구현할 수 있었다.
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 1local 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 (원자성 없음) | 10 | 90 |
| After (원자적 CAS 적용) | 1 | 99 |
100개 중 정확히 1개만 성공했다.
코드 리뷰에서 우연히 잡은 버그인데, 보안 관점에서 꽤 중요한 부분이었다. Refresh Token Rotation은 탈취 감지가 핵심인데, 원자성이 없으면 재사용 감지 자체가 불가능하다.
그리고 이번에 분산 락이랑 원자성이 비슷해 보여도 쓰임이 다르다는 걸 확실히 알게 됐다. 락은 순서를 만드는 거고, 원자성은 쪼개지지 않게 하는 거다.
동시 요청 시나리오에 대한 통합 테스트가 있었다면 배포 전에 잡혔을 거다. 다음엔 그걸 미리 만들어두는 게 맞다.
참고 자료