2025.10.21·6

프론트엔드 데이터 패칭 다시 보기 (04) - prefetch와 hydration은 언제 도움이 되고 언제 구조를 흐리는가

prefetch와 hydration이 초기 로드에 왜 강한지, 그리고 컴포넌트가 많아질수록 왜 중복 요청과 serialization 비용, ownership 혼란으로 이어질 수 있는지 정리합니다.

프론트엔드 데이터 패칭 다시 보기 (04) - prefetch와 hydration은 언제 도움이 되고 언제 구조를 흐리는가 대표 이미지

prefetch와 hydration은 첫 화면을 빠르게 준비하는 데 강합니다. 다만 그 구조를 아무 곳에나 늘리기 시작하면, 같은 패턴이 성능 최적화가 아니라 복잡함의 원인이 되기도 합니다.

들어가며

앞선 글에서는 TanStack Query가 서버 상태를 어떤 모델로 다루는지 정리했습니다. queryKey로 상태를 식별하고, cache로 마지막 값을 붙잡고, staleTime과 invalidation으로 다시 맞추는 구조였습니다.

여기까지 오면 자연스럽게 다음 질문이 나옵니다. 그렇다면 서버에서 미리 query를 채워 두고, 클라이언트가 그 상태를 이어받는 prefetch와 hydration은 언제 가장 잘 작동할까요.

작은 예제에서는 이 패턴이 아주 매력적으로 보입니다. 서버가 먼저 데이터를 준비해 두면 첫 화면에서 바로 내용을 보여 줄 수 있고, 클라이언트는 같은 cache를 이어받아 다시 요청하지 않아도 됩니다.

문제는 이 패턴이 커질 때입니다. 컴포넌트가 많아지고, 각 하위 컴포넌트가 자기 데이터만 생각하기 시작하면, prefetch와 hydration은 더 이상 "깔끔한 최적화"가 아니라 중복 요청, waterfall, 직렬화 비용, ownership 혼란을 만드는 구조가 될 수 있습니다.

이번 글에서는 prefetch와 hydration이 왜 매력적인지부터 시작해, 언제까지는 유용하고 언제부터는 구조를 흐리기 시작하는지를 정리하겠습니다. 핵심은 간단합니다. 이 패턴은 여전히 유효하지만, 화면 하위의 습관적인 패턴보다 상위에서 의도적으로 조율하는 전략에 더 가깝습니다.

이 글에서는 다섯 가지를 중심으로 보겠습니다.

  • prefetch와 hydration이 왜 첫 화면에 강한지
  • 작은 예제에서는 왜 특히 예쁘게 보이는지
  • 커질수록 어떤 비용이 드러나는지
  • 왜 페이지 상단 orchestration이 필요해지는지
  • 무엇을 prefetch하고 무엇을 늦게 가져와야 하는지
Keypoint

prefetch와 hydration의 가치는 "미리 가져온다"보다, 첫 화면의 critical path를 어디에서 끊을지 제어할 수 있다는 데 있습니다.

한 줄로 먼저 요약하면 이렇습니다.

  • prefetch는 요청 시작 시점을 앞당깁니다.
  • hydration은 서버가 준비한 상태를 클라이언트의 첫 기준점으로 넘깁니다.
  • 문제는 기술 자체보다, 그 패턴이 화면 어디에 배치되어 있는가에서 시작됩니다.

prefetch는 요청을 먼저 시작하게 해 줍니다

prefetch를 가장 단순하게 설명하면, 실제 화면에서 데이터를 읽기 전에 먼저 요청을 시작해 두는 일입니다.

이게 중요한 이유는 첫 화면의 병목이 종종 "무엇을 그릴까"보다 언제 요청을 시작했는가에서 갈리기 때문입니다.

예를 들어 클라이언트 컴포넌트가 mount된 뒤에야 데이터를 요청하면, 보통 아래 순서가 됩니다.

  1. HTML이 도착합니다.
  2. 자바스크립트가 로드됩니다.
  3. hydration이 끝납니다.
  4. 그제야 API 요청이 시작됩니다.
  5. 응답이 와야 내용을 그릴 수 있습니다.

이 구조는 화면이 준비된 뒤에야 요청을 시작하기 때문에, 첫 진입에서는 쉽게 늦어집니다. 반대로 서버에서 prefetch를 걸면 요청 시작 시점을 훨씬 앞당길 수 있습니다.

즉 prefetch의 핵심은 "어차피 필요한 요청이라면 더 빨리 시작한다"는 데 있습니다.

hydration은 서버가 준비한 상태를 클라이언트가 이어받게 해 줍니다

prefetch만으로는 아직 절반입니다. 서버가 미리 받아 둔 값을 클라이언트가 그대로 쓸 수 있어야 같은 패턴이 완성됩니다.

이때 필요한 것이 dehydration과 hydration입니다.

  • 서버는 query cache를 먼저 채웁니다.
  • 그 상태를 직렬화 가능한 형태로 바꿉니다.
  • 클라이언트는 그 직렬화된 상태를 다시 cache로 복원합니다.

즉 hydration은 "서버가 받은 값을 props로 한 번 넘겨준다"는 감각보다, 서버가 준비한 서버 상태를 클라이언트 cache가 그대로 이어받는다는 감각에 가깝습니다.

그래서 첫 렌더에서 아래 같은 이점이 생깁니다.

  • 클라이언트가 같은 요청을 다시 시작하지 않아도 됩니다.
  • 이미 준비된 데이터를 바로 읽을 수 있습니다.
  • 이후에는 같은 query key 기준으로 cache를 계속 활용할 수 있습니다.
Keypoint

prefetch가 요청 시작 시점을 앞당긴다면, hydration은 서버가 준비한 결과를 클라이언트의 첫 기준점으로 넘겨주는 과정입니다.

그래서 작은 예제에서는 이 패턴이 특히 좋아 보입니다

예제를 처음 접할 때 prefetch와 hydration이 아주 예쁘게 보이는 이유는, 작은 화면에서는 장점만 또렷하게 드러나기 때문입니다.

  • 서버가 먼저 데이터를 준비합니다.
  • 클라이언트는 그 값을 바로 읽습니다.
  • 같은 query key를 쓰므로 중복 요청도 줄어듭니다.

예를 들어 단일 상세 페이지 하나만 놓고 보면 구조가 꽤 이상적입니다.

// 페이지 진입 전에 서버에서 query cache를 먼저 채웁니다.
const queryClient = new QueryClient();
 
await queryClient.prefetchQuery({
  queryKey: ["post", slug],
  queryFn: () => fetchPost(slug),
});
 
return (
  // 서버가 준비한 cache 상태를 클라이언트가 그대로 이어받습니다.
  <HydrationBoundary state={dehydrate(queryClient)}>
    <PostView slug={slug} />
  </HydrationBoundary>
);

이 구조는 분명히 장점이 있습니다.

  • 첫 화면 내용을 서버가 미리 준비할 수 있습니다.
  • 클라이언트는 첫 렌더에서 같은 데이터를 바로 읽습니다.
  • 이후 상호작용은 query cache 위에서 이어집니다.

문제는 이 코드가 작은 화면 하나에서는 예쁘지만, 같은 패턴이 화면 아래로 복제되기 시작하면 이야기가 달라진다는 점입니다.

커질수록 문제는 데이터보다 배치에서 생깁니다

prefetch와 hydration이 커질 때의 문제는 대개 개별 query 자체보다, 그 query들이 어디에 배치되어 있는가에서 시작합니다.

예를 들어 카드, 탭, 패널, 사이드 영역이 각각 자기 데이터를 스스로 prefetch한다고 가정해 보겠습니다. 각 컴포넌트만 보면 책임이 분리된 것처럼 보일 수 있습니다.

하지만 화면 전체 입장에서는 다음 문제가 생깁니다.

  • 어떤 요청이 더 중요한지 우선순위가 보이지 않습니다.
  • 같은 데이터를 서로 다른 경로에서 중복 prefetch할 수 있습니다.
  • prefetch가 하위 트리에서 연쇄적으로 일어나며 waterfall이 생길 수 있습니다.
  • hydration boundary가 많아질수록 상태를 직렬화하고 복원하는 비용이 커집니다.
  • 결국 누가 이 데이터를 실제로 소유하고 조율하는지 흐려집니다.

즉 prefetch 자체가 문제인 것은 아닙니다. 화면 하위로 흩어진 prefetch 구조가 문제입니다.

한 줄로 줄이면 이렇습니다.

  • 작은 화면에서는 prefetch가 최적화처럼 보입니다.
  • 큰 화면에서는 배치가 나쁘면 prefetch가 병목처럼 보입니다.

특히 하위 컴포넌트 prefetch는 구조를 쉽게 흐립니다

하위 컴포넌트마다 prefetch를 넣는 방식은 처음에는 매력적으로 보입니다. 각 컴포넌트가 자기 데이터를 책임지는 것처럼 보이기 때문입니다.

하지만 이 방식은 곧 아래 질문을 만들기 시작합니다.

  • 이 데이터는 정말 이 컴포넌트만 필요한가
  • 페이지 전체 기준으로도 중요한가
  • 다른 컴포넌트와 함께 병렬로 묶는 편이 더 낫지 않은가
  • 이 hydration boundary는 정말 독립된 경계인가

즉 하위 컴포넌트 prefetch는 "캡슐화"처럼 보이지만, 화면 전체 기준에서는 종종 조율 불가능한 분산 구조가 됩니다.

prefetch는 어디서나 넣을 수 있다고 해서 어디서나 넣는 것이 좋은 패턴은 아닙니다.

실무에서는 이 차이가 코드로 더 직접적으로 보입니다. 예를 들어 각 하위 위젯이 자기 데이터만 보고 같은 패턴을 복제하기 시작하면 이런 구조가 됩니다.

export async function RevenueCard() {
  const queryClient = new QueryClient();
 
  // 카드 하나만 보면 자연스러운 코드처럼 보입니다.
  await queryClient.prefetchQuery(revenueQueryOptions());
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <RevenueCardClient />
    </HydrationBoundary>
  );
}
 
export async function TeamActivityPanel() {
  const queryClient = new QueryClient();
 
  // 패널도 같은 방식으로 자기 데이터만 미리 채웁니다.
  await queryClient.prefetchQuery(teamActivityQueryOptions());
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <TeamActivityPanelClient />
    </HydrationBoundary>
  );
}

각 컴포넌트만 보면 분리된 것처럼 보이지만, 페이지 전체에서는


무엇이 먼저 필요한지, 무엇을 같이 시작해야 하는지, 어디서 중복이 생기는지 를 상위에서 보기 어려워집니다.

그래서 orchestration은 점점 상위로 올라갑니다

화면이 커질수록 필요한 것은 하위의 더 많은 prefetch가 아니라, 상위에서 요청을 모아 조율하는 orchestration입니다.

즉 페이지나 layout 같은 상위 레이어가 아래 질문을 먼저 책임져야 합니다.

  • 첫 화면에 반드시 필요한 데이터는 무엇인가
  • 서로 병렬로 시작할 수 있는 요청은 무엇인가
  • 지금은 굳이 prefetch하지 않아도 되는 것은 무엇인가
  • 어떤 hydration boundary를 하나로 묶는 편이 자연스러운가

이렇게 보면 orchestration은 단순히 "상위에서 다 몰아서 한다"는 뜻이 아닙니다. 화면 전체의 critical path를 누가 책임질지 정하는 일에 가깝습니다.

그래서 더 나은 구조는 대개 페이지 상단에서 필요한 요청을 먼저 모으는 쪽으로 갑니다.

export default async function DashboardPage() {
  const queryClient = new QueryClient();
 
  // 첫 화면에 필요한 요청을 상단에서 병렬로 조율합니다.
  await Promise.all([
    queryClient.prefetchQuery(revenueQueryOptions()),
    queryClient.prefetchQuery(teamActivityQueryOptions()),
  ]);
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <DashboardView />
    </HydrationBoundary>
  );
}

이렇게 하면 적어도 아래 기준이 한곳에 모입니다.

  • 첫 화면에 꼭 필요한 데이터가 무엇인지
  • 무엇을 병렬로 시작할지
  • 무엇을 나중으로 미룰지
  • 어느 범위까지 하나의 hydration 경계로 묶을지
Keypoint

prefetch가 커질수록 중요한 것은 더 많은 prefetch가 아니라, 누가 요청의 우선순위와 경계를 조율할 것인가입니다.

무엇을 prefetch하고 무엇을 늦게 가져와야 할까

이제 기준은 조금 더 분명해집니다. prefetch는 "가능한 것은 전부 미리 가져오기"가 아니라, 첫 화면의 critical path에 필요한 것만 먼저 가져오기에 가깝습니다.

prefetch가 잘 맞는 경우는 보통 이렇습니다.

  • 첫 화면에서 바로 보여 줘야 하는 핵심 데이터
  • 여러 하위 컴포넌트가 함께 참조하는 공통 데이터
  • 요청 시작 시점을 앞당길수록 체감 차이가 큰 데이터
  • 초기 렌더에서 비어 있으면 UX가 크게 흔들리는 데이터

반대로 늦게 가져와도 되는 경우도 분명합니다.

  • 탭을 열기 전까지 필요 없는 데이터
  • 사용자가 특정 상호작용을 해야만 필요한 데이터
  • 페이지 하단이나 보조 패널처럼 중요도가 낮은 정보
  • 이미 이전 cache로 충분히 대응 가능한 데이터

즉 prefetch의 기준은 "기술적으로 가능하냐"가 아니라, 지금 이 데이터가 첫 화면의 비용을 정당화할 만큼 중요한가입니다.

한 번 더 줄이면 판단 기준은 이렇습니다.

  • 공통으로 쓰이고 첫 화면에 꼭 필요하면 상위에서 prefetch합니다.
  • 특정 상호작용 뒤에만 필요하면 나중에 가져옵니다.
  • 독립 위젯의 부가 정보라면 굳이 초기 hydration에 태우지 않습니다.

hydration은 많이 쓸수록 좋은 것이 아닙니다

hydration도 비슷합니다. 서버가 준비한 상태를 클라이언트가 이어받는다는 점만 보면 많을수록 좋아 보일 수 있습니다.

하지만 hydration은 공짜가 아닙니다.

  • 직렬화할 상태가 많아집니다.
  • 클라이언트가 복원해야 할 cache도 커집니다.
  • boundary가 많아질수록 화면 구조가 복잡해집니다.
  • 결국 어떤 상태가 어디서 준비됐는지 추적하기 어려워집니다.

즉 hydration은 "있는 것이 무조건 좋은 기능"이 아니라, 서버 준비 상태를 클라이언트에 넘길 가치가 있는가를 따져야 하는 전략입니다.

그래서 prefetch와 hydration은 항상 같이 평가해야 합니다. 미리 가져오는 것만 보고 결정할 수 없고, 그 상태를 얼마만큼 넘기고 복원할지도 함께 봐야 하기 때문입니다.

마치며

prefetch와 hydration은 분명히 강력한 패턴입니다. 요청 시작 시점을 앞당기고, 서버가 준비한 상태를 클라이언트가 이어받게 해 주기 때문에 첫 화면을 훨씬 안정적으로 만들 수 있습니다.

하지만 그 힘이 항상 좋은 구조로 이어지는 것은 아닙니다. 컴포넌트가 많아질수록 문제는 개별 query보다 그 query를 어디에 배치하고 누가 조율하느냐에서 생깁니다.

그래서 prefetch와 hydration은 "어디서나 쓸 수 있는 편리한 패턴"이 아니라, 화면 상위에서 의도적으로 조율해야 하는 전략으로 보는 편이 더 정확합니다.

다음 글에서는 여기서 마지막으로 Next.js App Router 쪽으로 넘어가 보겠습니다. 지금까지 React에서 서버 상태 문제와 TanStack Query 모델, prefetch와 hydration의 trade-off를 정리했다면, 이제는 App Router에서 DAL, RSC, 최상단 orchestration을 어떻게 함께 설계할지 살펴볼 차례입니다.