새로고침 시 로그아웃되는 문제 - Zustand store 대신 localStorage로 판별하여 해결

Melon Coder·2026년 2월 2일

Trouble shooting

목록 보기
8/13

배경

Next.js + TanStack Query + JWT 조합으로 사내툴을 만들고 있었다.
테스트를 위해 accessToken 만료 시간을 10초로 줄여 놓고, 메뉴 이동과 새로고침 시점에 refresh 로직이 잘 도는지 확인했다.

흐름은 이렇게 설계했다.

  • 토큰 만료
  • API 요청
  • 백엔드에서 401 반환
  • axios 인터셉터가 refresh 요청
  • 새 accessToken 발급 후 원래 요청 재시도

기본적인 흐름은 잘 동작했다.
문제는 “직원 관리 / 라이선스 관리” 페이지에서 몇 초 있다가 브라우저 새로고침을 하면, 갑자기 로그아웃되고 /login으로 튕기는 현상이었다.
바로 새로고침하면 괜찮고, 몇 초 지나서 새로고침하는 경우에만 문제가 터졌다.


원인

처음에는 인터셉터나 백엔드 refresh API를 의심했지만, 실제 원인은 “토큰이 있는지 판별하는 기준”이었다.
초기 구현은 다음과 같았다.

  • 로그인/refresh 성공 시 accessToken을 Zustand store에 저장하고, persist로 localStorage에도 같이 저장
  • ProtectedPage에서는 const accessToken = useAccessTokenStore(state => state.accessToken) 으로 store만 보고, if (!accessToken) 이면 refresh를 호출했다.

문제가 된 시나리오는 이렇다.

  1. 직원/라이선스 페이지에 몇 초 머무르는 동안 401이 한 번 발생한다
    → 인터셉터가 refresh 호출
    → 새 accessToken 발급
    → refresh 토큰은 한 번 소비된 상태가 된다
  2. 그 상태에서 브라우저 새로고침을 한다
  3. 새로고침 후 첫 렌더에서 JS 메모리는 초기화되어 Zustand store의 accessToken은 초기값(빈 값) 상태다. 반면 localStorage에는 이미 새 accessToken이 저장된 상태다.
  4. ProtectedPage는 store의 accessToken만 보고 “토큰이 없다”고 판단한다
  5. 그 결과, 새로고침 직후에 불필요하게 refresh를 한 번 더 호출한다
  6. 이미 한 번 소비된 refresh 토큰으로 다시 refresh를 시도하기 때문에, 백엔드가 1회용으로 관리하는 경우 실패 처리되고, 클라이언트는 “refresh 실패”로 간주하고 토큰을 지우고 /login으로 이동한다

정리하자면, 실제로는 localStorage에 유효한 accessToken이 있는데, 토큰의 유무를 Zustand store만 기준으로 판단하다 보니, 새로고침 직후에 두 번째 refresh 호출이 발생했고, 이게 강제 로그아웃의 직접적인 원인이었다.


해결

토큰의 유무를 판단할 때 Zustand store가 아니라 localStorage 기준으로 바꿨다.
ProtectedPage에서 다음처럼만 변경했다.

  • persist와 같은 키/형식으로 localStorage에서 accessToken을 읽는 함수를 두고,
  • refresh 호출 여부를 store의 accessToken이 아니라 이 함수 반환값으로 판단한다.
const getStoredAccessToken = () => {
  try {
    return JSON.parse(localStorage.getItem('accessToken') ?? '{}')?.state?.accessToken ?? null
  } catch {
    return null
  }
}

// 이전: store 기준
// if (!accessToken) { attemptRefresh() } else { setIsRefreshing(false) }

// 이후: localStorage 기준
const storedToken = getStoredAccessToken()
if (!storedToken) {
  attemptRefresh()
} else {
  setIsRefreshing(false)
}

이렇게 바꾸고 나서는, 페이지 안에서 401 → refresh 한 번 도는 것은 그대로 유지되고, 그 뒤에 몇 초 있다가 새로고침을 해도 localStorage에 accessToken이 있는 이상 새로고침 직후에는 refresh를 다시 호출하지 않아서 강제 로그아웃이 발생하지 않았다.


정리

  • 새로고침 직후에는 Zustand store가 항상 초기값에서 시작한다.
    반면 persist로 저장된 localStorage에는 직전에 발급받은 accessToken이 남아 있다.
  • 토큰의 유무를 store 기준으로만 보면, 실제로는 토큰이 있는데도 새로고침 직후에 없는 것처럼 잘못 판단해서 refresh를 한 번 더 호출할 수 있다.
  • 특히 refresh 토큰을 1회용으로 관리하는 백엔드에서는, 이런 불필요한 두 번째 refresh 호출이 곧바로 로그아웃으로 이어질 수 있다.
  • 이 문제는 인증 로직을 React Query 옵션이나 컴포넌트 마운트 타이밍만으로 해결하려고 하기보다, 스토리지 레벨(Zustand + localStorage)에서 언제 refresh를 호출할지를 명확하게 설계하고 정리해야 될 것 같다...
profile
About me: https://resume-seven-beige.vercel.app/

0개의 댓글