토큰을 갱신했더니 로그아웃됐다 — 리프레시 토큰 회전의 경쟁 상태

보안을 위해 리프레시 토큰을 쓸 때마다 새 토큰으로 교체하는 회전(rotation) 방식을 도입했습니다. 그런데 앱에서 여러 요청이 거의 동시에 토큰을 갱신하면 갑자기 로그아웃되는 제보가 들어왔습니다.

동시 갱신이 서로의 토큰을 무효화한다

토큰 회전은 옛 리프레시 토큰을 즉시 폐기하고 새 토큰을 내주는 구조입니다. 그런데 모바일 앱이 만료 직후 여러 API를 동시에 호출하면, 갱신 요청도 여러 개가 거의 같은 순간에 날아옵니다. 첫 요청이 옛 토큰을 폐기하고 새 토큰을 발급하는 사이, 두 번째 요청은 아직 방금 폐기된 옛 토큰을 들고 옵니다. 서버는 이미 무효가 된 토큰이라며 거절하고, 앱은 로그아웃됩니다. 전형적인 경쟁 상태였습니다.

갱신을 직렬화하고 짧은 유예를 두다

같은 사용자의 토큰 갱신은 한 번에 하나만 처리되도록 사용자 키 단위로 락을 걸었습니다. 그리고 방금 회전된 옛 토큰은 아주 짧은 유예 시간 동안은 유효한 것으로 인정해서, 찰나의 차이로 도착한 동시 요청을 흡수하게 했습니다.

if (lock.tryLock(userId)) {
    try { return rotate(refreshToken); }
    finally { lock.unlock(userId); }
}

정리

단일 요청 시나리오만 생각하면 완벽해 보이는 로직이, 동시 요청 앞에선 무너졌습니다. 토큰처럼 상태를 바꾸는 작업은 항상 동시에 두 번 들어오면 어떻게 되나를 먼저 그려 봐야 한다는 교훈을 남겼습니다.