운영 중인 서비스에서 간헐적으로 사용자가 강제 로그아웃되는 현상이 발생했다. 매번 그런 건 아니고, 한번씩 불규칙하게 발생하는 종류의 버그였다. 로그를 확인해보면 refresh token 갱신이 실패하면서 로그아웃 처리가 되고 있었다.
결론부터 말하면, refreshAccessToken() 함수가 동시에 여러 번 호출되면서 발생하는 Race Condition이 원인이었고, Promise 공유 패턴을 적용하여 해결했다.
우리 프로젝트는 Next.js 기반 모노레포다. API 호출 경로가 두 가지 존재하는데, 이 이중 구조를 먼저 이해해야 문제가 보인다.
브라우저 ──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);
}
};
브라우저 ──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;
};
의도적으로 설계한 것이 아니라, 현실적인 제약 때문에 자연스럽게 생긴 이중 구조다.
그리고 두 경로는 공통적으로 refreshAccessToken()을 호출한다. 여기서 문제가 시작된다.
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', ... }); // 오픈 준비도 저장
증상은 단순했다. 사용자가 한동안 앱을 사용하지 않다가 돌아와서 조작하면, 간헐적으로 로그인 페이지로 튕기는 것이다.
처음에는 refresh token 자체가 만료된 건가 의심했지만, 토큰 유효기간은 충분히 길었다. 항상 발생하는 것도 아니었다.
재현 조건을 좁혀보니, access token이 만료된 상태에서 동시에 여러 API를 호출하는 상황에서만 발생했다. React 환경에서는 이런 상황이 흔하다. 페이지 진입 시 useEffect에서 여러 fetch가 동시에 실행되거나, React Query / SWR이 병렬 요청을 보내는 경우다.
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 내부에만 존재한다는 점이었다.
authFetch는 handle401Error를 거치지 않고 refreshAccessToken()을 직접 호출한다.
const ensureValidToken = async () => {
if (isTokenExpiredClient()) {
await refreshAccessToken(); // 직접 호출 — isRefreshing 체크 없음
}
};
정리하면:
handle401Error → 보호됨ensureValidToken → 보호 없이 직접 호출우리 백엔드는 refresh token rotation을 적용하고 있었다. refresh token을 사용하면 새 토큰을 발급하고, 기존 토큰은 즉시 무효화하는 방식이다.
동시 호출이 발생하면 이런 시나리오가 된다.
시간 →
[axios 요청] : ─── 401 ──→ handle401Error ──→ refreshAccessToken() ──→ 성공 (OLD token 무효화)
[authFetch] : ─── 만료감지 ─────────────────→ refreshAccessToken() ──→ 실패 (이미 무효화된 token)
↑
동시에 2회 호출
같은 OLD refresh token 전송
clearTokens(true) → 강제 로그아웃"한번씩" 발생하는 이유도 이걸로 설명됐다. access token이 만료된 상태에서 동시에 여러 API 호출이 있어야만 재현되기 때문이다.
authFetch에도 isRefreshing 체크를 넣는 방법이다.
const ensureValidToken = async () => {
if (isTokenExpiredClient() && !isRefreshing) {
await refreshAccessToken();
}
};
하지만 이 방식은 근본적인 해결이 아니다. 또 다른 호출 경로가 추가되면 같은 실수를 반복할 수 있다. 보호가 분산되어 있으면 누락 가능성이 항상 존재한다.
어디서 몇 번을 호출하든 실제 API 요청은 1번만 발생하도록 함수 자체를 보호하는 방법이다.
이 방식을 선택했다. 보호해야 할 것은 호출부가 아니라 공유 자원 자체다.
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
기존 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 두 개의 상태를 동기화해야 한다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의 기본 동작을 활용하는 편이 더 단순하고 안전한 선택이 될 수 있다.