SPA에서 JWT 자동 갱신과 동시 요청 레이스 컨디션
SPA에서 인증 처리는 한 번 만들면 잘 안 건드리는데, 제대로 안 만들면 사용자가 갑자기 로그아웃되는 상황이 반복됩니다. 특히 토큰 만료 시점에 여러 API 요청이 동시에 날아가면 문제가 복잡해집니다.
엔터프라이즈 SaaS 프론트엔드에서 겪은 인증 구조를 정리합니다.
토큰 구조
- accessToken: localStorage 저장, API 요청 시 Bearer 헤더로 첨부
- refreshToken: HttpOnly Cookie,
credentials: "include"로 자동 전송
accessToken은 짧은 수명(15분~1시간), refreshToken은 긴 수명(7일~30일). accessToken이 만료되면 refreshToken으로 갱신합니다.
자동 갱신의 기본 흐름
HTTP 클라이언트에서 응답을 가로채서 처리합니다:
async function request(config: RequestConfig) {
try {
return await fetch(config.url, { headers: { Authorization: `Bearer ${accessToken}` }, ... });
} catch (error) {
if (isTokenExpiredError(error)) {
await refreshAccessToken();
return await fetch(config.url, { headers: { Authorization: `Bearer ${newToken}` }, ... });
}
throw error;
}
}
단순합니다. 토큰 만료 에러가 오면 갱신하고 원래 요청을 재시도합니다.
문제: 동시 요청 레이스 컨디션
페이지 로드 시 API 요청이 5개 동시에 나갑니다. 토큰이 만료된 상태라면 5개 모두 401을 받습니다. 위 코드라면 refreshAccessToken()이 5번 호출됩니다.
refresh 엔드포인트가 토큰을 로테이션하는 경우(refresh token rotation), 두 번째 refresh 요청부터는 이미 폐기된 refreshToken으로 호출하게 됩니다. 서버가 이걸 탈취로 판단해서 전체 세션을 무효화할 수도 있습니다.
해결: 싱글턴 Promise
let refreshPromise: Promise<string> | null = null;
async function refreshAccessToken(): Promise<string> {
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
try {
const response = await fetch("/auth/refresh", { credentials: "include" });
const { accessToken } = await response.json();
setAccessToken(accessToken);
return accessToken;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
}
첫 번째 호출이 Promise를 만들면, 나머지 4개는 같은 Promise를 await합니다. refresh 요청은 한 번만 나가고, 모든 대기 중인 요청이 새 토큰을 받아서 재시도합니다.
finally에서 refreshPromise = null로 초기화하는 게 중요합니다. 다음 만료 시점에 다시 fresh한 요청을 보내야 하니까요.
앱 초기화 — 토큰 유효성 선검증
새로고침이나 앱 진입 시, localStorage에 accessToken이 있다고 바로 인증된 상태로 취급하면 안 됩니다. 서버에서 revoke됐을 수 있거든요.
async function initializeAuth() {
const token = getAccessToken();
if (!token) return redirectToLogin();
try {
const { member, organization } = await verifyToken(token);
setMemberState(member);
setOrganizationState(organization);
} catch {
removeAccessToken();
redirectToLogin();
}
}
verifyToken은 토큰을 서버에 보내서 유효한지 확인하면서 동시에 사용자 정보를 받아옵니다. 한 번의 요청으로 검증 + 데이터 로드를 합니다.
Route Guard 상태 머신
라우트 진입 시 6가지 상태로 분기합니다:
type AuthState =
| "allowed" // 접근 가능
| "authRequired" // 로그인 필요
| "organizationRequired" // 조직 선택 필요
| "serviceRequired" // 서비스 활성화 필요
| "blocked" // 권한 없음
| "unknown"; // 판단 불가 (네트워크 에러 등)
router.beforeEach(async (to) => {
const state = await evaluateAuthState(to);
switch (state) {
case "authRequired":
return { path: "/login", query: { redirect: to.fullPath } };
case "organizationRequired":
return { path: "/organizations" };
case "blocked":
return { path: "/403" };
case "allowed":
return true;
}
});
단순 로그인 여부뿐 아니라, 조직 선택 → 서비스 활성화 → 권한 확인까지의 multi-step 검증이 라우트 가드에서 일어납니다.
조직 변경 시 상태 리셋
멀티 조직을 지원하면 조직 전환 시 기존 데이터를 날려야 합니다:
async function switchOrganization(orgId: string) {
// 기존 상태 초기화
memberStore.$reset();
serviceStore.$reset();
menuStore.$reset();
// 새 조직 데이터 로드
await Promise.all([
fetchOrganization(orgId),
fetchServiceInfo(orgId),
fetchMenuPermissions(orgId),
]);
}
이걸 빼먹으면 A 조직의 데이터가 B 조직 화면에 보이는 보안 이슈가 됩니다.
IP 차단 대응
관리자가 특정 IP를 차단하면 모든 API 요청이 즉시 실패합니다. 이걸 각 요청마다 개별 처리하면 에러 메시지가 폭발하니까, HTTP 클라이언트 레벨에서 일괄 차단합니다:
if (isForbiddenIPError(error)) {
cancelAllPendingRequests();
router.push({ path: "/organizations", query: { reason: "ip_blocked" } });
return; // 이후 어떤 요청도 보내지 않음
}
로그아웃 프로세스
로그아웃은 단순 토큰 삭제가 아닙니다:
async function logout() {
await fetchLogout(); // 서버에 세션 무효화 요청
removeAccessToken(); // localStorage 정리
memberStore.$reset(); // Pinia store 초기화
organizationStore.$reset();
thirdPartySDK.logout(); // 외부 서비스(Zendesk 등) 로그아웃
router.push("/login");
}
서버 로그아웃 요청이 실패하더라도 클라이언트 정리는 진행합니다. 네트워크 에러 때문에 로그아웃을 못 하면 사용자 경험이 더 나쁘니까요.