4 min read

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");
}

서버 로그아웃 요청이 실패하더라도 클라이언트 정리는 진행합니다. 네트워크 에러 때문에 로그아웃을 못 하면 사용자 경험이 더 나쁘니까요.


참고