Refresh Token Rotation 구현: 3개월간 부순 것과 고친 것

목차

[2026-02-12 03:14:21] WARN  auth.refresh_token: reuse_detected user_id=8429 family_id=fa-7e2b token_hash=abc... revoking entire family
[2026-02-12 03:14:23] WARN  auth.refresh_token: reuse_detected user_id=2114 family_id=fa-3d99 ...
[2026-02-12 03:14:25] ERROR session.invalidate: bulk logout triggered count=87 reason=token_reuse

이처럼, reuse_detected 알람이 한 시간 만에 87건 한꺼번에 떴다. Refresh Token Rotation 구현이 운영에 올라간 지 일주일째 되는 시점이었고, 87명의 사용자가 동시에 강제 로그아웃됐다. 다음 영업일 CS 티켓이 23건 들어왔고, 그중 절반은 "갑자기 로그아웃되고 비밀번호도 안 맞는다"였다.

원인은 토큰 탈취가 아니라 race condition이었다. 모바일 앱이 백그라운드에서 깨어나면서 동시에 두 번 refresh를 호출한 거다. 그런데 우리 서버는 토큰을 한 번 쓰면 즉시 폐기했다. 두 번째 요청은 "이미 쓴 토큰을 다시 쓰려는 시도"로 잡혔고, 진짜 탈취 시나리오와 신호가 똑같이 생겼다. 서버는 그게 race인지 공격인지 구분할 정보가 없으니 일단 family 전체를 죽였다.

이 글은 그 사건을 시작점으로, 3개월간 Rotation 구조를 다시 짜고 false positive를 0.4% 수준까지 떨어뜨린 시간순 기록이다. 처음부터 알았으면 좋았을 것들이 많다.

0개월 차 — 왜 Rotation으로 갔나

발단은 보안 감사였다. 기존 인증은 만료가 12시간인 JWT를 그냥 발급했다. 토큰은 localStorage에 저장됐고, XSS 한 번이면 12시간이 통째로 털리는 구조다. 감사 결과서에는 "토큰 수명 단축, 재사용 감지 도입" 두 줄만 적혀 있었지만 그 두 줄이 3개월짜리 작업이 됐다.

처음 검토한 건 단순 단축이었다. Access Token 수명을 1시간으로 줄이고 끝낸다. 다만 이러면 사용자가 1시간마다 로그인해야 한다. 그래서 Refresh Token을 따로 두기로 했고, Refresh Token이 또 12시간 살면 결국 같은 문제로 돌아오는 구조라서 Rotation까지 넘어가게 됐다. 의사결정 문서에는 후보로 (1) Sliding Session, (2) Refresh Token without rotation, (3) Refresh Token Rotation 세 개를 올렸고 3번을 골랐다.

이처럼, OAuth 2.0 스펙은 RFC 6749에서 Rotation을 SHOULD로만 권고한다. 그런데 Best Current Practice 초안 draft-ietf-oauth-security-topics (작성 시점 2026-03 기준 draft-26)에서는 SPA와 Native App에 대해 Rotation을 사실상 요구한다. 이걸 의사결정 근거로 첨부했다.

실제로, 스택은 FastAPI 0.110, Redis 7.2, PostgreSQL 15.4. 프론트는 Next.js 14다. 토큰 저장 방식도 같이 갈았다. Refresh Token은 HttpOnly 쿠키로 옮기고, Access Token은 메모리에만 두는 구조다. localStorage는 손도 안 댔다. 이 결정 자체는 지금도 옳았다고 본다.

1개월 차 — 토큰 패밀리 설계

Rotation의 핵심은 "한 번 쓴 Refresh Token은 즉시 죽는다"이다. 새 Refresh Token을 발급할 때 이전 토큰과 같은 family_id를 부여한다. family_id는 로그인 시점에 한 번 만들어지고, 같은 디바이스에서 갱신되는 동안 유지된다. 디바이스를 새로 인증하면 새 family가 생긴다.

또한, 이렇게 하면 누군가 옛 토큰을 가져다 쓰려 할 때 family 전체를 폐기할 수 있다. 한 디바이스 세션이 통째로 끊어지고, 공격자도 정상 사용자도 동시에 쫓겨난다. 보안 관점에서는 맞는 설계다. 사용자 경험 관점에서는 그게 함정이라는 걸 한 달이 지나서야 알았다.

처음에는 Redis에 family를 SET으로 저장했다. 발급된 토큰 해시를 추가하고, 사용된 건 별도 used_set에 옮기는 단순한 구조였다. 토큰을 검증할 때 used_set에 있으면 "재사용 감지"로 판단해 family 전체를 revoke했다. 코드 길이는 30줄도 안 됐고, 단위 테스트와 동시성 테스트도 다 통과했다.

여기까지 끝낸 시점에 운영에 올렸다. 며칠 안에 도입부 로그가 떴다. 단위 테스트에서 만든 동시성과 실제 모바일 앱의 동시성은 다른 세계였다는 걸 다시 한 번 확인하게 됐다. 특히 backgrounded WebView가 wakeup 시 두 번 fetch를 부르는 패턴은 로컬 환경에서 재현이 거의 불가능했다.

2개월 차 — Silent Refresh와 race condition

따라서, 원인을 추적해보니 패턴이 명확했다. 모바일 Safari에서 백그라운드 탭이 다시 활성화되는 순간, 그리고 Next.js의 Server Component와 Client Component가 동시에 만료된 Access Token으로 API를 부르는 순간이었다. 두 호출이 모두 401을 받고, 두 호출이 모두 같은 Refresh Token으로 갱신을 시도한다. 첫 번째는 통과하고 두 번째는 reuse로 잡힌다.

클라이언트 큐잉

먼저 클라이언트 쪽에서 갱신 큐를 만들었다. 만료 401이 떨어지면 첫 번째 호출만 실제 refresh를 부르고, 나머지는 같은 Promise를 await하게 했다. axios 인터셉터든 fetch wrapper든 형태는 비슷하다. 핵심은 "동시에 들어온 401을 하나의 refresh로 합친다"이다.

반면, 이걸로 한 페이지 안에서의 동시성은 잡혔다. 그런데 탭이 여러 개거나 PWA에서 앱이 두 번 깨어나면 여전히 막을 수 없었다. 같은 브라우저의 다른 탭은 BroadcastChannel로 묶어 해결했다. 한 탭이 refresh 중이면 다른 탭은 결과를 기다리도록. 다만 네이티브 앱에서는 OS 레벨에서 동시 wakeup이 일어나서 클라이언트 쪽 처리만으로는 한계가 분명했다.

서버에서 받아주지 않으면 안 되는 시점이었다.

서버 grace window

물론, 서버 쪽에서 grace period를 두기로 했다. 토큰을 used로 옮긴 뒤 10초간은 같은 토큰을 한 번 더 받아도 reuse로 처리하지 않는다. 대신 그 사이에 발급된 가장 최근 토큰을 돌려준다. 이렇게 하면 race로 들어온 두 번째 요청이 같은 새 토큰을 받게 된다.

# Silent Refresh 적용 후 - grace window 10초
async def rotate_refresh_token(token: str):
    payload = decode(token)
    family_id = payload["fid"]
    used_at = await redis.get(f"used_at:{family_id}:{hash(token)}")

    if used_at:
        # grace window 안이면 마지막 발급 토큰을 재반환
        if time.time() - float(used_at) < 10:
            return await redis.get(f"latest:{family_id}")
        # grace 지난 재사용은 진짜 탈취 시그널로 본다
        await revoke_family(family_id)
        raise TokenReuseDetected()

    pipe = redis.pipeline()
    pipe.set(f"used_at:{family_id}:{hash(token)}", time.time(), ex=600)
    new_token = issue_token(family_id)
    pipe.set(f"latest:{family_id}", new_token, ex=10)
    await pipe.execute()
    return new_token

즉, 10초라는 숫자는 임의가 아니다. 모바일 네트워크 RTT 최악 케이스(3G에서 WebView 재시작 직후) 측정값이 6~8초 사이였고, 거기에 마진을 더한 값이다. 더 줄이면 race가 새지 않고, 늘리면 진짜 탈취 시도와 정상 race를 구분하기가 어려워진다.

reuse_detected 알람이 일 평균 87건에서 6건으로 떨어졌다. 단 6건이 진짜 공격인지, 아니면 grace를 넘긴 탭 중복인지를 구분할 방법이 또 필요했다. 6건 중 절반 정도는 여전히 정상 사용자였다.

3개월 차 — 탈취 자동 감지와 false positive

진짜 탈취와 false positive를 구분하려면 단일 시그널로는 부족했다. User-Agent, IP 대역, ASN, 디바이스 핑거프린트를 같이 봤다. 6주간 운영 데이터로 정리한 시그널 조합 비교는 아래와 같다.

시그널 조합 탐지율(대략) False positive(대략)
토큰 reuse 단독 100% 11.2%
reuse + IP 대역 일치 시 통과 96% 3.4%
reuse + IP + User-Agent 일치 통과 91% 1.1%
reuse + IP + UA + ASN 일치 통과 88% 0.4%

그러나, 수치는 우리 서비스 사용 패턴 기준이라 절대값보다 상대 변화에 의미가 있다. 모바일 트래픽 비중이 높은 서비스라 ASN 신호가 잘 작동했고, B2B 데스크톱 위주 서비스였다면 결과가 다르게 나왔을 가능성이 크다. 같은 사무실에서 다 같이 쓰는 환경이면 ASN과 IP가 거의 동일해서 추가 정보가 안 된다.

팀이 고른 건 두 번째 줄, IP 대역 일치 시 통과였다. 모바일 캐리어가 IP를 자주 바꾸기 때문에 /24 범위가 아니라 ASN+국가 조합으로 묶었다. 진짜 공격을 4% 놓치는 건 아쉽지만, 정상 사용자를 강제 로그아웃시키는 비용이 더 크다고 봤다. 보안 트레이드오프는 결국 "어느 쪽 비용이 더 큰가"의 문제다.

그러나, 여기서 의외였던 점 하나. 디바이스 핑거프린트는 효과가 거의 없었다. WebView 안에서 fingerprint가 매 세션 바뀌는 케이스가 많았고, 같은 사용자도 알림 클릭 → 앱 → 외부 브라우저로 흐를 때 매번 다른 값이 나왔다. (개인적으로 fingerprint는 봇 차단에는 쓸 만했지만, 세션 검증 신호로는 부적합한 것으로 보인다.)

운영 들어가서 터진 의외의 것

따라서, 설계대로 다 만들었는데도 한 달 정도 더 부순 것들이 있다. 짧게만 적는다.

결국, 첫째, Redis fail-over 중에 family_id가 사라졌다. AOF 동기화 간격이 1초였는데, 그 사이에 끊어진 트랜잭션이 family를 잃어버렸다. 모든 사용자가 한꺼번에 다시 로그인해야 했다. AOF를 always로 바꾸고, 핵심 키만 PostgreSQL에 미러링하도록 추가 작업을 넣었다. write 지연이 살짝 늘었지만 인증 경로라 받아들였다.

둘째, Refresh 엔드포인트가 외부 봇의 표적이 됐다. Rotation을 도입하면 Refresh가 가장 자주 호출되는 인증 엔드포인트가 된다. WAF rate limit을 IP당 분당 30회로 잡았는데 정상 사용자도 가끔 막혔다. user_id 단위 제한과 IP 단위 제한을 따로 걸어서 풀었다. 모바일 캐리어 NAT을 고려하면 IP만 보면 안 된다.

반면, 셋째, 로그아웃이 제대로 안 됐다. 로그아웃은 family를 폐기해야 하는데, 초기 구현은 access token만 만료시켰다. Refresh Token을 들고 있는 누군가는 새 access token을 계속 받아갔다. 로그아웃 시 family revoke를 명시적으로 부르도록 고쳤다. 이건 보안 감사 재실사에서 잡혔다.

넷째, 모니터링 대시보드가 없으면 위 사건들을 다 놓친다. Grafana에 reuse_detected, family_revoked, refresh_qps 세 지표를 패널로 띄워두고 알림 임계값을 분당 20건으로 잡았다. 임계값은 정상 트래픽 기준선의 4배 수준에서 시작해 운영하며 조정했다.

언제 쓰고 언제 안 쓰는지

이처럼, 3개월 굴리고 정리한 판단 기준이다.

특히, Rotation을 쓰는 게 맞는 상황: SPA나 모바일 앱이라 토큰을 클라이언트가 들고 있어야 하는 구조. Refresh Token 수명이 7일 이상으로 긴 정책. 보안 감사 요건상 reuse 감지가 필요한 조직. 셋 중 하나라도 해당되면 도입할 만하다. XSS 노출 면적이 큰 SPA에서는 가장 효과가 분명한 보호 수단이다.

굳이 안 써도 되는 상황: 서버사이드 세션을 쿠키로만 다루는 전통적 웹앱. 세션 ID 자체가 서버에서 무효화 가능하므로 Rotation의 추가 이득이 작다. 또 단일 backend-for-frontend 구조라면 BFF에서 세션을 관리하고 Refresh Token을 외부에 노출 안 해도 된다. Refresh Token 수명이 1~2시간 정도로 짧고 사용자 재로그인 비용이 낮은 내부 도구라면, Rotation 없이 짧은 만료만으로도 충분한 경우가 많다.

그런데, 지금 도입을 시작한다면 즉시 실행할 만한 것 세 가지. 첫째, grace window를 처음부터 5~10초로 잡고 시작해라. 0초로 시작하면 우리처럼 알람이 한 시간에 87건 뜬다. 둘째, family revoke 시 사용자에게 사유를 별도 코드로 내려줘라. 클라이언트가 "재로그인 필요(보안 알림)"와 "세션 만료(자동 갱신 실패)"를 구분해서 메시지를 보여줘야 CS 부담이 줄어든다. 셋째, false positive를 줄일 때는 reuse 단독 시그널 대신 IP+ASN 조합을 같이 봐라. 우리 서비스 기준으로는 11%대였던 false positive가 0.4%까지 떨어졌다.

결국, 남은 영역은 모바일 SDK의 백그라운드 fetch 시 토큰 캐시 동기화이고, 현재는 SharedPreferences/Keychain 기반 in-house SDK로 운영 중인 상태다.

관련 글