React

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

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

구성:

  • 같은 백엔드를 쓰는 쌍둥이 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에서 1회 생성 후 앱 전체가 공유
  • 로그인/로그아웃과 무관하게 존속
  • 로그아웃 시 명시적으로 비우지 않으면, 로그아웃은 캐시 입장에서 아무 사건도 아님

어떻게 고쳤나

  • 사용자 포털: 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()
  • 이유: “세션이 끝나면 서버에서 받아온 모든 것을 버린다”는 규칙은 예외가 없어 검토 불필요

남는 교훈

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

  • 그리고 이런 버그는 개발 중 포착이 어려움

  • 로컬에서는 대체로 단일 계정 사용
  • 계정 전환 시 습관적으로 새로고침
  • A로 확인 → B로 재로그인 → 재확인 흐름을 타야 비로소 노출

  • 통합테스트 국면에 이런 종류의 버그가 몰려 나오는 이유