Home > AI > Frontend > TanStack Query에서 로그아웃은 토큰만 지우는 게 아니다

TanStack Query에서 로그아웃은 토큰만 지우는 게 아니다
AI Claude Code TanStack Query React Zustand

문제: 로그아웃했는데 이전 사용자 데이터가 보인다

같은 백엔드를 쓰는 쌍둥이 Next.js 프론트엔드 — 관리자 포털과 사용자 포털이다. 두 앱 모두 인증은 Zustand, 서버 상태는 TanStack Query로 나눠 관리한다. 이 분리 자체는 흔하고 합리적인 구성이다.

문제는 로그아웃 경로였다. useAuthStore.logout()은 토큰과 사용자 정보, isAuthenticated 플래그를 초기화한다. 딱 거기까지다. React Query의 QueryClient는 아무도 건드리지 않는다.

그래서 A 계정으로 로그아웃하고 B 계정으로 로그인하면, 대시보드와 목록 화면에 A의 데이터가 잠깐 보였다. TanStack Query의 stale-while-revalidate 때문이다. 캐시에 남아 있는 이전 응답을 먼저 그려놓고 백그라운드로 재요청을 보내니, 새 응답이 도착하기 전 짧은 구간 동안 이전 사용자의 화면이 그대로 노출된다. 페이지를 수동으로 새로고침해야 B의 데이터로 바뀌었다.

멀티테넌트 SPA에서 이건 UI 깜빡임이 아니라 정보 노출 버그다. 관리 화면이라 목록에 다른 조직 이름이나 자원 이름이 그대로 실려 있다.

원인: 캐시 수명 경계를 아무도 안 그었다

인증 상태와 서버 캐시를 다른 라이브러리에 맡긴 순간, 두 상태의 수명을 누가 맞추느냐는 질문이 생긴다. 그런데 이 질문에 아무도 답을 정하지 않았다.

  • Zustand의 auth 상태 수명 = 세션
  • QueryClient 캐시 수명 = QueryClient 인스턴스의 수명 = 앱이 떠 있는 동안

QueryClient는 Providers에서 한 번 만들고 앱 전체가 공유한다. 로그인/로그아웃과는 아무 관계없이 살아 있다. 로그아웃 시 명시적으로 비우지 않으면, 로그아웃은 캐시 입장에서 아무 사건도 아니다.

어떻게 고쳤나

사용자 포털에서는 NavHeader.handleLogoutfinally 블록에 한 줄 추가했다.

const queryClient = useQueryClient();

// ...
try {
  await logout();
} catch (error) {
  // logout error handled silently
} finally {
  // 이전 사용자의 React Query 캐시를 비워 새 로그인 후 다른 데이터가
  // stale-while-revalidate 로 잠깐 노출되는 문제를 방지한다.
  queryClient.clear();
  handleMenuClose();
  router.push("/");
}

finally인 게 중요하다. 로그아웃 API 호출이 실패해도 클라이언트 캐시는 비워야 한다. 서버 세션 종료가 실패한 상황에서 브라우저에 이전 사용자 데이터를 남겨두는 건 더 나쁘다.

관리자 포털은 로그아웃이 try/catch 없이 메뉴 아이템의 onClick에서 동기적으로 처리되고 있어서 그 자리에 넣었다.

onClick={() => {
  logout();
  queryClient.clear();
  handleMenuClose();
  router.push("/");
}}

이 메뉴는 useMemo로 감싸져 있어서 의존성 배열에 queryClient를 추가하는 것까지가 한 세트였다. queryClient는 참조가 안정적이라 실제로 재계산이 늘어나진 않지만, 의존성을 빠뜨리면 lint가 잡고 나중에 실제 stale closure의 근거가 된다.

두 저장소에 1초 차이로 들어간 5줄짜리 수정이다.

clear() vs removeQueries() vs resetQueries()

셋 중에 무엇을 쓸지가 유일한 설계 판단이었다.

메서드 동작 로그아웃에 맞나
clear() 캐시 전체 제거 + mutation 캐시까지 초기화 적합
removeQueries() 필터에 맞는 쿼리만 캐시에서 제거 부분 정리용
resetQueries() 초기 상태로 되돌리고 활성 쿼리는 즉시 refetch 부적합

resetQueries()는 명백히 틀렸다. 활성 쿼리를 다시 요청하는데, 로그아웃 직후엔 토큰이 없으니 401 세례를 받게 된다.

removeQueries()는 필터를 잘 쓰면 동작하지만, “무엇을 남길까”를 결정해야 한다. 로그아웃 시점에 남겨도 되는 서버 캐시란 사실상 없다. 공통 코드 목록 같은 걸 아깝게 여겨서 예외를 하나 두는 순간, 그 예외가 사용자별 데이터를 물고 있지 않은지 매번 검토해야 한다. 화이트리스트를 유지하는 비용이 캐시 재조회 비용보다 크다.

그래서 clear()다. “세션이 끝나면 서버에서 받아온 모든 것을 버린다”는 규칙은 예외가 없어서 검토할 것도 없다.

남는 교훈

클라이언트 상태와 서버 상태를 분리하는 건 좋은 설계지만, 분리하면 경계에 규칙이 필요해진다. 이 경우 규칙은 하나다 — 캐시 수명은 세션 수명을 넘지 않는다.

그리고 이런 버그는 개발 중에 잘 안 걸린다. 로컬에서는 대체로 한 계정만 쓰고, 계정을 바꿀 때 습관적으로 새로고침을 하기 때문이다. QA에서 A로 확인하고 B로 재로그인해 재확인하는 흐름을 타야 비로소 드러난다. 통합테스트 국면에 이런 종류의 버그가 몰려 나오는 이유다.