Ari.dev

토큰이 만료되자 요청 다섯 개가 동시에 튕겼다 — 401 리프레시 레이스를 인터셉터 큐로 잡은 이야기

액세스 토큰이 만료되는 순간 화면이 동시에 부른 API들이 전부 401을 받고, 각자 토큰 리프레시를 호출하면서 중복 갱신·무효화 충돌로 로그아웃이 튀었다. '리프레시는 한 번만, 나머지는 기다렸다 재시도'하는 인터셉터 큐로 무중단 인증을 만든 과정.

동시성

화면 하나가 뜰 때 API를 다섯 개쯤 동시에 부르는 건 흔하다. 문제는 그 다섯 개가 하필 토큰 만료 경계에서 동시에 나갈 때 생겼다. 다섯 개가 전부 동시에 401을 받았다.

문제 — 동시 401이 리프레시 레이스를 만든다

액세스 토큰이 만료된 직후, 병렬로 나간 요청들은 각자 401을 받는다. 여기까진 정상이다. 문제는 그다음이다.

각 요청이 "401 받았으니 토큰을 갱신하고 재시도하자"라고 각자 리프레시를 호출하면:

  • 리프레시가 여러 번 중복 실행된다. (요청 수만큼)
  • 리프레시 API가 회전(rotation) 방식이면, 먼저 갱신된 토큰을 나중 리프레시가 무효화한다.
  • 그 결과 어떤 요청은 이미 무효가 된 토큰으로 재시도해 다시 깨지고, 로그아웃이 튀거나 화면이 반쯤 실패한 상태가 된다.

즉 "토큰 만료"라는 정상 상황이, 동시성 때문에 로그아웃 사고로 번졌다.

선택지 — 세 갈래

방식문제
A401마다 각자 리프레시중복 리프레시·레이스·무효화 충돌 — 문제 그 자체
B만료 시간을 미리 재서 선제 갱신서버/클라 시계 오차와 네트워크 지연 때문에 100% 못 막음. 보조책은 되나 단독으론 불완전
C응답 인터셉터에서 리프레시를 1회로 직렬화 + 대기 요청 큐잉첫 401만 리프레시하고, 그 사이 들어온 401들은 큐에서 기다렸다 새 토큰으로 일괄 재시도

A가 지금의 버그였다. B는 그럴듯하지만 함정이 있다 — 만료를 "미리" 알려면 토큰 만료 시각을 신뢰해야 하는데, 시계 오차와 네트워크 지연 때문에 만료 순간을 정확히 못 맞춘다. 선제 갱신은 대부분을 줄여 주는 보조책일 뿐, 동시 401 자체를 없애지는 못한다.

그래서 C를 택했다.

선택 근거 — "리프레시는 한 번만, 나머지는 기다렸다 재시도"

핵심 문장은 이거다.

첫 번째 401만 리프레시를 실행하고, 그 사이 들어온 나머지 401들은 큐에 넣고 기다렸다가, 갱신이 끝나면 새 토큰으로 한꺼번에 재시도한다.

응답 인터셉터에 **"리프레시가 진행 중인가"**를 나타내는 상태를 하나 두는 게 전부다. 진행 중이면 새로 들어온 401 요청은 자기가 리프레시를 또 부르지 않고, 진행 중인 리프레시의 완료를 기다렸다가 원래 요청을 다시 보낸다. 이른바 single-flight(같은 작업은 동시에 한 번만) 패턴이다.

// 개념 스케치 — 진행 중 리프레시를 공유(single-flight)
let refreshing = null;               // 진행 중인 리프레시 Promise (없으면 null)

onResponseError(원요청, error):
  if error.status !== 401: throw error
  if (!refreshing) refreshing = doRefresh().finally(() => refreshing = null);
  await refreshing;                  // 동시 401들은 '같은' 리프레시 하나를 기다림
  return retry(원요청);              // 끝나면 새 토큰으로 원요청 재전송

여기서 중요한 건 refreshing공유한다는 점이다. 다섯 개의 401이 들어와도 리프레시 Promise는 하나만 만들어지고, 나머지 넷은 그 하나를 await한다. 리프레시는 정확히 1회, 재시도는 다섯 개 전부 — 사용자에게는 끊김 없는(무중단) 인증으로 보인다.

무한 루프와 실패를 막는 두 개의 가드

큐잉만으로 끝내면 안 된다. 두 가지를 더 막아야 한다.

  • 리프레시 자체가 실패하면 — 큐에서 기다리던 요청을 전부 reject하고 로그인 화면으로 보낸다. 어설프게 재시도하지 않는다.
  • 재시도한 요청이 또 401이면 — 재시도는 1회로 제한하고, 그래도 401이면 바로 로그아웃 처리한다. "재시도 → 401 → 다시 리프레시 → 재시도…" 무한 루프를 원천 차단한다.

그럼 B(선제 갱신)는 버렸나 — 아니, 역할이 다르다

B를 아예 버린 건 아니다. 다만 B는 동시 401을 없애는 수단이 아니라 줄이는 수단이다. 선제 갱신은 평상시 만료를 미리 처리해 401 발생 빈도를 낮추고, C는 그럼에도 실제로 401을 받은 순간을 확실하게 처리한다. 확실성이 필요한 자리는 "만료를 예측하는" 요청 단계가 아니라 "401을 실제로 받은" 응답 단계였다.

정리

지표before(각자 리프레시)after(인터셉터 큐)
동시 401 시 리프레시 횟수요청 수만큼(N번)1번
토큰 무효화 충돌발생없음(single-flight)
사용자 경험로그아웃 튐·부분 실패무중단(대기 후 일괄 재시도)
무한 재시도위험1회 제한 + 실패 시 로그아웃

돌아보면 이건 "인증" 문제라기보다 동시성 문제였다. 토큰 만료 자체는 정상 흐름이고, 그걸 사고로 키운 건 여러 요청이 같은 일(리프레시)을 동시에 하려던 것이었다. 해법도 인증 로직이 아니라 동시성 제어 — **"같은 작업은 한 번만, 나머지는 그 결과를 공유한다"**는 single-flight였다. 비슷한 냄새가 나는 자리(중복 요청, 캐시 stampede 등)에 두루 쓰이는 패턴이라, 한 번 손에 익혀 둘 값어치가 있었다.

댓글