logo

DowanKim

서버와 Tanstack Query 의 캐시 동기화 방법

2026년 7월 24일

PLick

서버 컴포넌트와 TanStack Query 사이, 캐시는 어떻게 맞추나

처음 페이지를 만들어서 사용자에게 보내주는 상황에선 서버 컴포넌트로 데이터를 받아 그려줍니다

그리고무한 스크롤과 좋아요 기능이 생기면서 TanStack Query(React Query, 이하 RQ)를 도입했습니다.

이렇게 되면, "데이터가 두 군데 있다"는 문제가 발생합니다.

서버가 아는 것과 브라우저 캐시가 아는 것이 어긋나는 순간이 두 번 있는데,

첫 로드 때 한 번, 뮤테이션(좋아요·댓글 같은 쓰기 요청) 때 한 번입니다.

이를 동기화하는 다양한 해결 방법이 있겠지만, 저는 initialData를 주입하는 방식을 선택했습니다.


1. 전제: 화면은 항상 캐시를 그린다

RQ는 fetch 라이브러리라기보다는 클라이언트 쪽 데이터 창고입니다.

useQuery를 부르면 RQ는 이렇게 동작합니다.

  1. 1.창고(캐시)에 queryKey로 된 데이터가 있는지 봅니다.
  2. 2.있으면 일단 그걸 화면에 줍니다.
  3. 3.그 데이터가 "오래됐다"(stale) 판정이면 뒤에서 다시 fetch해서 창고를 갱신합니다.

즉 화면은 항상 캐시를 보고 그립니다.

서버 DB에 뭐가 있든, 방금 요청이 성공했든, 캐시에 든 값이 화면에 나옵니다.

모든 문제는 결국 진실은 서버에 있는데 화면은 캐시를 그리는 것입니다.

2. 캐시는 하나가 아니라 셋입니다

캐시어디에 사나누가 제어하나
Next 데이터 캐시넥스트 서버서버 컴포넌트 fetchnext: { revalidate }
브라우저 HTTP 캐시각 유저의 브라우저BE가 붙이는 Cache-Control·ETag 헤더
RQ 캐시브라우저 JS 메모리queryKey·staleTime 등 RQ 옵션

첫째, Next 데이터 캐시는 서버에 살고 모든 유저가 공유합니다. 만약 next: {revalidate: 60} 이면 한 유저가 릴스 1페이지를 받아 캐시에 채우면, 그 뒤 60초 동안 접속하는 전원이 BE를 직접 건드리지 않고 그 저장분을 씁니다.

둘째, 브라우저 HTTP 캐시는 각 유저의 브라우저에 내장돼 자동으로 돕니다. 다만 이적 루머 피드처럼 자주 바뀌는 API는 BE가 대개 no-store를 줘서 실제로는 잘 안 낍니다.

셋째가 RQ 캐시입니다. 브라우저 JS 메모리에 있는, queryKey로 데이터를 넣고 빼는 Map입니다. 앞의 둘과 결이 다른 게, HTTP 캐시가 히트하면 fetch는 부르되 네트워크만 아끼는 반면, RQ 캐시가 히트하면 fetch 호출 자체를 안 하고 로딩 깜빡임 없이 데이터를 바로 쥡니다.

image.png

핵심은 이 셋이 서로의 존재를 모른다는 겁니다.

서버 컴포넌트의 fetch는 Node에서 돌아 Next 데이터 캐시에만 담기고, 그 결과는 브라우저의 어느 캐시에도 들어가지 않습니다. 실행 장소(서버냐 브라우저냐)가 어떤 캐시가 붙을지를 가릅니다.

3. 문제 1: 첫 로드의 이중 페치

서버 컴포넌트가 릴스 첫 페이지를 받아 HTML로 그려 내려보냈는데, 브라우저에서 useInfiniteQuery가 마운트되면 RQ 캐시는 비어 있습니다.

서버가 받은 데이터는 HTML에는 있지만 RQ 창고에는 없기 때문입니다.

그래서 아무것도 안 하면 이렇게 됩니다.

  • 서버: 릴스 1페이지 fetch → HTML로 그려서 내려보냄
  • 브라우저: 훅 마운트 → 창고가 비었네? → 같은 1페이지를 또 fetch

화면에 데이터가 이미 그려져 있는데 로딩이 한 번 더 도는 이중 페치입니다. 충돌이 아니라 격리 때문에 일이 공유가 안 되는 문제입니다.

해결책은 서버가 받은 데이터를 클라 창고에 미리 넣어주는 것입니다.

그 방법이 두 가지 있습니다.

4. 캐시 동기화 두가지 방법 : HydrationBoundary와 initialData

방법 A: dehydrate / HydrationBoundary

RQ 공식 문서의 정석 방법입니다.

  • dehydrate(탈수): 서버에서 QueryClient 창고 전체를 JSON으로 직렬화합니다. 창고를 "말려서" 들고 갈 수 있는 형태로 만드는 겁니다.
  • HydrationBoundary(수화): 브라우저에서 그 JSON을 받아 클라 창고에 "물을 부어" 복원합니다.
// 서버 컴포넌트 export default async function Page() { const queryClient = new QueryClient(); await queryClient.prefetchQuery({ queryKey, queryFn }); return ( <HydrationBoundary state={dehydrate(queryClient)}> <ClientComponent /> </HydrationBoundary> ); }

값이 아니라 캐시 상태 자체가 이동합니다. 서버에서 쿼리를 여러 개 미리 받아둘 때 특히 좋습니다.

방법 B: initialData 시딩

훨씬 소박한 방법입니다. 서버 컴포넌트가 데이터를 그냥 props로 클라 컴포넌트에 넘기고, 클라 훅이 그걸 initialData 옵션으로 창고에 심습니다. "시딩(seeding)"은 씨앗 심기라는 뜻 그대로, 창고의 첫 값을 심어준다는 얘기입니다.

// 서버 컴포넌트 (reels/page.tsx) export default async function ReelsPage() { const page = await getReels(); // 서버에서 fetch return <ReelsFeed initial={{ page, fetchedAt: Date.now() }} />; } // 클라이언트 훅 (useReelsFeed.ts) export function useReelsFeed(initial?: InitialReelFeed) { return useInfiniteQuery({ queryKey: reelKeys.feed(), queryFn: ({ pageParam }) => getReels({ cursor: pageParam }), initialPageParam: null as string | null, getNextPageParam: (last) => last.nextCursor ?? undefined, initialData: initial ? { pages: [initial.page], pageParams: [null] } : undefined, }); }

이러면 마운트 순간 창고에 이미 데이터가 있으니 재fetch가 안 나갑니다. RQ 입장에서는 이 값이 서버에서 왔는지도 모르고 그냥 "캐시가 비어 있으면 이걸 초기값으로 써라"라는 선언일 뿐입니다.

두방식 차이점

저는 initialData를 골랐는데, 그 근거가 된 차이를 정리하면 이렇습니다.

첫째, 데이터가 건너오는 경로가 다릅니다. initialData는 props입니다. 서버 컴포넌트가 await한 평범한 JS 값을 자식에 내려줄 뿐입니다. HydrationBoundary는 캐시 자체를 옮깁니다. dehydrate()가 캐시를 JSON으로 말아 경계를 넘긴 뒤 브라우저 QueryClient에 도로 부어넣습니다.

둘째, 여러 쿼리를 한 번에 심을 수 있느냐가 실무에서 제일 큰 차이일듯 합니다. initialData는 쿼리 하나당 props 하나입니다. 한 화면에 쿼리가 다섯 개면 값 다섯 개를 트리 아래로 일일이 꽂아야 합니다. dehydrate는 캐시를 통째로 말기 때문에 queryHash로 알아서 찾아갑니다. 트리 어디에 있든 같은 쿼리키를 쓰는 훅이면 데이터가 이미 캐시에 있습니다.

셋째, 신선도 시계가 도는 시점이 다릅니다. dehydrate는 캐시 상태를 통째로 직렬화하니까 dataUpdatedAt(서버가 실제 fetch한 시각)이 그대로 보존됩니다. 반면 initialData는 RQ 소스가 이렇게 돼 있습니다.

dataUpdatedAt: hasData ? (initialDataUpdatedAt ?? Date.now()) : 0;

initialDataUpdatedAt을 직접 넘기지 않으면 브라우저가 캐시 엔트리를 만드는 순간이 데이터의 생일로 찍힙니다. 실제보다 더 신선한 것으로 취급된다는 뜻입니다.

넷째, 캐시에 이미 데이터가 있을 때의 행동이 정반대입니다. 그리고 이게 무한 스크롤에서는 결정적입니다. initialData는 캐시에 데이터가 있으면 무시됩니다. hydrate는 타임스탬프를 비교해서 서버 데이터가 더 새로우면 덮어씁니다.

// hydrate 내부 if (state.dataUpdatedAt > query.state.dataUpdatedAt || hasNewerSyncData) { query.setState({ ...serializedState, data, ... }); }

initialData 선택 이유

처음 고른 이유는 단순함입니다. 씨앗을 심을 쿼리가 화면당 하나뿐이라, 여러 쿼리를 한 번에 심는 dehydrate의 장점이 무의미했고 props drilling도 한 단계라 부담이 없었습니다.

결과적으론 무한 스크롤에서는 이게 맞는 선택이었습니다. 무한 쿼리의 data{ pages, pageParams } 객체이고, 서버가 심는 건 항상 1페이지짜리입니다. HydrationBoundary를 썼다면 이런 사고가 납니다.

  1. 홈에서 8페이지까지 스크롤합니다.
  2. 릴스에 갔다가 홈으로 소프트 내비게이션합니다.
  3. 서버가 새로 받은 1페이지가 타임스탬프상 더 새로우니 hydrate가 캐시를 덮어씁니다.
  4. 쌓아둔 8페이지가 1페이지로 접히면서 스크롤하던 목록이 리셋됩니다.

initialData는 캐시에 데이터가 있으면 그냥 무시하니까 8페이지가 그대로 남습니다.

image.png

정리하면, HydrationBoundary가 나은 자리는 한 페이지에서 여러 쿼리를 미리 채워 여러 자식이 각자 집어가게 할 때, 그리고 신선도를 정확히 넘기고 싶을 때입니다. 쿼리가 하나뿐인 무한 스크롤 피드라면 initialData 쪽이 코드도 가볍고 스크롤 보존에도 맞습니다.

5. initialData 사용 시 주의점

앞선 세번째 차이점을 해결해주어야 합니다

initialData만 주면 RQ는 그 데이터가 심어진 순간에 태어났다고 착각합니다. 실제로는 서버가 그보다 전에 받은 데이터입니다(큰차이는 없겠지만).

서버 fetch에 캐시(revalidate)를 안 걸었다면 그 간격이 SSR과 전송 지연 정도라 무시할 만하지만, Next 데이터 캐시로 응답을 몇 분씩 재사용하기 시작하면 이 차이가 벌어집니다. 5분 전 데이터가 "방금 받은 것"으로 취급돼서, staleTime이 다 지나도록 갱신이 안 됩니다.

그래서 서버가 fetch한 시각(fetchedAt)을 props에 같이 실어 보내고, 훅에서 initialDataUpdatedAt으로 데이터의 진짜 나이를 달아줍니다.

export function useReelsFeed(initial?: InitialReelFeed) { return useInfiniteQuery({ // ...앞서 본 옵션들... /** * 씨앗의 신선도를 서버가 받은 시각으로 못박는다. 안 넘기면 RQ가 * 캐시 엔트리를 만드는 순간으로 찍어서, 묵은 데이터가 방금 받은 * 것으로 취급된다. 제 나이를 달아 두면 그런 씨앗은 심자마자 * stale로 판정돼 갱신이 걸린다. */ initialDataUpdatedAt: initial ? () => Math.min(initial.fetchedAt, Date.now()) : undefined, staleTime: FEED_FRESH_MS, }); }

Math.min(fetchedAt, Date.now()) 클램프에는 이유가 있습니다. 기기 시계가 서버보다 뒤처져 있으면 서버 시각이 미래로 보여서, 데이터가 영영 신선한 것으로 판정됩니다. 미래인 값은 지금으로 깎아서 그 구멍을 막았습니다.

dehydrate였으면 공짜로 됐을 일을 손으로 하는 셈이지만, 코드 세 줄이면 끝나는 일이기도 합니다.

6. 정보) QueryClient도 SSR용 패턴이 따로 있습니다

QueryClient 자체도 서버와 브라우저에서 다르게 만들어야 합니다.

import { isServer, QueryClient } from "@tanstack/react-query"; function makeQueryClient(): QueryClient { return new QueryClient({ defaultOptions: { queries: { // SSR 직후 클라이언트가 곧바로 같은 데이터를 재fetch하는 낭비를 막는다 staleTime: 60_000, }, }, }); } let browserQueryClient: QueryClient | undefined; /** * TanStack Query 공식 SSR 패턴 — 서버는 요청마다 새 인스턴스(요청 간 캐시 격리), * 브라우저는 모듈 싱글턴을 재사용한다. */ export function getQueryClient(): QueryClient { if (isServer) { return makeQueryClient(); } browserQueryClient ??= makeQueryClient(); return browserQueryClient; }

서버에서 싱글턴을 쓰면 서로 다른 유저의 요청이 캐시를 공유하는 사고가 납니다. 브라우저에서 매번 새로 만들면 캐시가 계속 날아갑니다. 그래서 서버는 요청마다 새로, 브라우저는 싱글턴으로 갑니다. Provider에서 useState로 만들지 않고 렌더 중에 getQueryClient()를 호출하는 것도 공식 권장인데, React가 suspend로 초기 렌더를 버려도 클라이언트가 유실되지 않기 때문입니다.

7. 문제 2: 뮤테이션 이후, 창고가 뒤처진다

두 번째 불일치는 방향이 반대입니다. 첫 로드 때는 서버가 앞서 있었다면, 이번엔 내가 서버를 바꿔놓고 브라우저캐시가 뒤처진 상황입니다.

좋아요를 눌렀다고 해보겠습니다.

  1. 서버에 "좋아요" 요청 → 서버 DB에는 반영됨 ✅
  2. 그런데 내 캐시 속 릴스 데이터는 여전히 liked: false
  3. 화면은 캐시를 그리니까 → 하트가 안 채워짐

뮤테이션은 서버만 바꿉니다. 브라우저 캐시는 그대로입니다. 그래서 뮤테이션이 성공하면 브라우저 캐시도 맞춰줘야 화면이 바뀝니다. 뮤테이션 이후 캐시 갱신을 해줘야 합니다.

invalidate가 항상 답은 아닙니다

캐시를 맞추는 제일 쉬운 방법은 invalidateQueries입니다. "이 쿼리 낡았음" 표시를 하면 RQ가 알아서 재fetch합니다. 코드 한 줄이라 편하고, 서버가 진실이니 정확하기도 합니다.

문제는 우리 제품 피드가 전부 커서 기반 무한 쿼리라는 점입니다.

무한 쿼리를 무효화하면 쌓인 페이지 전부를 다시 받는데, 커서 체인이라 앞 페이지가 와야 다음 커서를 알 수 있어 요청이 순차로 나갑니다. 20페이지를 본 상태에서 무효화하면 요청 20개가 줄지어 나가고, 그게 다 끝나야 화면이 안정됩니다. 좋아요 한 번에 치를 비용이 너무 커져버립니다.

그래서 전체 무효화를 의도적으로 피하고, setQueryData로 캐시를 직접 고칩니다. 서버가 어떻게 바뀌었는지 이미 아니까(좋아요를 켰으니 liked: true, 카운트 +1) 재요청 없이 창고 속 그 항목만 수정하는 겁니다. 뮤테이션 성격에 따라 세 가지 패턴으로 갈립니다.

패턴 1: 좋아요 — 낙관적 갱신과 롤백

낙관적 갱신(optimistic update)은 서버 응답을 기다리지 않고 누르는 즉시 화면을 바꾸는 기법입니다. "요청은 성공할 거야"라고 낙관하는 거라 이런 이름이 붙었고, 대신 실패하면 원래 값으로 되돌리는(롤백) 책임이 따라옵니다. 좋아요처럼 반응 속도가 체감을 좌우하는 상호작용에 씁니다.

const { mutate, isPending } = useMutation({ mutationFn: async (liked: boolean) => { const result = await toggle(liked); // 서버 액션 호출 if (!result.ok) throw new ApiError(result.status, result.code, result.message); return result.likeCount; }, // 누르는 즉시: 하트 채우고 카운트 ±1. 그 시점 값을 컨텍스트로 찍어둔다 onMutate: (liked) => { const previous = state; onChange({ liked, likeCount: Math.max(0, state.likeCount + (liked ? 1 : -1)), }); return previous; // ← onError의 세 번째 인자로 돌아온다 }, // 응답 오면: BE가 계산한 카운트로 덮는다 (그 사이 남이 누른 것까지 반영) onSuccess: (likeCount, liked) => onChange({ liked, likeCount }), // 실패하면: 찍어둔 이전 값으로 롤백 onError: (e, _liked, previous) => { if (previous) onChange(previous); }, });

여기 주의할게 롤백할 값을 클로저의 state에서 읽으면 안 됩니다. onError가 돌 시점엔 렌더가 이미 낙관적 값으로 지나간 뒤라, 클로저에 남은 state를 쓰면 낙관적 값으로 "되돌리는" 꼴이 됩니다. 그래서 onMutate가 그 순간의 값을 리턴해 컨텍스트로 넘기고, onError가 그걸 받아 롤백합니다. RQ가 이 컨텍스트 전달 통로를 공식으로 제공하는 이유가 바로 이겁니다.

디테일 두 가지를 더 붙였습니다. 응답 대기 중 재클릭은 isPending으로 막습니다. API가 멱등이라 순서가 뒤집혀도 서버는 안 망가지지만, 늦게 온 응답의 카운트가 화면을 덮으면 숫자가 튑니다.

그리고 이 훅은 캐시를 직접 만지지 않고 onChange 콜백으로 원본 갱신을 위임합니다. 좋아요의 원본이 화면마다 다르기 때문입니다. 릴스는 무한 쿼리 캐시가 원본이라 캐시를 고쳐야 넘겼다 돌아와도 유지되고, 기사 상세는 서버가 내려준 props가 원본이라 컴포넌트 state로 받습니다.

릴스 쪽 onChange 구현은 이렇게 캐시의 전 페이지를 훑어 같은 id를 고칩니다.

export function useReelLike(reel: ReelCard) { const queryClient = useQueryClient(); return useArticleLike({ articleId: reel.id, state: { liked: reel.liked, likeCount: reel.likeCount }, onChange: (next) => { queryClient.setQueryData<InfiniteData<ReelFeedPage, string | null>>( reelKeys.feed(), (data) => data && { ...data, pages: data.pages.map((page) => ({ ...page, items: page.items.map((item) => item.id === reel.id ? { ...item, ...next } : item, ), })), }, ); }, }); }

image.png

패턴 2: 댓글 작성 — 낙관 없이 응답을 캐시에 삽입

댓글 작성은 낙관적 갱신을 안 합니다. BE가 201로 생성된 댓글 객체(id, 작성 시각 포함)를 그대로 돌려주기 때문에, 성공 응답을 목록 캐시에 끼워 넣기만 하면 됩니다. 가짜 임시 id를 만들어 넣었다가 진짜 id로 바꿔치는 낙관적 갱신의 번거로움을 치를 이유가 없고, 등록 버튼 한 번 누르는 동작이라 즉시성도 덜 중요합니다.

onSuccess: (comment: ArticleComment, input) => { qc.setQueryData<InfiniteData<CommentPage, string | null>>( commentKeys.list(articleId), (data) => { // 목록을 아직 안 받았으면 끼울 곳이 없다 — 첫 조회가 새 댓글째 받아온다 const first = data?.pages[0]; if (!data || !first) return data; // 원 댓글은 첫 페이지 맨 앞에(최상위는 최신순) if (input.parentCommentId == null) { return { ...data, pages: [ { ...first, items: [comment, ...first.items] }, ...data.pages.slice(1), ], }; } // 답글은 부모의 replies 끝에(답글은 오래된순) return { ...data, pages: data.pages.map((page) => ({ ...page, items: page.items.map((item) => item.id === input.parentCommentId ? { ...item, replies: [...item.replies, comment] } : item, ), })), }; }, ); },

끼워 넣는 위치가 서버의 정렬 규칙과 같아야 한다는 게 포인트입니다. 최상위 댓글은 최신순이니 맨 앞에, 답글은 오래된순이니 끝에 넣습니다. 캐시를 "서버가 다음에 내려줄 모양"과 똑같이 만들어 두면, 나중에 refetch가 와도 화면이 안 튑니다.

패턴 3: 댓글 삭제 — 빼지 않고 tombstone으로

삭제는 직관과 다르게 갔습니다. 항목을 목록에서 빼는 게 아니라 { content: null, isDeleted: true }로 바꿉니다. 이런 걸 tombstone(묘비)이라고 부릅니다. 삭제됐다는 표시만 남기고 자리는 지키는 겁니다.

onSuccess: ((commentId: number) => { qc.setQueryData<InfiniteData<CommentPage, string | null>>( commentKeys.list(articleId), (data) => data && { ...data, pages: data.pages.map((page) => ({ ...page, items: page.items.map((item) => applyDelete(item, commentId)), })), }, ); }, /** 대상이면 tombstone으로 바꾸고, 아니면 답글들에서 찾아 바꾼다 */ function applyDelete(item: ArticleComment, commentId: number): ArticleComment { if (item.id === commentId) { return { ...item, content: null, isDeleted: true }; } const replies = item.replies.map((reply) => reply.id === commentId ? { ...reply, content: null, isDeleted: true } : reply, ); return replies.some((reply, i) => reply !== item.replies[i]) ? { ...item, replies } : item; }

이유는 BE의 조회 동작에 있습니다. 현재 BE가 삭제된 댓글을 목록에서 빼주지 않고 tombstone 형태로 계속 내려줍니다(대댓글 유무와 무관하게). 캐시에서 항목을 빼 버리면, 다음 refetch 때 서버 응답에는 그 tombstone이 있으니 "삭제한 댓글이 되살아나는" 깜빡임이 생깁니다. 여기서도 원칙은 같습니다. 캐시를 서버가 다음에 줄 모양과 똑같이 만들어 둔다!

수동 새로고침: invalidate 대신 reset

마지막으로, 당겨서 새로고침이나 홈 탭 재탭 같은 수동 갱신 통로가 하나 있습니다. 여기서도 invalidateQueries를 안 쓰고 resetQueries를 씁니다.

export function useHomeRefresh() { const queryClient = useQueryClient(); const router = useRouter(); return useCallback(async () => { const { homeFilter } = useViewState.getState(); router.refresh(); // 서버 컴포넌트 영역(캐러셀)도 RSC 페이로드 재요청으로 갱신 await queryClient.resetQueries({ queryKey: articleKeys.feed(homeFilter) }); }, [queryClient, router]); }

무효화는 쌓인 페이지 전부를 순차 재요청하지만, 리셋은 캐시를 비우고 첫 페이지 하나만 다시 받습니다. 어차피 새로고침과 함께 맨 위로 올라가므로 뒷 페이지를 되살릴 이유가 없습니다. 그리고 화면에는 서버 컴포넌트가 그리는 영역(핫이슈 캐러셀)도 있어서 router.refresh()를 같이 부릅니다. 이건 페이지 리로드가 아니라 서버에 RSC 페이로드를 다시 요청해 화면을 갈아 끼우는 소프트 갱신이라, 스크롤 위치나 필터 같은 클라이언트 상태는 그대로 남습니다.

8. 정리

요지는 하나입니다. 진실은 서버에 있는데, 화면은 클라이언트 캐시를 그린다. 둘이 어긋나는 순간이 두 번 있고, 각각 이렇게 풀었습니다.

첫 로드 때 (서버가 앞서 있음)

  • 1.서버가 받은 첫 페이지를 RQ 캐시에 심어 이중 페치를 막습니다.
  • 2.방법은 dehydrate(캐시 통째 직렬화)와 initialData 시딩(값을 props로) 두 가지. 쿼리가 하나뿐이라 가벼운 initialData를 골랐고, 무한 쿼리에서는 "캐시에 데이터가 있으면 무시된다"는 initialData의 동작이 스크롤 보존에 오히려 필수였습니다.
  • 3.대가로 initialDataUpdatedAt에 서버 fetch 시각을 직접 달아 신선도 시계를 맞춥니다. 기기 시계 뒤처짐은 Math.min으로 클램프합니다.

뮤테이션 때 (창고가 뒤처짐)

  • 1.커서 기반 무한 쿼리에서 invalidateQueries는 쌓인 페이지 전부의 순차 재요청이라 비쌉니다. 대신 setQueryData로 캐시를 직접 고칩니다.
  • 2.좋아요는 낙관적 갱신 + 컨텍스트 기반 롤백. 댓글 작성은 서버가 돌려준 객체를 정렬 규칙에 맞는 자리에 삽입. 삭제는 제거 대신 tombstone.
  • 3.세 패턴의 공통 원칙은 하나입니다. 캐시를 서버가 다음에 내려줄 응답과 똑같은 모양으로 만들어 둔다. 그러면 최종 정합성은 다음 refetch가 서버 값으로 덮으면서 자연히 보장됩니다.
  • 4.수동 새로고침은 resetQueries(첫 페이지만) + router.refresh()(서버 컴포넌트 영역)로 갱신합니다.

캐시가 셋이고 서로 남남이라는 것, 실행 장소가 어느 캐시를 탈지 가른다는 것. 이 걸 이해하면 Next.js와 TanStack Query 사이에서 데이터가 어디에 살고 언제 어긋나는지를 이해할 수 있을 것 같습니다.