조용진의 개발 블로그
← 글 목록

동시 401 요청을 하나의 토큰 갱신 Promise로 합친 방법

인증토큰 갱신동시성TypeScript

여러 요청에서 동시에 발생한 401 응답이 토큰 갱신을 중복 실행하지 않도록 Promise를 공유하고 재시도 횟수를 제한한 과정을 정리합니다.

여러 API 요청이 동시에 만료된 토큰을 사용하면 응답 인터셉터에 401이 한꺼번에 들어온다. 각 요청이 독립적으로 토큰 갱신을 호출하면 동일한 refresh token으로 여러 요청이 경쟁하고, 일부 실패가 자동 로그아웃으로 이어질 수 있다.

Boolean 잠금 대신 진행 중인 Promise를 공유했다

구현은 토큰 종류별로 진행 중인 갱신 Promise를 저장하는 Map을 사용했다.

아래 코드 예시는 설명을 위해 식별자와 운영 설정값을 일반화했습니다.

type SessionKind = "account" | "organization";
const pendingRefreshes = new Map<SessionKind, Promise<string>>();
export function shareRefresh(kind: SessionKind, renew: () => Promise<string>) {
  const pending = pendingRefreshes.get(kind);
  if (pending) return pending;
  const task = renew().finally(() => pendingRefreshes.delete(kind));
  pendingRefreshes.set(kind, task);
  return task;
}

첫 번째 401만 실제 갱신 함수를 실행한다. 같은 토큰 종류에서 뒤이어 들어온 요청은 새 갱신을 시작하지 않고 동일한 Promise를 기다린다. 갱신이 끝나면 finally에서 Map 항목을 제거해 다음 만료 시 새 작업을 시작할 수 있게 했다.

서로 다른 인증 주체의 토큰을 같은 키로 묶지 않았다. 각 주체의 갱신은 병렬로 진행할 수 있지만, 동일 주체 안에서는 한 요청으로 합쳐야 한다.

갱신된 토큰으로 원래 요청을 다시 보냈다

인터셉터는 갱신 함수가 반환한 access token을 저장하고 실패했던 Axios 요청의 Authorization 헤더를 교체해 재전송했다.

const accessToken = await shareRefresh("account", renewSession);
saveAccessToken(accessToken);
return retryRequest({ ...request, accessToken });

이 방식은 대기 중인 각 요청이 같은 새 토큰을 받으면서도 자신의 원래 config로 다시 실행되게 한다.

일시적인 네트워크 실패에는 제한된 백오프를 적용했다

갱신 실패에는 제한된 재시도와 점진적인 대기 시간을 적용했다.

for (let attempt = 0; attempt < retryLimit; attempt++) {
  try { return await renewSession(); }
  catch (error) {
    if (attempt === retryLimit - 1) throw error;
    await wait(backoffFor(attempt));
  }
}

갱신이 최종 실패해 로그아웃할 때는 서버와 기기에 남은 알림 토큰 정리도 함께 수행했다. 인증 세션만 지우고 알림 식별자를 남기지 않기 위한 후처리다.

flowchart TD
  A[여러 요청에서 401 발생] --> B{같은 토큰 갱신 중?}
  B -- 예 --> C[진행 중 Promise 대기]
  B -- 아니오 --> D[갱신 요청 시작]
  D --> E[새 토큰 저장]
  C --> F[원래 요청 재전송]
  E --> F

핵심은 Mutex라는 이름이 아니라 동시 요청이 하나의 결과를 공유하도록 만든 것이다. 인증 오류를 각 요청의 독립 사건으로 처리하지 않고 토큰 종류별 단일 작업으로 모델링하자 중복 갱신과 경쟁 조건을 함께 줄일 수 있었다.