전체 흐름 요약
await queryClient.prefetchQuery({
  queryKey: logKeys.list(params),
  queryFn: () => getLogs(params),
});
return (
  <HydrationBoundary state={dehydrate(queryClient)}>
    <ClientComponent />
  </HydrationBoundary>
);
useQuery({
  queryKey: logKeys.list(params),
  queryFn: () => fetchUseLogs(params),
});
- 서버에서 만든 
queryClient를 dehydrate로 직렬화 
HydrationBoundary 하위에서 동일한 queryKey를 쓰면, 클라이언트는 SSR 캐시를 재사용 
queryFn은 fallback용 (SSR 캐시가 없을 때만 실행됨) 
핵심 전략 1: queryKey는 SSR/CSR 완전 일치
예시 - 로그 리스트
export const logKeys = {
  list: ({ currentPage, pageSize, sort }: LogsParams) =>
    safeKey('log', 'list', `page-${currentPage}`, `size-${pageSize}`, `sort-${sort}`),
};
unstable_cache(() => fetchLogs(params), [...logKeys.list(params)], { ... });
useQuery({
  queryKey: logKeys.list(params),
  queryFn: () => fetchUseLogs(params),
});
queryKey가 완전히 동일해야 dehydrate() → useQuery()로 캐시가 이어진다. 
[...logKeys.list(params)]처럼 펼친 이유
이유 1: unstable_cache는 keyParts를 배열로 받아야 하기 때문
unstable_cache(() => ..., 'log:list:page-1')
ㅤ
unstable_cache(() => ..., ['log', 'list', 'page-1'])
이유 2: 명시적 배열 보장 + 유지보수 유리
logKeys.list()는 string[]을 반환하지만, 조건문 등으로 key가 변할 경우 문제가 생길 수 있음 
- 따라서 항상 
[...]로 명시해주는 것이 타입 안전하고 일관성 있음
ㅤ 
실제 추천 패턴
const queryKey = logKeys.list(params);
ㅤ
return unstable_cache(
  () => fetchLogs(params),
  [...queryKey], 
  {
    tags: [cacheTags.logList(params), cacheTags.logAll],
    revalidate: 300,
  }
)();
핵심 전략 2: tagKey는 SSR 무효화용 의미 기반 키
예시 - 로그 캐시 무효화
export const cacheTags = {
  logList: (params: LogsParams) => logKeys.list(params).join(':'), 
  logAll: 'log:all',
};
unstable_cache(() => fetchLogs(params), [...logKeys.list(params)], {
  tags: [cacheTags.logList(params), cacheTags.logAll],
  revalidate: 300,
});
revalidateTag(cacheTags.logList(params));
revalidateTag(cacheTags.logAll);
전체 구성 구조
queryKey 정의 (keys.ts)
export const logKeys = {
  list: ({ currentPage, pageSize, sort }: LogsParams) =>
    safeKey('log', 'list', `page-${currentPage}`, `size-${pageSize}`, `sort-${sort}`),
  detail: (logId: string) => safeKey('log', 'detail', logId),
};
export const cacheTags = {
  logList: (params: LogsParams) => logKeys.list(params).join(':'),
  logDetail: (logId: string) => logKeys.detail(logId).join(':'),
  logAll: 'log:all',
};
서버 캐시 예시 전체
export async function getLogs(params: LogsParams) {
  return unstable_cache(
    () => fetchLogs(params),
    [...logKeys.list(params)], 
    {
      tags: [
        cacheTags.logList(params), 
        cacheTags.logAll,          
      ],
      revalidate: 300,
    }
  )();
}
마무리
| 항목 | 설명 | 예시 | 
|---|
queryKey | SSR/CSR 캐시 키 | ['log', 'list', 'page-1', ...] | 
tagKey (cacheTags) | 무효화용 키 (문자열) | 'log:list:page-1:...' | 
globalTags | 그룹 무효화 키 | 'log:all', 'user:all' 등 | 
[...] | keyParts 배열 보장 | unstable_cache(..., [...queryKey]) | 
팁
queryKey는 useQuery, prefetchQuery, unstable_cache 모두 동일 구조로 
tagKey는 문자열 기반 → join(':') 또는 의미 있는 키 구성 
safeKey() 유틸로 undefined/null 제거 → 캐시 키 일관성 유지 
dehydrate + HydrationBoundary를 썼다면 반드시 queryKey 일치를 보장해야 함