Refresh Token이 간헐적으로 실패하는 이유 — Promise 공유 패턴으로 Race Condition 해결하기

Dam·2026년 2월 13일

[Project]

목록 보기
1/5

문제의 시작: 간헐적 로그아웃

운영 중인 서비스에서 간헐적으로 사용자가 강제 로그아웃되는 현상이 발생했다. 매번 그런 건 아니고, 한번씩 불규칙하게 발생하는 종류의 버그였다. 로그를 확인해보면 refresh token 갱신이 실패하면서 로그아웃 처리가 되고 있었다.

결론부터 말하면, refreshAccessToken() 함수가 동시에 여러 번 호출되면서 발생하는 Race Condition이 원인이었고, Promise 공유 패턴을 적용하여 해결했다.


1. 프로젝트의 인증 구조

우리 프로젝트는 Next.js 기반 모노레포다. API 호출 경로가 두 가지 존재하는데, 이 이중 구조를 먼저 이해해야 문제가 보인다.

경로 1: axios — 외부 백엔드 호출

브라우저 ──axios──→ Java 백엔드 (api.panzy.site)

Swagger로 자동 생성된 API 클라이언트가 axios 인스턴스를 사용한다. Authorization 헤더에 토큰을 넣어서 보내고, response interceptor가 401 응답을 가로채서 토큰 갱신과 재시도를 자동 처리한다.

// http-client.ts — response interceptor
const errorHandler = async (err: AxiosError) => {
  if (err.response?.status === 401 && !originalRequest._retry) {
    const retryResponse = await handle401Error(err, originalRequest);
    return transformResponse(retryResponse);
  }
};

경로 2: authFetch — 내부 Next.js API Route 호출

브라우저 ──fetch──→ Next.js 서버 (/api/host/...)

Next.js API Route는 같은 도메인이라 쿠키가 자동 전달된다. 그래서 axios 대신 fetch + credentials: 'include'를 사용하는데, authFetch는 이 fetch에 토큰 만료 체크와 갱신을 붙인 래퍼다.

// authFetch.ts
export const authFetch = async (input, init) => {
  await ensureValidToken(); // 만료 시 refreshAccessToken() 호출

  let response = await fetch(input, fetchInit);

  if (response.status === 401) {
    await refreshAccessToken();
    response = await fetch(input, fetchInit);
  }

  return response;
};

왜 이런 이중 구조가 생겼을까

의도적으로 설계한 것이 아니라, 현실적인 제약 때문에 자연스럽게 생긴 이중 구조다.

  • Swagger 자동 생성 코드가 axios 기반
  • Next.js API Route 호출은 쿠키 전달 때문에 fetch가 필요
  • 각각 다른 대상을 호출하는 정당한 이중 구조

그리고 두 경로는 공통적으로 refreshAccessToken()을 호출한다. 여기서 문제가 시작된다.

authFetch에 대한 오해

authFetch라는 이름 때문에 로그인 관련 함수로 오해할 수 있다. 실제로는 이름의 "auth"는 "이 fetch에는 인증 처리가 포함되어 있다"는 뜻이다.

// 일반 fetch — 토큰 만료됐으면 그냥 401 에러
await fetch('/api/host/policies/translate', { credentials: 'include' });

// authFetch — 토큰 만료됐으면 자동으로 갱신 후 요청
await authFetch('/api/host/policies/translate', { ... });

axios에는 interceptor가 있어서 요청/응답을 가로채 토큰 갱신을 자동 처리할 수 있다. fetch에는 이 기능이 없으니, authFetch가 interceptor 역할을 직접 구현한 것이다.

실제 사용처를 보면 전부 Next.js API Route 호출이고, 로그인이 아닌 일반 비즈니스 로직이다.

await authFetch('/api/host/resources', { method: 'POST', body: ... });           // 텍스트 리소스 저장
await authFetch('/api/host/policies/translate', { method: 'POST', body: ... });  // AI 번역 요청
await authFetch(`/api/host/readiness/${id}/step`, { method: 'POST', ... });      // 오픈 준비도 저장

2. 문제 발견: "한번씩" 로그아웃되는 현상

증상은 단순했다. 사용자가 한동안 앱을 사용하지 않다가 돌아와서 조작하면, 간헐적으로 로그인 페이지로 튕기는 것이다.

처음에는 refresh token 자체가 만료된 건가 의심했지만, 토큰 유효기간은 충분히 길었다. 항상 발생하는 것도 아니었다.

재현 조건을 좁혀보니, access token이 만료된 상태에서 동시에 여러 API를 호출하는 상황에서만 발생했다. React 환경에서는 이런 상황이 흔하다. 페이지 진입 시 useEffect에서 여러 fetch가 동시에 실행되거나, React Query / SWR이 병렬 요청을 보내는 경우다.


3. 원인 추적: 동시성 보호의 사각지대

handle401Error()의 코드를 보면, 동시 호출에 대한 보호가 잘 되어 있다.

let isRefreshing = false;
let failedQueue = [];

export const handle401Error = async (error, originalRequest) => {
  if (isRefreshing) {
    return new Promise((resolve, reject) => {
      failedQueue.push({ resolve, reject });
    }).then((token) => {
      originalRequest.headers["Authorization"] = `Bearer ${token}`;
      return axios(originalRequest);
    });
  }

  isRefreshing = true;
  try {
    const newToken = await refreshAccessToken();
    processQueue(null, newToken);
    return axios(originalRequest);
  } catch (refreshError) {
    processQueue(refreshError, null);
    clearTokens(true);
  } finally {
    isRefreshing = false;
  }
};

isRefreshing 플래그와 failedQueue로 동시에 여러 401이 발생해도 실제 갱신은 한 번만 하고, 나머지는 대기했다가 새 토큰을 받는다. 이 경로만 놓고 보면 완벽하다.

문제는 이 보호가 handle401Error 내부에만 존재한다는 점이었다.

authFetchhandle401Error를 거치지 않고 refreshAccessToken()을 직접 호출한다.

const ensureValidToken = async () => {
  if (isTokenExpiredClient()) {
    await refreshAccessToken(); // 직접 호출 — isRefreshing 체크 없음
  }
};

정리하면:

  • axios 경로 → handle401Error보호됨
  • fetch 경로 → ensureValidToken보호 없이 직접 호출

4. Refresh Token Rotation이 만든 파급 효과

우리 백엔드는 refresh token rotation을 적용하고 있었다. refresh token을 사용하면 새 토큰을 발급하고, 기존 토큰은 즉시 무효화하는 방식이다.

동시 호출이 발생하면 이런 시나리오가 된다.

시간 →

[axios 요청]  : ─── 401 ──→ handle401Error ──→ refreshAccessToken() ──→ 성공 (OLD token 무효화)
[authFetch]   : ─── 만료감지 ─────────────────→ refreshAccessToken() ──→ 실패 (이미 무효화된 token)
                                                 ↑
                                            동시에 2회 호출
                                            같은 OLD refresh token 전송
  1. 두 경로가 동시에 같은 OLD refresh token으로 갱신 요청
  2. 첫 번째 호출이 성공 → 새 토큰 발급, OLD token 무효화
  3. 두 번째 호출은 이미 무효화된 OLD token을 전송 → 서버가 거부
  4. 갱신 실패 → clearTokens(true) → 강제 로그아웃

"한번씩" 발생하는 이유도 이걸로 설명됐다. access token이 만료된 상태에서 동시에 여러 API 호출이 있어야만 재현되기 때문이다.


5. 해결 방향: 보호는 어디에 둘 것인가

선택지 A: 각 호출부에 보호 추가

authFetch에도 isRefreshing 체크를 넣는 방법이다.

const ensureValidToken = async () => {
  if (isTokenExpiredClient() && !isRefreshing) {
    await refreshAccessToken();
  }
};

하지만 이 방식은 근본적인 해결이 아니다. 또 다른 호출 경로가 추가되면 같은 실수를 반복할 수 있다. 보호가 분산되어 있으면 누락 가능성이 항상 존재한다.

선택지 B: refreshAccessToken() 자체에 보호 추가

어디서 몇 번을 호출하든 실제 API 요청은 1번만 발생하도록 함수 자체를 보호하는 방법이다.

이 방식을 선택했다. 보호해야 할 것은 호출부가 아니라 공유 자원 자체다.


6. 구현: Promise 공유 패턴

let refreshPromise: Promise<string> | null = null;

export const refreshAccessToken = async (): Promise<string> => {
  // 이미 갱신 중이면 진행 중인 Promise를 그대로 반환
  if (refreshPromise) {
    return refreshPromise;
  }

  // 최초 호출 — 실제 갱신 시작
  refreshPromise = doRefreshAccessToken();

  try {
    return await refreshPromise;
  } finally {
    refreshPromise = null;
  }
};

const doRefreshAccessToken = async (): Promise<string> => {
  const { accessToken, refreshToken } = getTokensFromCookie();

  if (!refreshToken) {
    clearTokens(true);
    throw new Error("No refresh token available");
  }

  const response = await axios.post(
    `${baseUrl}/v1/auth/refresh-token`,
    { accessToken, refreshToken },
  );

  const newAccessToken = response.data?.data?.accessToken;
  const newRefreshToken = response.data?.data?.refreshToken;

  if (!newAccessToken) {
    throw new Error("Failed to get new access token");
  }

  saveTokens(newAccessToken, newRefreshToken || refreshToken);
  return newAccessToken;
};

동작 흐름은 이렇다.

호출 1 → refreshPromise 없음 → doRefreshAccessToken() 실행 (실제 API 호출)
호출 2 → refreshPromise 있음 → 같은 Promise를 await (API 호출 없음)
호출 3 → refreshPromise 있음 → 같은 Promise를 await (API 호출 없음)
  ...
호출 1 완료 → 모든 호출자가 같은 새 토큰을 받음 → refreshPromise = null

7. 왜 Mutex가 아니라 Promise인가

기존 handle401Error에 이미 Mutex 스타일의 구현이 있었다.

// Mutex 패턴
let isRefreshing = false;
let failedQueue = [];

if (isRefreshing) {
  return new Promise((resolve, reject) => {
    failedQueue.push({ resolve, reject });
  });
}

isRefreshing = true;
try {
  const token = await doRefresh();
  failedQueue.forEach(p => p.resolve(token));
  return token;
} finally {
  failedQueue = [];
  isRefreshing = false;
}

이 패턴은 동작하지만 관리 포인트가 많다.

  • isRefreshing 플래그와 failedQueue 두 개의 상태를 동기화해야 한다
  • 큐를 수동으로 순회하면서 resolve/reject해야 한다
  • 큐 초기화를 빠뜨리면 메모리 누수가 생긴다
  • 에러 발생 시 큐의 모든 항목에 reject를 전파하는 코드를 직접 작성해야 한다

Promise 공유 패턴은 이 모든 것을 JavaScript Promise의 기본 동작에 위임한다.

// Promise 공유 패턴
let refreshPromise: Promise<string> | null = null;

if (refreshPromise) return refreshPromise;

refreshPromise = doRefresh();
try { return await refreshPromise; }
finally { refreshPromise = null; }

핵심 원리는 JavaScript의 Promise가 settled(resolve 또는 reject) 된 이후에 await해도 같은 결과를 즉시 반환한다는 점이다. 별도 대기열 없이, 같은 Promise 객체를 여러 곳에서 await하는 것만으로 결과가 공유된다. 에러도 자동 전파된다.

Mutex 패턴Promise 공유 패턴
관리 상태2개 (isRefreshing + queue)1개 (refreshPromise)
대기열 관리수동 (push → forEach → 초기화)불필요
에러 전파수동으로 reject 호출자동 (같은 Promise를 await)
코드 라인약 20줄약 8줄

정리

이 버그의 본질은 단순했다. 동시성 보호가 handle401Error 내부에만 있었고, authFetch가 그 보호를 우회해서 refreshAccessToken()을 직접 호출하고 있었다. 공유 자원에 대한 보호가 빠져 있었기 때문에, 간헐적인 로그아웃이라는 형태로 드러났다.

보호는 호출부가 아닌 공유 자원에 걸어야 한다. 호출부마다 보호를 추가하는 건 누락 가능성이 있다. refreshAccessToken() 자체에 deduplication을 넣으면 어디서 호출하든 안전하다.

간헐적 버그는 동시성을 의심하라. "한번씩 발생"이라는 증상은 Race Condition의 전형적인 신호다. 특히 토큰 갱신처럼 여러 경로에서 호출되는 함수는 동시 호출 시나리오를 반드시 검토해야 한다.

Promise 공유 패턴은 JavaScript에서 async 함수의 deduplication에 가장 간결한 방법이다. 복잡한 Mutex보다 Promise의 기본 동작을 활용하는 편이 더 단순하고 안전한 선택이 될 수 있다.

profile
🌐 DOM 위에서 살아남기

0개의 댓글