About·GitHub·Email

2026-05-21·[디버깅 경험]

프론트엔드 캐시 정리 3: TanStack Query와 SWR

TanStack Query와 SWR을 이해하려면 stale, gc, invalidation, 즉시 반영을 나눠서 봐야 한다.

브라우저 캐시와 Next.js 캐시까지 정리하고 나면, 그래도 설명되지 않는 문제가 남을 수 있다. 나는 이 단계에서 꽤 오래 헤맸다. invalidateQueries면 다 되는 줄 알았는데, 그게 아니었다. 대표적으로 이런 것들이다.

  • 좋아요를 눌렀는데 숫자가 바로 안 바뀐다.
  • 장바구니 수량이 화면마다 다르게 보인다.
  • 탭을 잠깐 다른 데 갔다 왔더니 갑자기 refetch가 일어난다.

이쯤 되면 이제 서버 캐시보다 클라이언트 메모리 안의 서버 데이터 캐시를 봐야 한다. 그런데 TanStack Query를 바로 API 이름부터 보면 좀 헷갈린다. 나는 먼저 SWR(stale-while-revalidate)이라는 캐시 전략을 떠올리는 편이 이해하기 쉬웠다.

TanStack Query SWR 흐름도


HTTP 캐시 전략에서 온 SWR

SWR은 TanStack Query에서 갑자기 나온 개념은 아니다. stale-while-revalidate는 원래 HTTP 캐시에서 쓰이는 Cache-Control 확장 지시자다. 캐시가 오래된 응답을 바로 돌려주면서, 동시에 뒤에서 원 서버에 다시 검증할 수 있게 하는 전략이다.

응답 헤더에는 이런 식으로 들어간다.

Cache-Control: max-age=60, stale-while-revalidate=300

이걸 말로 풀면 이렇다. 60초 동안은 fresh한 응답으로 본다. 그 시간이 지나면 stale해진다. 그런데 stale해졌다고 바로 버리는 건 아니다. 그 뒤 300초 동안은 캐시가 오래된 응답을 일단 재사용할 수 있고, 동시에 백그라운드에서 원 서버에 다시 확인해서 캐시를 갱신할 수 있다.

여기서 중요한 건 stale이 "삭제됨"이나 "사용 불가"가 아니라는 점이다. 더 정확히는 "최신이라고 장담할 수 없음"에 가깝다.

이 전략은 사용자 경험 측면에서 꽤 실용적이다. 매번 새 응답을 기다리면 느려질 수 있으니, 일단 가진 값을 보여주고 뒤에서 다시 확인한다. 대신 아주 최신이어야 하는 데이터에는 조심해서 써야 한다. 오래된 값을 잠깐 보여주는 전략이기 때문이다.


TanStack Query도 비슷한 질문을 다룬다

TanStack Query는 이 HTTP 캐시 전략의 사고방식을 클라이언트 상태 관리 쪽으로 가져온 것에 가깝다. 공식 문서에서도 query의 상태와 fetch 상태를 설명할 때 stale-while-revalidate 로직을 언급한다. 다만 HTTP 캐시를 그대로 쓰는 건 아니다. Cache-Control 헤더를 읽어서 브라우저 캐시처럼 동작한다기보다, 브라우저 메모리 안에서 서버 상태를 관리하는 라이브러리다.

다만 질문은 비슷하다. 이 데이터는 아직 fresh한지, stale해졌다면 기존 값을 보여줘도 되는지, 언제 다시 서버에 확인할지, 사용자가 방금 바꾼 값은 언제 화면에 반영할지를 정해야 한다.

그래서 TanStack Query를 단순한 "메모리 저장소"로만 이해하면 중간부터 다시 꼬이기 쉽다. staleTime, gcTime, invalidateQueries, setQueryData는 각각 다른 질문에 답한다.

이 그림처럼 보면 TanStack Query의 핵심은 "캐시가 있냐 없냐"보다 fresh -> stale -> refetch -> updated 흐름을 어떻게 다루느냐에 가깝다. 그리고 바로 이 지점에서 setQueryDatainvalidateQueries의 역할도 갈린다.

캐시가 stale해졌다고 해서 화면에서 바로 사라지는 것은 아니다. 화면에는 여전히 캐시된 값이 보일 수 있고, 라이브러리는 그 값을 바탕으로 UI를 그린 다음 뒤에서 새 값을 가져와 교체할 수 있다.


queryKey는 캐시 식별자 역할을 한다

TanStack Query는 기본적으로 queryKey로 캐시를 구분한다.

const { data } = useQuery({
  queryKey: ['notices'],
  queryFn: fetchNotices,
});

이러면 ['notices']라는 이름표 아래에 결과가 저장된다. 게시글 상세면 ['post', postId]가 될 수 있고, 장바구니면 ['cart']가 될 수 있다.

이걸 Next.js의 cacheTag('notices')와 완전히 같다고 보면 안 되지만, 사고방식은 꽤 닮아 있다. 둘 다 "나중에 이 데이터를 묶어서 다시 다루고 싶다"는 의도를 담고 있으니까.


staleTime은 신선도 시간이다

TanStack Query에서 가장 먼저 바로잡아야 하는 개념은 staleTime이다.

공식 문서 기준으로 staleTime 기본값은 0이다. 그래서 기본 설정에서는 query data가 곧바로 stale하다고 본다. 여기서 중요한 건 또 한 번, stale하다고 해서 캐시가 사라지는 건 아니라는 점이다.

const { data, isStale } = useQuery({
  queryKey: ['notices'],
  queryFn: fetchNotices,
  staleTime: 30_000,
});

이 코드에서는 30초 동안 fresh하게 본다. 30초가 지나면 stale이 된다. 하지만 stale이 됐다고 캐시가 비워지는 건 아니고, 이 값을 보여준 채 mount, window focus, reconnect 같은 타이밍에 백그라운드 refetch가 일어날 수 있다.

개발 중에 refetch가 자꾸 보인다면, DevTools와 앱 탭 사이를 오가는 것만으로도 window focus가 바뀌면서 refetch가 걸릴 수 있다는 점을 먼저 떠올릴 필요가 있다. 이걸 모르고 보면 "왜 멀쩡한데 자꾸 다시 요청하지?"라는 오해가 생기기 쉽다. 솔직히 처음엔 나도 라이브러리 버그인가 싶었다. DevTools랑 앱 탭을 번갈아 보면서 한참 헤매다가, 단순히 window focus 때문이라는 걸 알았을 땐 좀 허탈했다.


gcTime은 캐시 생존 시간을 다룬다

예전 글이나 예전 기억 속에는 cacheTime이라는 이름이 더 익숙할 수도 있다. 지금 문서 기준으로는 gcTime이라고 보는 게 맞다.

이건 staleTime과 완전히 다른 질문에 답한다.

  • staleTime: 언제부터 낡았다고 볼까
  • gcTime: 아무도 안 쓰는 캐시를 언제 메모리에서 치울까

공식 문서 기준 기본 gcTime은 5분이다. 즉 어떤 query를 더 이상 아무 컴포넌트도 구독하지 않아 inactive 상태가 되면, 그 캐시는 5분 뒤 가비지 컬렉션 대상이 될 수 있다.

이 둘을 섞으면 계속 헷갈린다. stale은 신선도 문제고, gc는 생존 시간 문제다.


invalidateQueries는 재검증 신호에 가깝다

여기서부터는 Next.js의 revalidateTag와 닮아 보이는 구간이 나온다.

queryClient.invalidateQueries({ queryKey: ['notices'] });

이 코드는 대체로 "이 query는 이제 stale니까 다시 확인하자" 쪽에 가깝다. 지금 당장 눈앞의 숫자를 바꾸는 함수라기보다, 관련 데이터를 다시 검증하게 만드는 신호에 가깝다.

그래서 invalidateQueries즉시 UI 반영 수단으로 생각하면 어긋난다. 서버 왕복이 끼고, refetch 타이밍에 따라 사용자가 즉시 변경을 못 볼 수도 있기 때문이다.

이 감각은 Next.js의 revalidateTag('notices', 'max')와 꽤 닮아 있다. 둘 다 stale-while-revalidate 성격이 강하다.


즉시 반영에는 setQueryData가 더 직접적이다

반대로 좋아요 수나 장바구니 수량처럼 지금 이 순간 화면이 바로 움직여야 하는 값setQueryData가 훨씬 직접적이다.

queryClient.setQueryData(['post', postId], (prev: Post | undefined) => {
  if (!prev) return prev;
  return {
    ...prev,
    likes: prev.likes + 1,
  };
});

이건 네트워크를 기다리는 게 아니라, 클라이언트 캐시 자체를 먼저 바꾼다. 그러면 컴포넌트는 그 값을 보고 곧바로 다시 그려진다.

실무에서는 보통 여기서 끝내지 않는다. 즉시 반영은 setQueryData로 하고, 나중에 서버의 실제 값과 맞추기 위해 invalidateQueries를 한 번 더 거는 편이 더 안정적이다.

queryClient.setQueryData(['post', postId], (prev: Post | undefined) => {
  if (!prev) return prev;
  return { ...prev, likes: prev.likes + 1 };
});

await likePost(postId);

queryClient.invalidateQueries({ queryKey: ['post', postId] });
queryClient.invalidateQueries({ queryKey: ['posts'] });

좋아요를 누른 직후 상세 화면 숫자는 먼저 바뀌고, 서버 요청이 끝난 뒤 상세와 목록 데이터를 다시 검증해서 맞춰주는 흐름이다. 실제 코드에서는 실패했을 때 롤백 처리도 같이 넣어야 하지만, 여기서는 setQueryDatainvalidateQueries의 역할 차이만 보려고 단순화했다.


Next.js 캐시와의 대응 관계

시리즈 앞 글과 연결해서 보면 이렇게 비교하는 편이 가장 단순하다.

Next.js의 revalidateTag('notices', 'max')는 서버 쪽 stale-while-revalidate에 가깝다. TanStack Query의 invalidateQueries({ queryKey: ['notices'] })는 클라이언트 쪽 stale-while-revalidate에 가깝다.

Next.js의 updateTag('notices')는 같은 앱 안 Server Action에서 "방금 쓴 값이 바로 보여야 한다"는 요구에 가깝고, TanStack Query의 setQueryData는 클라이언트 메모리 안에서 "지금 바로 화면을 바꾸자"는 요구에 가깝다.

레이어는 다르지만, 둘 다 결국 최신성과 응답성 사이의 균형을 다룬다는 점은 같다.


데이터별 캐시 레이어를 먼저 정해야 한다

블로그 글 본문이나 공지사항 리스트처럼 읽기 중심이고 SEO가 중요한 데이터는 Next.js 캐시 쪽이 더 잘 맞는다. 서버에서 한 번 잘 준비해두고 여러 사용자가 공유하기 쉽기 때문이다.

반대로 좋아요, 장바구니, 읽지 않은 알림 개수처럼 사용자가 누르자마자 화면이 바뀌어야 하는 값은 TanStack Query가 훨씬 설명이 잘 된다. setQueryData, invalidateQueries, staleTime 같은 개념이 전부 그쪽 UX를 설명하는 데 잘 맞는다.

결국 중요한 건 "이 데이터는 어느 레이어가 주인인가?"를 먼저 정하는 일이다. 이걸 못 정하면 같은 데이터를 Next.js도 캐시하고 TanStack Query도 캐시해서, 어느 쪽이 진실인지 스스로도 헷갈리게 된다.


클라이언트 캐시를 구분하는 기준

여기까지 정리하고 나면 클라이언트 캐시를 볼 때 질문 순서를 고정해두는 편이 좋다.

먼저, 뒤로 가기나 앞으로 가기에서만 이상한가를 본다. 그렇다면 브라우저 bfcache 가능성이 크다.

그다음, hard reload를 해도 예전 데이터가 보이는가를 본다. 그렇다면 HTTP 캐시나 Next.js 서버 캐시 쪽을 의심한다.

그다음, 앱 내부 링크 이동에서만 유난히 빠르거나 상태가 남는가를 본다. 그러면 Router Cache를 본다.

마지막으로, mutation 직후 특정 위젯만 값이 안 맞는가를 본다. 이때는 거의 항상 TanStack Query의 queryKey, staleTime, setQueryData, invalidateQueries를 다시 본다.

실제로 자주 나오는 실수는 두 가지다.

하나는 queryKey 설계를 대충 해서 서로 다른 데이터를 같은 캐시로 다뤄버리는 것. 다른 하나는 invalidateQueries만 걸어놓고 "왜 바로 안 바뀌지?"라고 당황하는 것이다. 즉시성을 원하면 setQueryData가 먼저여야 한다.


레이어를 먼저 나눠서 보기

프론트엔드 캐시를 잘 다루려면 API 이름을 많이 외우는 것보다, 지금 보고 있는 문제가 어느 캐시 레이어에서 발생하는지 먼저 분리하는 쪽이 더 중요해 보인다.

브라우저 캐시도 알아야 하고, Next.js 캐시도 알아야 하고, 결국엔 클라이언트 캐시까지 알아야 한다. 레이어는 많지만 한 번 구분해두면 적어도 엉뚱한 곳을 먼저 의심하는 일은 줄어든다.

아직 몸에 완전히 익지는 않았다. 그래도 예전처럼 invalidateQueries 하나로 다 설명하려고 하던 때보다는 훨씬 덜 헤매게 된 것 같다.

이 시리즈는 아래 순서로 이어진다.

  1. 브라우저 캐시와 캐시 레이어
  2. Next.js 캐시와 15·16 비교
  3. TanStack Query와 SWR

참고

TanStack Query - Important Defaults TanStack Query - useQuery Next.js - revalidateTag Next.js - Revalidating