Next.js ISR와 TanStack Query 캐시 충돌 해결하기

Maria Kim·2025년 7월 4일
0

캐시가 겹칠 때 벌어지는 일을 파헤치고, 1 줄로 해결한 경험담을 공유합니다.

들어가며

Next.js의 ISR(Incremental Static Regeneration) 은 정적 페이지의 속도와 서버 렌더링의 실시간성을 모두 챙길 수 있는 멋진 기능입니다. 여기에 클라이언트 상태 관리 라이브러리인 TanStack Query (React Query) 를 더하면, 화면 전환은 더 빨라지고 네트워크 요청은 줄어듭니다.

그런데 두 캐시가 충돌하면 사용자가 방금 수정한 데이터조차 보이지 않는 불상사가 발생할 수 있습니다. 저의 레시피 공유 서비스에서 실제로 일어난 문제였죠. 글쓴이(레시피 작성자)는 “수정 완료” 메시지를 봤는데, 프로필 페이지엔 옛날 제목이 그대로 남아 있었습니다.

아래는 문제 원인부터 1 줄짜리 해결책, 그리고 얻은 교훈까지 정리한 기록입니다.


문제 증상 

프로필 페이지(/profile/maria) — ISR로 24시간마다 재생성합니다.

Jamie가 레시피를 오전 10시에 수정합니다.

기대: 새로고침하면 즉시 수정된 제목이 보여야 합니다.

현실: 24시간이 지나 ISR이 다시 빌드되기 전까진 옛날 데이터가 그대로 노출됩니다.

방문자(maria가 아닌 사람)에게 캐싱된 페이지가 보이는 것은 좋았습니다. 그러나 작성자 본인이 바뀐 내용을 못 보는 UX는 치명적이죠.


원인: 두 개의 캐시, 서로 다른 수명 주기

초기 설정은 아래와 같았습니다.

// page.tsx

import {
  dehydrate,
  HydrationBoundary,
  queryOptions,
} from '@tanstack/react-query';

export const revalidate = 86_400; // 24 h

async function ProfilePage({ params }: Props) {
  const { username } = await params;
  if (!username) return notFound();

  const queryClient = getQueryClient();

  queryClient.prefetchQuery(
    queryOptions(
      recipesOptions({
        query: username,
      }),
    ),
  );

  return(
    <HydrationBoundary state={dehydrate(queryClient)}>
      {...}
    </HydrationBoundary>
  );

}

// RecipeList.tsx

async function RecipeList() {

  const query = useSuspenseQuery(recipesOptions({
    query: params.username,
    enabled: isUserProfile,
    // 0 ms for owners → always refetch
    // 24 h for visitors → performance
    staleTime: isUserProfile ? 0 : 86_400_000,
  }));

왜 staleTime만으로는 부족했을까?

  1. 서버 프리패치 : Next.js가 페이지를 SSR/ISR 과정에서 렌더링할 때 서버에서 TanStack-Query prefetch를 호출해 데이터를 가져옵니다.

  2. Hydration : 브라우저에서 TanStack-Query는 해당 상태를 그대로 복원(하이드레이션)합니다. 즉, 첫 마운트부터 캐시가 차 있습니다.

  3. staleTime === 0은 데이터를 ‘오래됨(stale)’으로만 표시할 뿐, 메모리에서 삭제하지 않습니다. React Query는 백그라운드에서 리패칭을 시도할 수 있지만, 그 사이에는 이전 데이터를 그대로 보여줍니다.

  4. 작성자에겐 단 한 번의 오래된 렌더링도 UX 실패입니다.

해결책: gcTime을 0으로 

gcTime(Garbage Collection Time)은 캐시 객체가 메모리에서 언제 완전히 사라질지 결정합니다. 0으로 설정하면 컴포넌트 언마운트 후(또는 stale 상태가 되자마자) 즉시 제거되며, 다음 마운트 시엔 빈 캐시로 시작해 네트워크 요청을 강제합니다.

recipesOptions({
  query: params.username,
  enabled: isUserProfile,
  staleTime: isUserProfile ? 0 : 86_400_000, 
  gcTime:   isUserProfile ? 0 : 86_400_000, // memory window
});

동작 흐름

  • 작성자(gcTime = 0)
  1. 서버에서 가져온 cache된 데이터가 하이드레이션되지만, 곧바로 GC 대상이 됩니다.

  2. React Query는 빈 캐시를 인식하고 즉시 리패칭합니다.

  3. 작성자는 최신 레시피를 단 한 번의 요청 후에 확인합니다.

  • 방문자(gcTime = 24h)
  1. 캐시는 하루 동안 유지되며, 페이지 전환 시 즉시 렌더링됩니다.

  2. 서버 트래픽과 로딩 시간이 줄어듭니다.

핵심 정리

  • 두 캐시, 두 레버 — staleTime은 신선도, gcTime은 존속 기간을 조절합니다.

  • ISR이 주입한 초기 데이터를 클라이언트가 어떻게 다룰지 결정해야 ‘어제의 유령 데이터’를 피할 수 있습니다.

  • 사용자 분기 캐싱(작성자 vs. 방문자)으로 UX와 성능을 모두 잡을 수 있습니다.

  • 단 1 줄의 코드지만, 그 뒤엔 다층 캐시 흐름을 이해하는 사고 과정이 숨어 있습니다.

마무리하며

현대 프레임워크는 ‘빌드 타임’과 ‘런타임’의 경계를 흐립니다. 서버 캐시(ISR)와 클라이언트 캐시(React Query)가 겹칠 땐 데이터의 여정을 처음부터 끝까지 그려 보세요. 어느 지점에서 낡은 데이터가 끼어드는지 보인다면, 해결 레버(gcTime)가 저절로 눈에 들어올 것입니다.

Happy Caching! 

참고 링크

profile
Developer, who has business in mind.

0개의 댓글