[Next.js] 즐겨찾기 페이지의 렌더링 전략 전환기: 하이드레이션 워터폴과 이중 요청을 React Query Prefetch로 해결하기

즐겨찾기 기능을 처음 구현할 때는 별 고민 없이 SSR로 처리했다. 어차피 서버에서 데이터를 내려주면 되는 거 아닌가 싶었기 때문이다.

그런데 실제로 운영하다 보니 즐겨찾기를 추가하거나 제거해도 페이지를 새로고침하면 예전 상태가 그대로 표시되는 버그가 발생했다. 이걸 고치려고 CSR로 바꿨더니 이번엔 하이드레이션 이후 데이터 요청 워터폴과, Http-Only 쿠키 처리를 위한 프록시 이중 요청이라는 새로운 문제가 기다리고 있었다.

결국 두 번의 렌더링 전략 전환을 거쳐 React Query의 prefetch 기반 SSR로 정착하게 되었는데, 그 과정을 정리해보려 한다.

 

목차

  1. 1단계: 정적 SSR의 즐겨찾기 갱신 버그
  2. 2단계: CSR 전환과 새롭게 생긴 문제들
  3. 하이드레이션 워터폴이란 무엇인가
  4. Http-Only 쿠키와 프록시 이중 요청
  5. 3단계: React Query Prefetch 기반 SSR로 전환
  6. prefetch → dehydrate → hydrate → HydrationBoundary 파이프라인
  7. 설계의 핵심: queryHash 매칭과 안전장치
  8. 최종 정리

 

1단계: 정적 SSR의 즐겨찾기 갱신 버그

즐겨찾기 페이지는 처음엔 서버 컴포넌트가 직접 목록을 가져와 그대로 내려주는 구조였다. FavoriteItemSection이 async 서버 컴포넌트로 getFavoriteList를 호출하고, 받아온 배열을 클라이언트 컴포넌트인 FavoriteItemList에 props로 넘기는 방식이었다.

// widgets/favorite/ui/favorite-item-section.tsx (React Query 도입 전)
async function FavoriteItemSection() {
  const favoriteListResponse = await getFavoriteList({
    page: 1,
    size: MAX_FAVORITE_SIZE,
  });

  if (!favoriteListResponse.ok || !favoriteListResponse.data) {
    return ;
  }

  return ();
}

 

문제는 목록의 각 아이템이 렌더링하는 즐겨찾기 버튼 쪽에 있었다. FavoriteButton은 서버에서 내려받은 isFavorite을 useState의 초기값으로만 쓰고, 토글에 성공하면 로컬 상태만 뒤집었다.

 

// shared/ui/favorite-button.tsx (React Query 도입 전)
function FavoriteButton({ isFavorite, clubId }: FavoriteButtonProps) {
  const [favorite, setFavorite] = useState(isFavorite);

  const handleToggle = useMemo(
    () =>
      throttle(async () => {
        const result = !favorite
          ? await postFavorite(Number(clubId))
          : await deleteFavorite(Number(clubId));

        if (!result.ok) {
          toast.error(result.message);
          return;
        }
        // 문제: 로컬 상태만 토글하고 부모 목록이나 서버 상태는 건드리지 않음
        setFavorite((prev) => !prev);
      }, 300),
    [favorite, session, clubId],
  );
  // ...
}

 

여기서 버그가 드러났다. 즐겨찾기 페이지에서 해제 버튼을 눌러도 아이콘만 꺼질 뿐, FavoriteItemList가 들고 있는 clubs 배열 자체에서는 그 아이템이 빠지지 않았다. 목록에 여전히 남아있는 상태로, 다른 탭에서 즐겨찾기를 추가/삭제한 뒤 다시 즐겨찾기 페이지로 돌아와도 서버 컴포넌트가 매번 재실행된다는 보장이 없어 예전 목록이 그대로 보이는 경우도 있었다.

원인은 명확했다. 서버가 내려준 초기 데이터와, 버튼 하나하나가 들고 있는 로컬 상태가 완전히 분리되어 있었다. 버튼을 누르면 화면상 아이콘은 바뀌지만, 그 변경이 목록 배열에도, 서버 상태에도 반영되지 않으니 어느 시점에 무엇이 진실의 원천(source of truth)인지 알 수 없는 상태가 된 것이다.

 

 

2단계: CSR 전환과 새롭게 생긴 문제들

갱신 버그를 해결하기 위해 즐겨찾기 페이지를 CSR로 전환했다. React Query를 도입하고, 페이지에 진입할 때마다 클라이언트에서 데이터를 새로 fetching하도록 했다.

// widgets/favorite/ui/favorite-item-section.tsx (React Query 전환 후)
'use client';

function FavoriteItemSection() {
  const searchParams = useSearchParams();
  const page = Number(searchParams.get('page')) || 1;
  const size = Number(searchParams.get('size')) || 6;

  const { data } = useSuspenseQuery(favoriteQueries.list({ page, size }));

  return (

  );
}

 

즐겨찾기 갱신 버그는 해결되었다. 버튼 토글이 성공하면 mutation의 onSuccess에서 ['favorites'] 쿼리를 명시적으로 무효화하도록 했기 때문이다.

 

// src/widgets/favorite/ui/ClientFavoriteButton.tsx
const onSuccess = () => {
  setFavorite((prev) => !prev);
  queryClient.invalidateQueries({ queryKey: ['favorites'] });
};

const { mutate: addFavorite } = useMutation({ ...postFavoriteMutationOptions(), onSuccess });
const { mutate: removeFavorite } = useMutation({ ...deleteFavoriteMutationOptions(), onSuccess });

 

버튼 하나를 눌러도 ['favorites']로 시작하는 모든 페이지네이션 쿼리가 다시 검증되니, 목록 배열과 버튼 상태가 따로 노는 일이 사라졌다.

 

그런데 CSR로 바꾸고 나서 두 가지 새로운 문제가 등장했다.

 

첫째, 하이드레이션 이후 데이터 요청 워터폴
둘째, Http-Only 쿠키로 인한 프록시 이중 요청

 

하이드레이션 워터폴이란 무엇인가

CSR 방식에서 Next.js 페이지가 렌더링되는 흐름을 생각해보면 이런 식이다.

1. 서버: HTML 껍데기(빈 상태) 전송
2. 클라이언트: HTML 수신 및 파싱
3. 클라이언트: JavaScript 번들 다운로드
4. 클라이언트: 하이드레이션 (React가 DOM을 장악)
5. 클라이언트: useQuery 실행, API 요청 시작
6. 클라이언트: 데이터 수신 후 UI 렌더링

 

문제는 5번 단계다. useQuery가 실행되려면 하이드레이션이 완료되어야 한다. 즉, 사용자는 페이지를 열었을 때 실제 콘텐츠를 보기까지 "HTML 수신 → JS 다운로드 → 하이드레이션 → API 요청 → 데이터 수신" 이라는 긴 연쇄 과정을 기다려야 한다.

즐겨찾기 페이지가 복잡해지면서 이 지연이 눈에 띄게 느껴졌다. 목록 자체가 prefetch 없이 순수 클라이언트 쿼리였다 보니, useSuspenseQuery가 실행되는 시점 자체가 하이드레이션 이후로 밀렸다.

function FavoriteItemSection() {
  const searchParams = useSearchParams();
  const page = Number(searchParams.get('page')) || 1;
  const size = Number(searchParams.get('size')) || 6;

  // 하이드레이션이 끝나야 이 쿼리가 시작된다 → 워터폴
  const { data } = useSuspenseQuery(favoriteQueries.list({ page, size }));

  return ;
}

Suspense 경계 덕분에 로딩 UI 자체는 자연스러웠지만, "HTML 수신 → JS 다운로드 → 하이드레이션 → 쿼리 시작 → 데이터 수신"이라는 체인이 그대로 노출되는 건 마찬가지였다.

 

Http-Only 쿠키와 프록시 이중 요청

두 번째 문제는 인증 방식에서 비롯되었다. 보안을 위해 인증 토큰을 Http-Only 쿠키로 관리하고 있었는데, Http-Only 쿠키는 JavaScript에서 직접 읽을 수 없다. 그래서 클라이언트에서 외부 API를 직접 호출할 때 인증 헤더를 붙이는 게 불가능했다.

이를 해결하기 위해 Next.js API Routes를 프록시로 두는 방식을 채택했다.

클라이언트 → Next.js API Route (프록시) → 외부 API 서버
// src/app/api/favorites/route.ts (프록시 역할)
import { NextRequest } from 'next/server';
import requestByUser from '@/shared/lib/user-request';

export async function GET(request: NextRequest) {
  return requestByUser('favorites', {
    searchParams: request.nextUrl.searchParams,
  });
}

 

이 구조 자체는 문제가 없었다. 그런데 CSR 방식에서 이렇게 되면 요청 흐름이 이중으로 이루어졌다.

[요청 흐름]
1. 클라이언트 → /api/favorites (Next.js 프록시)  ← 1번 요청
2. Next.js 프록시 → 외부 API 서버               ← 2번 요청

= 사용자 한 명의 즐겨찾기 조회에 네트워크 홉이 2회 발생

 

사용자 수가 많아질수록 이 이중 요청은 서버 부하와 응답 지연 양쪽에서 손해였다. 물론 SSR에서도 프록시를 쓸 수는 있지만, 서버 컴포넌트나 getServerSideProps에서 직접 외부 API를 호출하면 프록시를 거치지 않고 서버-to-서버로 바로 통신할 수 있다. CSR에서는 이 옵션이 없었다.

 

3단계: React Query Prefetch 기반 SSR로 전환

두 문제를 동시에 해결하기 위해 최종적으로 React Query의 prefetch 기반 SSR을 도입하기로 했다. 핵심 아이디어는 이렇다.

  • 서버에서 React Query의 prefetchQuery를 실행해 데이터를 미리 가져온다
  • 그 상태를 dehydrate로 직렬화해 클라이언트에 전달한다
  • 클라이언트는 HydrationBoundary를 통해 hydrate하여 서버에서 가져온 데이터를 그대로 사용한다
  • 이후의 상태 관리(refetch, mutation 등)는 기존 React Query 방식대로 클라이언트에서 처리한다

이렇게 하면 하이드레이션 완료를 기다릴 필요 없이 첫 렌더링부터 데이터가 있는 상태로 그릴 수 있고, 서버에서 직접 외부 API를 호출하므로 프록시 이중 요청도 사라진다.

 

prefetch → dehydrate → hydrate → HydrationBoundary 파이프라인 [Next.js + React Query]

전환 과정을 단계별로 살펴보자.

서버에서 QueryClient 생성 및 prefetch

// src/views/favorite/ui/favorite-page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import FavoriteDateSection from '@/widgets/favorite/ui/favorite-date-section';
import FavoriteItemSection from '@/widgets/favorite/ui/favorite-item-section';
import favoriteQueries from '@/widgets/favorite/api/queries';
import getServerFavoriteList from '@/widgets/favorite/api/getServerFavoriteList';
import { getSession } from '@/shared/lib/cookie-session';
import getServerQueryClient from '@/shared/lib/get-query-client';
import LoginRequired from '@/shared/ui/login-required';

interface FavoritePageProps {
  page: number;
  size: number;
}

async function FavoritePage({ page, size }: FavoritePageProps) {
  const session = await getSession();
  if (!session) return ;

  const queryClient = getServerQueryClient();
  await queryClient.prefetchQuery({
    queryKey: favoriteQueries.list({ page, size }).queryKey,
    queryFn: () => getServerFavoriteList({ page, size }),
  });

  return (






  );
}

export default FavoritePage;

page/size는 상위 app/[universityCode]/(main)/favorite/page.tsx가 searchParams를 읽어서 props로 내려준다. 서버에서 prefetchQuery를 호출하면 queryClient 내부에 결과가 캐시되고, dehydrate(queryClient)가 이 캐시 상태를 직렬화해 HTML에 실어 클라이언트로 넘긴다.

여기서 new QueryClient()를 직접 쓰지 않고 getServerQueryClient()를 쓰는 이유는 뒤에 나오는 "요청 단위 QueryClient 관리" 절에서 다룬다.

 

서버 전용 fetch 함수

// src/widgets/favorite/api/getServerFavoriteList.ts
import 'server-only';
import api from '@/shared/api/auth-api';
import type { FavoriteList } from '@/shared/model/type';

interface GetServerFavoriteListParams {
  page: number;
  size: number;
}

async function getServerFavoriteList({ page, size }: GetServerFavoriteListParams) {
  const json = await api
    .get('favorites', { searchParams: { page: String(page), size: String(size) } })
    .json();
  return json.data;
}

export default getServerFavoriteList;

 

쿠키는 이 함수가 직접 읽는 게 아니라, 서버 전용 ky 인스턴스(auth-api.ts)의 beforeRequest 훅이 대신 처리한다.

 

// src/shared/api/auth-api.ts
import 'server-only';
import ky from 'ky';
import { getSession } from '@/shared/lib/cookie-session';

const api = ky.create({
  prefixUrl: process.env.NEXT_PUBLIC_API_URL,
  hooks: {
    beforeRequest: [
      async (req) => {
        if (req.headers.get('Authorization')) return;
        const session = await getSession();
        if (session?.accessToken) {
          req.headers.set('Authorization', `Bearer ${session.accessToken}`);
        }
      },
    ],
  },
});

export default api;

getSession()이 Http-Only 쿠키를 읽어 accessToken을 꺼내고, 매 요청마다 Authorization 헤더를 자동으로 붙여준다. 개별 fetch 함수(getServerFavoriteList 등)는 인증을 신경 쓸 필요 없이 api.get(...)만 호출하면 된다. 프록시를 거치지 않고 외부 API에 서버-to-서버로 바로 요청한다는 점은 동일하다.

 

클라이언트 컴포넌트

prefetch가 붙었다고 클라이언트 코드가 달라지지는 않는다. 2단계에서 만든 FavoriteItemSection, ClientFavoriteButton을 그대로 재사용한다.

// src/widgets/favorite/ui/favorite-item-section.tsx
'use client';

function FavoriteItemSection() {
  const searchParams = useSearchParams();
  const page = Number(searchParams.get('page')) || 1;
  const size = Number(searchParams.get('size')) || 6;

  const { data } = useSuspenseQuery(favoriteQueries.list({ page, size }));

  return (

  );
}

 

달라지는 건 코드가 아니라 동작이다. HydrationBoundary가 favoriteQueries.list({ page, size })와 동일한 queryKey로 캐시를 미리 채워놓기 때문에, 이 useSuspenseQuery는 첫 렌더링부터 캐시 히트로 즉시 데이터를 반환한다. Suspense fallback도, 클라이언트 API 요청도 없이 곧바로 목록이 그려진다.

 

렌더링 흐름 비교

[CSR 방식]
서버: HTML 껍데기 전송
클라이언트: JS 다운로드 → 하이드레이션 → useSuspenseQuery 실행
→ API 요청 (프록시 경유, 2회 홉)
→ 데이터 수신 → 렌더링

[Prefetch SSR 방식]
서버: prefetchQuery 실행(외부 API 직접 호출) → dehydrate → HTML에 데이터 포함해 전송
클라이언트: JS 다운로드 → HydrationBoundary에서 hydrate
→ useSuspenseQuery 호출 시 캐시에서 즉시 반환 (API 요청 없음)
→ 즉시 렌더링

 

설계의 핵심: queryHash 매칭과 안전장치

단순히 prefetchQuery와 HydrationBoundary를 사용하는 것 이상으로, 내부 동작 방식을 이해하는 것이 중요하다. React Query의 prefetch 기반 SSR에는 몇 가지 중요한 안전장치가 내장되어 있다.

 

queryHash 기반 매칭

서버에서 prefetch할 때와 클라이언트에서 useQuery를 호출할 때 queryKey가 정확히 일치해야 한다. React Query는 queryKey를 해시한 queryHash를 기준으로 캐시 항목을 매칭한다.

// 서버 prefetch
await queryClient.prefetchQuery({
  queryKey: ['item', id], // ['item', '123']
  queryFn: () => fetchItemDetailServer(id),
});

// 클라이언트 useQuery
const { data } = useQuery({
  queryKey: ['item', id], // ['item', '123'] - 반드시 동일해야 함
  queryFn: () => fetchItemDetail(id),
});

queryKey가 다르면 캐시 매칭이 실패하고 클라이언트에서 다시 API 요청이 발생한다. 이런 식으로 서버와 클라이언트가 동일한 queryKey 구조를 사용하도록 상수로 관리하는 것이 좋아 보인다.

 

// lib/queryKeys.ts - queryKey를 상수로 관리
export const favoriteKeys = {
  all: ['favorites'] as const,
  detail: (id: string) => ['item', id] as const,
};

// 사용 예시 - 서버와 클라이언트 모두 같은 키 사용
queryClient.prefetchQuery({
  queryKey: favoriteKeys.detail(id),
  queryFn: () => fetchItemDetailServer(id),
});

 

더 최신 데이터만 덮어쓰는 안전장치

hydrate 함수는 단순히 서버 데이터로 클라이언트 캐시를 덮어쓰지 않는다. 내부적으로 데이터의 dataUpdatedAt 타임스탬프를 비교하여 더 최신 데이터만 캐시를 업데이트한다.

[hydrate 내부 동작]
for each query in dehydratedState:
  if (클라이언트 캐시에 같은 queryHash가 없음):
    → 서버 데이터로 캐시 채움
  else if (서버 데이터의 dataUpdatedAt > 클라이언트 캐시의 dataUpdatedAt):
    → 더 최신인 서버 데이터로 덮어씀
  else:
    → 클라이언트 캐시 유지 (서버 데이터 무시)

 

이 안전장치 덕분에 다음과 같은 시나리오에서도 안전하다.

 

1. 사용자가 즐겨찾기 페이지 방문 (서버 prefetch 데이터 hydrate됨)
2. 사용자가 아이템 즐겨찾기 추가 (클라이언트 캐시 업데이트됨)
3. 사용자가 뒤로 갔다가 다시 즐겨찾기 페이지로 (서버 prefetch 재실행)
4. hydrate 시 클라이언트 캐시가 더 최신이므로 덮어쓰지 않음
→ 사용자가 방금 추가한 즐겨찾기 상태 유지

 

참고로 이건 우리가 따로 구현한 로직이 아니라 React Query의 hydrate 내장 동작이다. 갱신 버그 자체는 이미 2단계에서 invalidateQueries로 해결됐지만, prefetch SSR을 얹으면서 생길 수 있는 "뒤로가기 시 서버 데이터가 최신 mutation 결과를 덮어쓰지 않을까"라는 우려를 이 내장 동작이 자동으로 막아준다.

 

GlobalQueryClient 설정 주의점

// src/shared/lib/get-query-client.ts
import 'server-only';
import { cache } from 'react';
import createQueryClient from './query-client';

const getServerQueryClient = cache(() => createQueryClient());

export default getServerQueryClient;

 

매 요청마다 함수 바디에서 new QueryClient()를 호출해도 되지만, 여러 서버 컴포넌트가 같은 QueryClient를 공유해야 할 가능성을 대비해 React의 cache()로 감쌌다. cache()는 하나의 요청(request) 생명주기 안에서만 결과를 재사용하고 요청이 끝나면 초기화되므로, 모듈 레벨 싱글톤처럼 사용자 간 데이터가 섞이는 일 없이 동일한 이점(중복 생성 방지)을 얻을 수 있다.

 

최종 정리

즐겨찾기 페이지의 세 번에 걸친 렌더링 전략 전환을 다시 정리해보자면 이렇다.

 

1단계 - 서버 컴포넌트 SSR

  • 문제: 서버가 내려준 초기 데이터와 즐겨찾기 버튼의 로컬 state(useState)가 분리되어, 토글해도 목록 배열 자체는 갱신되지 않음
  • async 서버 컴포넌트(FavoriteItemSection) + 버튼별 useState 구조에서 발생

 

2단계 - CSR

  • 문제 1: 하이드레이션 이후 워터폴 (하이드레이션 완료 후에야 API 요청 시작)
  • 문제 2: Http-Only 쿠키로 인한 프록시 이중 요청
  • 갱신 버그는 해결, 성능과 구조 문제가 새로 생김

 

3단계 - React Query Prefetch 기반 SSR (최종)

  • 서버에서 prefetchQuery → dehydrate → 클라이언트에서 HydrationBoundary → hydrate
  • 초기 로드 성능 개선 (API 요청 없이 첫 렌더링부터 데이터 표시)
  • 프록시 이중 요청 제거 (서버-to-서버 직접 통신)
  • 클라이언트 상태 관리 반응성 유지 (mutation, invalidation 그대로 동작)

 

이 방식의 핵심을 세 가지로 정리하면 이렇다.

  • queryHash 기반 매칭: 서버와 클라이언트가 동일한 queryOptions().queryKey(favoriteQueries.list)를 써야 캐시 히트
  • 더 최신 데이터만 덮어쓰는 안전장치: React Query hydrate의 내장 동작 — dataUpdatedAt 비교로 클라이언트 상태 보호 (직접 구현한 로직 아님)
  • 요청 단위 QueryClient 관리: cache()로 감싸 요청 간 캐시가 섞이지 않도록 함

물론 이 전환이 공짜는 아니었다. 실제로 적용 전후를 비교해보니 세 가지가 눈에 띄게 달라졌다.

 

1) 하이드레이션 후 재요청이 사라짐


왼쪽(CSR)은 하이드레이션 이후 _favorites?page=... 요청이 워터폴에 그대로 잡히지만, 오른쪽(SSR prefetch 적용 후)은 해당 요청이 사라졌다._

더 이상 클라이언트단에서 즐겨찾기 목록에 대한 요청을 하지 않는다.

 

2) Lighthouse Performance: 55 → 57


 


SI, FCP가 오르면서 전체 점수도 소폭 상승했다.

  • SI 개선: 이전엔 하이드레이션 이후 프록시 요청을 통해 데이터가 주입되는 구조였는데, 이 구조 자체가 바뀌면서 상승했다.
  • FCP 개선: 즐겨찾기 페이지는 목록이 가장 먼저 그려지는 UI인데, 이전엔 이 목록이 프록시 요청 이후에야 그려질 수 있었던 반면 이제는 초기 HTML에 바로 실려서 상승했다.

 

3) 반대로 TTFB는 늘었다



서버가 prefetch 연산까지 마치고 응답해야 하니, 첫 바이트를 받기까지의 시간(TTFB)이 늘었다. Waiting for server 구간만 84ms 이상, 전체 TTFB는 약 120ms로 측정됐다.

클라이언트가 빈 셸을 먼저 받고 나중에 데이터를 받던 구조에서, 서버가 데이터까지 다 채워서 내려주는 구조로 바뀌었으니 당연한 트레이드오프다.

 

결국 "언제 데이터를 받느냐"의 병목이 클라이언트(하이드레이션 이후 프록시 요청)에서 서버(TTFB)로 옮겨간 셈이다. 다만 이 TTFB 증가가 실제 체감 성능에 얼마나 영향을 주는지는 별도로 더 들여다봐야 했는데, Lighthouse로 LCP/TBT를 다시 뜯어본 결과 메인스레드 경합(무거운 서드파티 스크립트 + 목록 전체의 클라이언트 하이드레이션 비용)이 원인으로 잡혔다. 이 부분은 SSE/WebSocket 같은 실시간 동기화가 아니라, 서드파티 스크립트 로딩 전략 조정과 즐겨찾기 카드의 "아일랜드화"(정적인 부분은 서버 컴포넌트로 남기고 ClientFavoriteButton만 client island로 분리)로 풀어야 할 다음 과제로 남겨뒀다.