Next.js App Router + TanStack Query SSR Hydration + Zustand 예제

서버에서 prefetch한 데이터를 클라이언트 캐시로 넘기고, 유저 인터랙션 시에만 refetch하는 구조.


데이터 흐름

[서버]
  prefetchQuery(initSearchData)
    → dehydrate → JSON 직렬화 → HTML에 포함

[클라이언트 - 첫 렌더]
  HydrationBoundary가 dehydratedState를 QueryClient에 복원
    → useSuspenseQuery(initSearchData) → queryKey 일치 → cache hit (fetch 없음)

[클라이언트 - 유저 인터랙션]
  Zustand filter 변경 → searchData 변경 → queryKey 변경
    → useSuspenseQuery가 새 queryKey 감지 → 자동 refetch

구현

1. API 서비스 레이어

// services/item/itemService.ts

import { ListResponse, SearchParams } from '@/models/item';

class ItemService {
    async getItems(searchData: SearchParams): Promise<ListResponse> {
        const params = new URLSearchParams({
            type: searchData.type,
            ...(searchData.category && { category: searchData.category }),
            ...(searchData.sortBy && { sort_by: searchData.sortBy }),
        });

        const res = await fetch(`/api/items?${params}`);
        return res.json();
    }
}

export default new ItemService();

2. React Query 옵션

서버/클라이언트가 동일한 queryKey + queryFn을 사용하도록 한곳에서 관리한다.

// services/item/queries.ts

import { SearchParams } from '@/models/item';
import itemService from './itemService';

const queryKeys = {
    all: (search: SearchParams) => ['items', search],
};

const queryOptions = {
    all: (search: SearchParams) => ({
        queryKey: queryKeys.all(search),
        queryFn: () => itemService.getItems(search),
    }),
};

export default queryOptions;

3. SSR Dehydration 유틸리티

// utils/react-query/getDehydratedQuery.ts

import { QueryClient, QueryKey, dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { cache } from 'react';

export const getQueryClient = cache(() => new QueryClient());

export async function getDehydratedQuery<T>({ queryKey, queryFn }: { queryKey: QueryKey; queryFn: () => Promise<T> }) {
    const queryClient = getQueryClient();
    await queryClient.prefetchQuery({ queryKey, queryFn });

    const dehydratedState = dehydrate(queryClient);
    const [target] = dehydratedState.queries.filter((q) => JSON.stringify(q.queryKey) === JSON.stringify(queryKey));

    return { queries: [target], mutations: [] };
}

export const Hydrate = HydrationBoundary;

4. ListPage (Server Component)

// app/list/page.tsx

import { Suspense } from 'react';
import { SearchParams } from '@/models/item';
import queryOptions from '@/services/item/queries';
import { Hydrate, getDehydratedQuery } from '@/utils/react-query/getDehydratedQuery';
import ItemListContainer from '../../_components/itemList/container';
import FilterBar from '../../_components/filter/container';
import Loading from './loading';

export default async function ListPage() {
    const initSearchData: SearchParams = {
        type: 'list',
        sortBy: 'default',
    };

    const { queryKey, queryFn } = queryOptions.all(initSearchData);
    const dehydratedState = await getDehydratedQuery({ queryKey, queryFn });

    return (
        <Hydrate state={dehydratedState}>
            <FilterBar type={initSearchData.type} />
            <Suspense fallback={<Loading />}>
                <ItemListContainer initSearchData={initSearchData} />
            </Suspense>
        </Hydrate>
    );
}

5. ItemListContainer (Client Component)

// app/_components/list/ItemListContainer.tsx
'use client';

import { SearchParams } from '@/models/item';
import { useItemFilter } from '@/hooks/useItemFilter';
import { useSuspenseQuery } from '@tanstack/react-query';
import queryOptions from '@/services/item/queries';
import { useFilterStore } from '@/stores/filterStore';
import { useEffect, useState } from 'react';

export default function ItemListContainer({ initSearchData }: { initSearchData: SearchParams }) {
    const [isClient, setIsClient] = useState(false);
    const { isFiltered } = useFilterStore();
    const { searchData } = useItemFilter();

    // 핵심: 초기에는 서버와 동일한 initSearchData → cache hit
    // isClient=true 이후에는 Zustand searchData → 필터 변경 시 refetch
    const { data } = useSuspenseQuery(queryOptions.all(isClient ? searchData : initSearchData));

    useEffect(() => {
        setIsClient(true);
    }, []);

    if (!data) return null;

    if (data.count === 0) {
        return isFiltered ? <p>필터 조건에 맞는 항목이 없습니다.</p> : <p>항목이 없습니다.</p>;
    }

    return (
        <ul>
            {data.results.map((item) => (
                <li key={item.id}>
                    {item.title} - {item.price}
                </li>
            ))}
        </ul>
    );
}

6. Zustand 필터 스토어

// stores/filterStore.ts

import { create } from 'zustand';
import { SearchParams } from '@/models/item';

type FilterState = {
    type: SearchParams['type'];
    category: string;
    sortBy: string;
    isFiltered: boolean;
};

type FilterAction = {
    updateCategory: (category: string) => void;
    updateSort: (sortBy: string) => void;
    reset: (type: SearchParams['type']) => void;
};

export const useFilterStore = create<FilterState & FilterAction>((set) => ({
    type: 'list',
    category: '',
    sortBy: 'default',
    isFiltered: false,

    updateCategory: (category) => set({ category, isFiltered: true }),

    updateSort: (sortBy) => set({ sortBy }),

    reset: (type) => set({ type, category: '', sortBy: 'default', isFiltered: false }),
}));

7. 필터 → 검색 파라미터 변환 훅

// hooks/useItemFilter.ts

import { SearchParams } from '@/models/item';
import { useFilterStore } from '@/stores/filterStore';

export const useItemFilter = () => {
    const { type, category, sortBy } = useFilterStore();

    const searchData: SearchParams = {
        type,
        ...(category && { category }),
        ...(sortBy && { sortBy }),
    };

    return { searchData };
};

8. 모델 정의

// models/item.ts

export interface SearchParams {
    type: 'list' | 'bookmark' | 'history';
    category?: string;
    sortBy?: string;
}

export interface Item {
    id: number;
    title: string;
    price: number;
    isBookmarked: boolean;
}

export interface ListResponse {
    count: number;
    results: Item[];
}

트러블슈팅: 이렇게 하면 안 된다

위 구현에 도달하기까지 마주한 문제들을 코드로 정리한다.

1. queryKey 불일치 → 불필요한 refetch

// 서버 (ListPage)
const initSearchData: SearchParams = {
    type: 'list',
    sortBy: 'default',
};
// → queryKey: ['items', { type: 'list', sortBy: 'default' }]

// 클라이언트 (ItemListContainer)
const { searchData } = useItemFilter();
// Zustand 초기값 → { type: 'list', category: '', sortBy: 'default' }
// → queryKey: ['items', { type: 'list', category: '', sortBy: 'default' }]

const { data } = useSuspenseQuery(queryOptions.all(searchData));
// ❌ queryKey가 다르므로 cache miss → 같은 API를 다시 호출!
// ✅ isClient 플래그로 hydration 시점에는 서버와 동일한 값을 사용
const [isClient, setIsClient] = useState(false);
const { data } = useSuspenseQuery(queryOptions.all(isClient ? searchData : initSearchData));

useEffect(() => {
    setIsClient(true);
}, []);

2. useQuery → 로딩 UI 깜빡임

// ❌ useQuery: cache에 데이터가 있어도 첫 렌더에서 data가 undefined일 수 있음
const { data, isLoading } = useQuery(queryOptions.all(searchData));

if (isLoading) return <Loading />; // hydration 직후 잠깐 보임
return <ItemList items={data.results} />;
// ✅ useSuspenseQuery: cache hit 시 즉시 data 반환, 깜빡임 없음
const { data } = useSuspenseQuery(queryOptions.all(searchData));

// data는 항상 존재 (T 타입, undefined 아님)
return <ItemList items={data.results} />;

3. Safari/iOS WebView Suspense 스트리밍 버그

// ❌ loading.tsx의 HTML이 1KB 미만이면 Safari가 버퍼링하여 표시하지 않음
// → iOS WebView에서 흰 화면이 오래 지속
export default function Loading() {
    return <Spinner />; // HTML 크기가 너무 작음
}

// ✅ loading.tsx의 HTML이 1KB 이상이 되도록 보장
export default function Loading() {
    return (
        <div>
            <Skeleton /> {/* 충분한 크기의 로딩 UI */}
            <Skeleton />
            <Skeleton />
        </div>
    );
}

next.js#52444 — Safari에서 1KB 미만의 스트리밍 청크를 버퍼링하는 버그. Chrome에서는 정상이지만 iOS WebView에서는 fallback이 표시되지 않는다.


isClient 패턴 정리

이 패턴이 필요한 이유: Zustand store의 초기값과 서버 initSearchData의 객체 구조가 다르면 queryKey 불일치로 불필요한 refetch가 발생한다.

서버 initSearchData:  { type: 'list', sortBy: 'default' }
Zustand store 초기값: { type: 'list', category: '', sortBy: 'default' }
                                      ^^^^^^^^^^^^^^^ 이 차이로 cache miss
[첫 렌더 (SSR/Hydration)]
  isClient = false
  → useSuspenseQuery(initSearchData)
  → queryKey가 서버 prefetch와 동일 → cache hit → 네트워크 요청 없음

[useEffect 실행 후]
  isClient = true
  → useSuspenseQuery(searchData)  ← Zustand 기반
  → searchData가 initSearchData와 동일 → queryKey 동일 → 여전히 cache hit

[유저가 필터 변경]
  Zustand store 업데이트 → searchData 변경 → queryKey 변경
  → useSuspenseQuery가 새 queryKey 감지 → 자동 refetch