
인증을 다루다 보면 자연스럽게 토큰 관리에 대한 고민이 생기는데요,
Access Token은 수명을 짧게 가져가도록 하고 있지만, 만료 기간이 긴 Refresh Token은 보안 측면에서 고민이 됩니다.
만약 이 Refresh Token이 탈취당한다면? 공격자는 토큰이 만료될 때까지 정상 사용자인 척할 수 있게 됩니다,, 🤨
그래서 오늘은 이 문제를 완화하는 대표적인 방식인 RTR(Refresh Token Rotation) 구조에 대해 정리해보려고 합니다.
🤔 RTR이란?
RTR은 이름 그대로 Refresh Token을 회전(Rotation)시키는 구조입니다.
기존 방식에서는 Access Token이 만료되면 Refresh Token으로 새 Access Token만 발급받았는데요.
RTR에서는 재발급 요청이 올 때마다 Refresh Token도 함께 새로 발급하고, 방금 사용된 기존 Refresh Token은 즉시 무효화합니다.
[기존 방식]
Refresh Token A → Access Token 재발급 (A는 만료 전까지 계속 유효)
[RTR]
Refresh Token A → Access Token + Refresh Token B 발급 (A는 즉시 무효화)
Refresh Token B → Access Token + Refresh Token C 발급 (B는 즉시 무효화)
즉, 모든 Refresh Token은 딱 한 번만 사용 가능한 일회용 토큰이 됩니다. 그리고 A → B → C로 이어지는 이 계보를 **토큰 패밀리(Token Family)**라고 부릅니다. 이 패밀리 개념이 뒤에서 중요한 역할을 하게 돼요!
참고로 OAuth 2.0 보안 권고안(RFC 9700, OAuth 2.0 Security Best Current Practice)에서도 Refresh Token은 Rotation을 적용하거나 특정 클라이언트에 바인딩(sender-constrained)할 것을 권장하고 있습니다.
😮 탈취당하면 어떻게 방어될까?
"어차피 새 토큰도 탈취되면 똑같은 거 아닌가?" 싶을 수 있는데, RTR의 진짜 힘은 재사용 감지(Reuse Detection)에 있습니다.
공격자가 Refresh Token A를 탈취했다고 가정해보겠습니다. 이때 시나리오는 두 가지입니다.
시나리오 1 : 공격자가 먼저 사용한 경우
공격자가 A로 재발급을 받으면 새 토큰 B는 공격자 손에 들어갑니다.
하지만 잠시 후 정상 사용자가 (이미 무효화된) A로 재발급을 시도하겠죠?
서버는 "죽은 토큰이 다시 사용됐다"는 걸 감지하고, 해당 토큰 패밀리 전체를 무효화합니다.
공격자가 들고 있던 B도 함께 죽는 것입니다.
시나리오 2 : 정상 사용자가 먼저 사용한 경우
사용자가 A로 재발급받아 B를 갖게 되면, 공격자가 뒤늦게 A를 사용하는 순간 역시 재사용이 감지되고 패밀리 전체가 무효화됩니다.
Refresh Token A (무효화됨) 재사용 감지
→ Token Family(A, B, C ...) 전체 폐기
→ 모든 세션 로그아웃 → 재로그인 유도
어느 쪽이든 공격자가 토큰을 오래 유지하는 것이 불가능해집니다.
정상 사용자는 재로그인이라는 약간의 불편을 겪지만, 탈취 사실이 자연스럽게 드러나고 피해는 그 시점에 차단됩니다.
완벽한 예방이라기보다는 "탈취되어도 피해 시간을 최소화하는" 구조라고 이해하시면 됩니다!
🤓 클라이언트에서 구현할 때 고려할 점
개념은 간단해 보이지만, 막상 클라이언트(특히 모바일)에서 구현해 보면 신경 쓸 지점이 꽤 많습니다.
실제로 부딪혔던 포인트 위주로 함께 정리를 해보았습니다.
1. 동시 갱신 문제 : 재발급은 반드시 한 번만
여러 API 요청이 동시에 401을 받으면 각자 토큰 재발급을 시도하는데, RTR에서는 첫 요청이 성공하는 순간 기존 Refresh Token이 무효화되기 때문에 두 번째 재발급 요청이 "재사용"으로 감지되어 패밀리 전체가 날아갑니다.
멀쩡한 사용자가 강제 로그아웃되는 거죠,,!
그래서 재발급 로직은 반드시 직렬화해야 합니다. 흐름으로 보면 이렇습니다.
refreshToken(만료된 토큰):
락 획득 (재발급은 동시에 하나만)
// 내가 대기하는 동안 다른 요청이 이미 갱신을 끝냈다면?
if 저장된 토큰 != 만료된 토큰:
return 저장된 토큰 // 재발급 없이 그대로 사용
새 토큰 = 서버에 재발급 요청
새 토큰 저장 (Access + Refresh 모두)
return 새 토큰
락 해제
핵심은 락을 획득한 뒤 "내가 기다리는 사이 이미 누가 갱신했는지"를 먼저 확인하는 부분입니다.
이 체크가 없으면 락을 걸어도 순차적으로 재발급을 두 번 요청하게 됩니다.
2. 새 토큰은 받자마자, 원자적으로 저장하기
재발급 응답을 받고 저장하기 전에 앱이 죽으면, 서버는 이미 A를 무효화했는데 클라이언트는 A만 들고 있는 상태가 됩니다.
그래서 Access/Refresh Token은 하나의 단위로 함께 저장해야 합니다.
save(토큰):
하나의 트랜잭션으로 {
Access Token 저장
Refresh Token 저장
}
// 둘 중 하나만 저장된 상태가 존재하면 안 됨
저장소 자체도 평문 SharedPreferences가 아니라 Keystore 기반의 암호화 저장소를 사용하는 것이 좋습니다.
RTR이 있어도 토큰이 기기 안에서 쉽게 읽히면 의미가 없으니까요.
3. 응답 유실 대비
네트워크가 불안정해서 서버는 새 토큰을 발급했는데 클라이언트가 응답을 못 받는 경우도 있습니다.
이때 클라이언트는 구 토큰으로 재시도할 수밖에 없고, 그대로면 재사용 감지에 걸려 로그아웃됩니다. 그래서 서버 측에서 직전 토큰에 한해 아주 짧은 유예 시간(grace period)을 허용하는 방식이 함께 쓰이기도 합니다.
다만 유예 시간만큼 방어가 느슨해지는 트레이드오프가 있어서, 이 기준은 팀/서비스 상황에 맞게 정해야 하는 부분입니다!
📚 마무리
RTR을 정리하면서 느낀 건, 보안 설계라는 게 결국 "뚫리지 않게"가 아니라 "뚫려도 피해를 최소화하게" 만드는 일에 가깝다는 점입니다.
Refresh Token 탈취를 100% 막을 수는 없지만, 일회용으로 만들고 재사용을 감지하는 것만으로 공격자의 시간을 극단적으로 줄일 수 있으니까요.
그리고 개념 자체보다 동시성, 저장 원자성, 응답 유실 같은 클라이언트의 현실적인 문제들이 실제 구현의 대부분이었습니다.
인증 구조를 도입하실 때 서버뿐 아니라 클라이언트 시나리오까지 함께 설계해보시면 좋을 것 같습니다 🤔
완벽한 구조는 없어도, 방어 레이어를 하나씩 쌓다 보면 점점 단단해지겠죠?!
'Android' 카테고리의 다른 글
| [Android] suspend 함수는 어떻게 멈췄다가 다시 실행될까 (0) | 2026.08.16 |
|---|---|
| [Android] Service는 어떻게 동작할까? : Started, Bound, 그리고 Binder IPC까지 (0) | 2026.08.02 |
| [Android] Android 앱 아이콘을 클릭하는 순간부터 화면에 그려지기까지 (0) | 2026.03.08 |
| [Android] Compose가 UI를 그리기까지의 과정 (0) | 2026.02.22 |
| [Android] XML이 View를 그리기까지의 과정 (0) | 2026.02.08 |