Single Flight 동시 요청 안전하게 처리하기
프론트엔드에서 인증을 구현하다 보면, 액세스 토큰이 만료된 상태에서 여러 요청이 동시에 401 응답을 받는 상황을 흔히 마주하게 됩니다. 이 글에서는 그 상황이 발생하는 원인부터 해결 방법까지 차례대로 설명합니다.
1. 토큰 구조의 이해
대부분의 현대 웹 서비스는 두 종류의 토큰을 조합해서 인증을 구현합니다.
토큰 | 저장 위치 | 설명 |
|---|---|---|
액세스 토큰(AT) | 쿠키 또는 메모리 | 수명이 짧습니다. 자바스크립트에서 값을 읽어 |
리프레시 토큰(RT) | HttpOnly 쿠키 | 수명이 깁니다. 자바스크립트에서는 읽을 수 없고, 브라우저가 요청에 자동으로 실어 보냅니다. 재발급이 일어날 때마다 회전되므로, 직전에 사용한 리프레시 토큰은 즉시 무효화됩니다. |
여기서 중요한 부분은 리프레시 토큰 회전 정책(Refresh Token Rotation, RTR)입니다. 탈취된 리프레시 토큰이 다시 사용되는 상황을 막기 위해 도입한 정책입니다.
2. 재발급과 동시에 401
액세스 토큰이 만료된 상태에서 N개의 요청이 동시에 401응답을 받았을 때, 별다른 자리를 해 두지 않았다면 각 요청이 저마다 독립적으로 재발급을 시도합니다.
사용자는 아무런 잘못을 하지 않았는데도 로그아웃됩니다. B와 C가 요청을 보낸 시점에서 RT1은 분명히 유효했지만, A의 요청이 성공하면서 RT1이 함께 무효화되었기 때문입니다.
3. Single Flight
Single Flight는 분산 시스템에서 사용하는 개념으로, 동일한 요청이 중복 실행되지 않도록 나중에 들어온 호출을 이미 진행 중인 요청에 합류시키는 패턴입니다. 자바스크립트에서는 이 패턴을 어렵지 않게 구현할 수 있습니다.
/**
* 진행 중인 재발급 요청을 보관해서, 재발급이 동시에 두 번 나가지 않게 만드는 변수.
*
* 서버가 RT를 회전(rotate)시키면서 이전 RT를 즉시 무효화하기 때문에,
* 재발급이 동시에 N개 나가면 첫 번째만 성공하고 나머지는 세션을 파괴한다.
* 따라서 중복 제거는 최적화가 아니라 정확성 요건이다.
*/
let inflightReissue: Promise<boolean> | null = null;
export const requestReissue = (): Promise<boolean> => {
if (inflightReissue) return inflightReissue; // 이미 진행 중이면 그 요청에 합류한다
inflightReissue = (async () => {
try {
const response = await fetch('/auth/reissue', {
method: 'POST',
// RT는 HttpOnly 쿠키이므로 브라우저가 자동으로 실어 보낸다.
credentials: 'include',
});
if (!response.ok) return false;
const body = await response.json().catch(() => null);
if (!body?.accessToken) return false;
// 새 AT를 저장한다 (쿠키, 메모리 등 프로젝트에 맞게 선택한다).
saveAccessToken(body.accessToken, body.expiresIn);
return true;
} catch {
return false;
}
})();
// 성공하든 실패하든 변수를 비워서, 다음 만료 시점에 다시 갱신할 수 있게 한다.
return inflightReissue.finally(() => {
inflightReissue = null;
});
};동작을 단계별로 살펴보면 다음과 같습니다.
첫 번째 호출은
inflightReissue가 비어있음을 확인하고, 재발급 요청을 감싼Promise를 만들어 그 변수에 저장합니다. 실제 네트워크 요청은 이때 한 번만 발생합니다.뒤이어 들어온 호출들은
if(inflightReissue)조건에 걸려서, 이미 진행 중인Promise를 그대로 돌려받습니다. 재발급을 위한 새로운 요청은 생기지 않습니다.요청이 끝나면
finallty콜백이 변수를 다시null로 되돌리므로, 다음 만료 시점에 재발급을 새로 시도할 수 있습니다.
Single Flight를 적용하면 요청 순서는 다음과 같이 바뀝니다.
4. 단일 스레드와 뮤텍스
if(inflightReissue) 검사와 inflightReissue = ... 할당 사이에 awiat가 없습니다. 자바스크립트는 단일 스레드 이벤트 루프 위에서 동작하기 때문에, 중간에 제어권을 양보하지 않는 동기 구간은 원자적으로 실행됩니다. 따라서 검사와 할당 사이에 다른 호출자가 끼어들 수 없고, compare-and-set 같은 동기화 수단을 사용하지 않아도 충돌이 발생하지 않습니다.
반대로 여러 스레드가 동시에 실행되는 언어였다면, 이 코드는 안전장치 없이는 안전하게 동작하지 않습니다.
5. REST 클라이언트에 적용하기
API 호출을 공통 함수 하나로 추상화해 둔 프로젝트라면, requestReissue를 어렵지 않게 적용할 수 있습니다.
export const fetchApi = async <T>(
url: string,
options: RequestInit = {},
/** 재시도로 들어온 호출인지 여부. 401 → 재발급 → 재시도는 한 번만 수행한다. */
isRetry = false,
): Promise<T> => {
const token = getAccessToken();
const response = await fetch(url, {
...options,
headers: {
...options.headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
// AT 만료(401) → RT로 갱신 → 원래 요청 재시도.
if (
response.status === 401 &&
!isRetry && // 1. 재시도는 1회만 수행한다
url !== '/auth/reissue' && // 2. 재발급 요청 자신은 제외한다
typeof document !== 'undefined' // 3. SSR 환경에서는 시도하지 않는다
) {
if (await requestReissue()) {
return fetchApi<T>(url, options, true);
}
redirectToLogin();
}
if (!response.ok) throw new ApiError(response);
return response.json() as Promise<T>;
};세 가지 조건은 각각 다른 오류를 방어합니다.
재시도 횟수를 한 번으로 제한합니다. 새로 발급받은 토큰으로도
401응답을 받는다면 재발급을 다시 시도하지 않고 오류를 그대로 반환하며, 이렇게 해서 무한 반복을 막습니다.재발급 요청 자체가
401응답을 받은 경우에는 재발급을 시도하지 않으므로, 재귀 호출이 발생하지 않습니다.서버 사이드 렌더링이 진행되는 동안에는 브라우저 쿠키를 직접 조작할 수 없습니다. 이 경우에는 클라이언트에서 갱신을 시도하지 않고, 서버 미들웨어 계층에서 처리합니다.
6. 미들웨어
클라이언트의 Single Flight가 이미 발생한 401 응답에 대응하는 계층이라면, 미들웨어는 페이지를 이동하는 과정에서 401이 발생하기 전에 미리 토큰을 갱신하는 계층입니다.
// Next.js middleware.ts
const isExpiringSoon = !!refreshToken && (!accessToken || isAccessTokenExpiring(accessToken));
if (isExpiringSoon) {
const result = await reissueOnServer(refreshToken);
if (result.ok) {
// 새 AT와 RT를 응답 헤더에 설정해서 다음 요청에 자동으로 적용되게 한다.
return attachTokensToResponse(result);
}
}미들웨어에서는 실패의 종류를 구분하는 작업이 중요합니다. 리프레시 토큰이 완전히 무효화 된 상황과 서버가 일시적으로 응답하지 못하는 상황을 똑같이 취급하면, 다시 시도하면 회복되는 실패에도 사용자를 로그아웃 시키게 됩니다
7. 정리
계층 | 시점 | 방법 | 범위 |
|---|---|---|---|
미들웨어 | 페이지를 이동할 때 | 만료가 임박하면 미리 갱신 | SSR 페이지 전환 |
REST 클라이언트 | API가 | Single Flight로 재발급 시도 | 모든 REST 호출 |