Singleton Promise 기반 JWT 토큰 갱신 구현

작성일: 2026년 8월 2일 AM 05:52
조회수: 20

thumbnail

보호 라우트에 들어갈 때마다 /auth/refresh가 두 번씩 찍혔습니다. 하나면 충분한 요청이었고, 기능은 멀쩡히 동작했기 때문에 한동안 넘어갔습니다. 그런데 백엔드가 Refresh Token Rotation을 쓰기 시작하면서 이야기가 달라졌습니다. 두 번째 요청이 이미 무효화된 토큰을 들고 가면서 사용자가 강제로 로그아웃되는 일이 생겼습니다. 이 글은 그 중복 호출을 하나로 모은 과정에 대한 기록입니다.

1. 문제 상황

이중 refresh 호출

보호 라우트에 진입하면 세션 검증 훅이 refresh를 호출합니다. 그런데 같은 시점에 자식 컴포넌트의 API 요청이 401을 받으면 Axios 인터셉터에서도 refresh를 호출합니다. 서로를 모르는 두 진입점이 같은 일을 하는, 전형적인 Race Condition입니다.

라우트 전환으로 인한 재호출

토큰이 만료된 상태로 보호 라우트에 접근해 refresh가 실패하면 로그인 페이지로 리다이렉트됩니다. 이때 게스트 레이아웃이 마운트되면서 동일한 세션 검증 훅이 다시 실행됩니다. 스토어가 비어 있으니 refresh가 한 번 더 나갑니다. 사용자 입장에서는 "첫 호출 직후 짧은 간격을 두고 발생하는 두 번째 호출"로 보입니다.

서버 부하 그리고 토큰 경합

여기까지는 불필요한 요청 하나가 더 나가는 정도의 문제입니다. 하지만 백엔드가 Refresh Token Rotation(한 번 쓴 토큰은 즉시 무효화) 정책을 쓴다면 성격이 완전히 달라집니다. 동시에 두 번 호출되면 한쪽 요청이 이미 무효화된 토큰을 들고 가게 되고, 그 요청은 실패합니다. 결과적으로 멀쩡히 로그인해 있던 사용자가 튕겨 나갑니다.

2. 해결 과정

Step 1: Singleton Promise 패턴 도입

진행 중인 refresh 요청의 Promise 인스턴스를 모듈 레벨 변수에 보관하고, 이미 진행 중인 요청이 있으면 새로 만들지 않고 기존 Promise를 그대로 반환하도록 했습니다.

여기서 한 가지 짚고 넘어갈 부분이 있습니다. 처음에는 isRefreshing 같은 boolean 플래그면 충분하다고 생각했는데, 그렇지 않았습니다. 플래그는 "지금 진행 중이다"만 알려줄 뿐, 두 번째 호출자에게 결과를 돌려줄 방법이 없습니다. 두 번째 호출자는 기다렸다가 다시 확인하거나, 그냥 실패로 처리하는 수밖에 없습니다. Promise 자체를 캐시해야 모든 호출자가 같은 결과를 await 할 수 있습니다.

Step 2: 단일 진입점으로 캡슐화

세션 검증 가드와 401 인터셉터가 모두 refreshSession()이라는 하나의 함수만 바라보도록 설계했습니다. 중복 방지 로직은 함수 안에 감춰두고, 호출하는 쪽은 그냥 부르기만 하면 됩니다. 호출자가 "내가 먼저인지 아닌지"를 신경 쓰기 시작하면 그 판단이 여러 곳에 흩어지기 때문입니다.

Step 3: 실패 직후 재호출 방지

refresh가 실패한 직후 로그인 페이지로 넘어가면서 발생하는 즉시 재호출은 Singleton Promise만으로는 막히지 않습니다. 첫 Promise가 이미 완료되어 초기화된 뒤이기 때문입니다. 그래서 응답에 refresh 토큰의 만료 기한을 포함시키고, 세션 검증 훅에서 최근 시도 기록이 있으면 일정 시간 동안 재요청을 건너뛰도록 했습니다.

3. 최종 코드

로우 레벨 API와 Singleton Promise가 적용된 공개 API를 분리했습니다. finally 블록에서 refreshPromise를 초기화해, 작업이 끝난 뒤에는 새 요청을 정상적으로 수행할 수 있게 했습니다.

typescript
// lib/auth/refresh-session.ts

interface RefreshResponse {
  accessToken: string;
  refreshExpiresAt: string;
}

async function requestRefresh(): Promise<RefreshResponse> {
  const response = await apiClient.post<RefreshResponse>(REFRESH_ENDPOINT);
  return response.data;
}

/**
 * 진행 중인 refresh 요청의 Promise.
 * Singleton Promise 패턴으로 중복 호출을 방지한다.
 */
let refreshPromise: Promise<RefreshResponse> | null = null;

export function refreshSession(): Promise<RefreshResponse> {
  // 1. 이미 진행 중인 요청이 있으면 그 Promise를 그대로 반환
  if (refreshPromise) return refreshPromise;

  // 2. 요청 시작: Promise를 모듈 변수에 보관
  refreshPromise = requestRefresh().finally(() => {
    // 3. 성공/실패와 무관하게 완료되면 초기화
    refreshPromise = null;
  });

  return refreshPromise;
}

finally를 쓴 이유는 실패했을 때도 반드시 초기화해야 하기 때문입니다. then에만 초기화를 두면 한 번 실패한 순간 refreshPromise가 실패한 Promise를 붙들고 있게 되고, 이후 모든 호출자가 그 실패를 그대로 받습니다.

4. 결과

동시 호출이 하나로 모였습니다. 여러 곳에서 동시에 refreshSession()을 호출해도 실제 네트워크 요청은 한 번만 발생하고, 모든 호출자가 같은 결과를 공유합니다.

라우트 전환 시 이중 호출이 사라졌습니다. 리다이렉트 과정에서 생기던 불필요한 요청과 토큰 경합이 없어지면서, 로그인이 임의로 풀리는 현상도 함께 해결됐습니다.

표준적인 설계를 따랐습니다. Go의 singleflight가 정확히 같은 문제를 같은 방식으로 풉니다. 직접 고안한 구조가 아니라 이미 검증된 패턴이라는 점이, 나중에 이 코드를 읽을 사람에게도 설명하기 쉬운 근거가 됐습니다.

0개의 댓글
💬

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!