2025년 03월 13일

10

[React Query] 서버에서 Prefetch 한 데이터 사용하기

Frontend

HS
Hyungseok Kwon
@hskwon5170
설정 이미지

1️⃣ 기존 방식: 서버에서 fetch한 데이터를 initialData에 바인딩

  • React Query의 useQuery에 서버 컴포넌트에서 fetch한 데이터를 initialData로 전달하는 방법으로, 기존에 제가 사용하던 패턴이였습니다. 이러한 패턴을 사용하면서 어떤 문제점을 겪었고 무엇때문에 React Query에 prefetch, dehydrate, hydrate API가 생겨나게 되었는지 살펴보겠습니다.

💡 Parent Server Component (As-Is)

1import { Detail } from "@/(pages)/ui/detail" 2import { PageProps } from "@/(shared)/types/types" 3import { apiGetBlog } from "@/(shared)/api/blog.get" 4 5const Page = async ({ params }: PageProps<"id">) => { 6 const blogData = await apiGetBlog(Number(params.id)) 7 return <Detail blogId={Number(params.id)} blogDetailData={blogData} /> 8}

💡 Child Client Component (As-Is)

1"use client" 2 3export const Detail = ({ 4 blogId, 5 blogDetailData, 6}: DetailProps) => { 7 8 ... 9 10 // 서버에서 전달받은 데이터를 props로 전달받아 initialData로 사용 11 const { data: blogDetail } = useQuery({ 12 queryKey: ["blog", blogId], 13 queryFn: () => apiGetBlog(blogId), 14 initialData: blogDetailData 15 }) 16 17 ... 18 (생략) 19 ... 20)}

2️⃣ 이러한 패턴의 단점

  1. 부모 컴포넌트에서 fetch한 데이터를 자식에게 props로 전달해주는 패턴의 경우, 컴포넌트 트리가 깊어질수록 관리가 어려워지고, 불필요한 (혹은 복잡한) props drilling이 발생할 수 있습니다.

  2. initialData를 전달받는 클라이언트 컴포넌트가 이곳저곳에서 재사용되는 경우, 매번 부모 컴포넌트에서 데이터를 fetch하여 props로 전달해줘야합니다. 이로 인해 서버컴포넌트는 불필요하게 데이터를 중복 fetch하게 될 수 있습니다.

    • 여러 위치에서 동일한 쿼리로 useQuery를 호출하는 경우, initialData를 한곳에만 전달하는 것은 앱이 변경될 때 문제가 발생 할 수 있습니다.
  3. 그로 인해 부모 컴포넌트는 반드시 서버 컴포넌트여야합니다. 컴포넌트 계층의 하부 Node에 클라이언트 컴포넌트가 존재하는게 올바른 방식이지만 이러한 제약이 생기는건 분명 단점이라고 생각합니다.

  4. 서버에서 fetch한 데이터를 props로 넘겨 initialData에 바인딩해주는 경우, useQuery에 지정해준 query key에 initialData가 캐싱됩니다. 하지만 이미 캐시에 데이터가 존재하는 경우 초기값을 덮어쓰지 않습니다. 즉 useQuery가 처음 실행될 때 캐시가 비어있다면 컴포넌트가 마운트될때 비로소 initialData로 캐시를 구성하지만, 이미 캐시가 있다면 그 값을 그대로 사용하게 됩니다.

    더 중요한 점은, 서버 컴포넌트에서 새로운 데이터를 fetch하여 props로 전달하더라도, 이미 React Query 캐시가 초기화된 이후에는 이 새로운 initialData 값이 기존 캐시를 갱신하지 않는다는 것입니다. 데이터는 오직 클라이언트 측 refetch나 invalidateQueries를 통해서만 갱신되며, 이는 staleTime 설정에 따라 자동으로 또는 수동으로 트리거될 수 있습니다. (이러한 점이 특히 치명적이라고 생각합니다.)

3️⃣ 개선된 방식: React Query의 Hydration API사용하기

  • React Query에서는 Dehydratehydrate 함수를 제공합니다.
  • 서버에서 미리 fetch한 데이터를 QueryClient의 캐시에 저장하고, 클라이언트에서 해당 캐시를 hydrate하여 추가 네트워크 요청없이 사용할 수 있습니다.

Hydrate와 Dehydrate의 역할

  • Hydrate는 클라이언트측에서 직렬화된 상태를 받아 React Query의 상태로 변환합니다. 서버에서 미리 가져온(prefetch한) 데이터가 이미 query key에 캐싱되어있기 때문에 클라이언트에서 별도 네트워크 요청 없이 데이터를 사용할 수 있습니다.

  • Dehydrate는 서버에서 React Query의 상태를 클라이언트로 전송할 수 있는 형태로 만들기 위해 사용됩니다. 서버에서 데이터를 가져온 뒤, 이 데이터를 직렬화(serialization)하여 클라이언트로 전송합니다. 이를 위해 queryClient 인스턴스를 선언한 후 prefetchQuery를 이용하여 query key를 선언하고, queryFn에 fetch를 위한 함수를 바인딩합니다. 직렬화된 데이터는 DehydratedState형태로 표현되고, 클라이언트 측에서 hydrate 함수를 통해 데이터가 수화(水化)되어 React Query 상태로 변환됩니다.

💡 Parent Server Component (To-Be)

1import type { PageProps } from "@/(shared)/types/types" 2 3import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query" 4 5import { Detail } from "@/(pages)/ui/detail" 6import { apiGetBlog } from "@/(shared)/api/blog.get" 7 8const Page = async ({ 9 params 10}: PageProps<"id">) => { 11 const queryClient = new QueryClient() 12 13 const blogId = Number(params.id) 14 15 await queryClient.prefetchQuery({ 16 queryKey: ["blog", blogId], 17 queryFn: () => apiGetBlog(blogId), 18 }) 19 20 const dehydratedState = dehydrate(queryClient) 21 22 return ( 23 // HydrationBoundary로 자식 컴포넌트를 감싸고, state에 dehydratedState를 props로 전달합니다. 24 <HydrationBoundary state={dehydratedState}> 25 <Detail blogId={Number(params.id)} /> 26 </HydrationBoundary> 27 ) 28} 29export default Page

💡 Child Client Component (To-Be)

1"use client" 2 3export const Detail = ({ 4 blogId, 5}: DetailProps) => { 6 ... 7 8 const { data: blogDetail = [] } = useQuery({ 9 queryKey: ["blog", blogId], 10 queryFn: () => apiGetBlog(blogId), 11 12 }) 13 ... 14 (생략) 15 ... 16)}

4️⃣ 결론

기존에 initialData를 이용해 서버에서 패칭한 데이터를 props로 전달하는 방식은 단순해 보이지만, 앱이 복잡해질수록 관리와 유지보수에 한계가 생길 수 밖에 없다고 생각합니다. React Query의 Dehydrate/hydrate API를 활용하면 서버에서 미리 패칭한 데이터를 캐싱하고, 클라이언트에서 이를 손쉽게 복원할 수 있으므로 다음과 같은 장점이 존재합니다.

  • 불필요한 props drilling 제거
  • 일관된 캐시 관리와 자동 업데이트
  • 코드 유지보수성 향상

5️⃣ Mutation함수에서 Invalidate했는데 왜 적용이 안되어 보일까....? Next.js의 HTML 페이지 캐싱 문제😥

최근 서버 컴포넌트에서 dehydrate로 직렬화된 데이터를 prefetch하여 특정 query key에 캐싱하고, 클라이언트 컴포넌트에서는 그 query key를 사용해 useQuery로 데이터를 불러오는 패턴을 적용했습니다.
하지만, 글 수정 후 query key를 invalidate하고 브라우저에서 새로고침했을 때, 수정 이전의 글이 계속 나타나는 현상을 발견했습니다. Supabase 데이터베이스에는 최신 데이터가 저장되어있었습니다.

⏺️ 문제 분석

  1. 클라이언트 컴포넌트 내 invalidate 정상 작동 확인

    • 처음 의심했던 queryClient 인스턴스 불일치는 문제의 원인이 아님을 확인 후 배제할 수 있었습니다.
    • dev tools에서 확인 결과 query key 무효화는 정상적으로 동작했습니다.
  2. 서버와 클라이언트의 데이터 불일치 문제

    • Next.js는 서버에서 모든 페이지를 pre-render하여 정적 HTML을 생성한 후, 이를 캐싱해 재사용합니다.
    • 반면, 클라이언트 컴포넌트는 hydration을 통해 실시간으로 갱신된 데이터를 반영합니다.
    • 이로 인해, 클라이언트에서 최신 데이터가 반영되어 있어도, 서버에서 생성된 정적 HTML에는 이전 데이터가 그대로 남아 있는 상황이 발생합니다.
  3. 해결 시도: revalidate = 0 적용

    • 서버 컴포넌트 최상단에 export const revalidate = 0 을 추가하여 매 요청마다 서버가 HTML을 재생성하도록 함으로써 문제를 해결할 수 있지만, 이는 근본적인 해결책이 아니라고 생각합니다.
    • (트래픽이 많을 때 성능 저하, Next.js가 수행하는 캐싱 이점을 포기)

⏺️ 좀 더 생각해볼 점

  • 정적 캐싱의 한계
    Next.js의 서버는 정적 HTML을 캐싱하여 빠른 응답을 제공하는 장점이 있지만, 실시간 데이터 업데이트가 중요한 경우 최신 데이터를 반영하지 못하는 단점이 있습니다.

  • 실시간 데이터 업데이트와 정적 HTML의 조화
    만약 수정된 내용을 즉시 반영해야 한다면, 서버 측에서 HTML을 매번 재생성해야할 수 있습니다. revalidate = 0은 개발 환경이나 데이터 변경 빈도가 매우 높은 경우에 유용할 수 있지만, 서비스 규모나 트래픽에 따라서는 그다지 효과적인 방법이 아닐 수 있을 것 같습니다.

⏺️ 문제 해결

서버 컴포넌트 파일 최상단에 revalidate = 0을 설정하는 대신, React Query의 useMutation과 서버 액션을 조합하는 방법으로 해결할 수 있었습니다.

구체적인 구현은 다음과 같습니다.

먼저, 특정 경로를 재검증하는 서버 액션 함수를 생성합니다. (revalidate할 pathname을 프롭스로 전달받는데 stringstring[] 타입을 전달받을 수 있도록 했습니다.)

1"use server" 2 3import { revalidatePath } from "next/cache" 4 5export const revalidateBlog = async (paths: string | string[]): Promise<void> => { 6 const pathsToRevalidate = Array.isArray(paths) ? paths : [paths]; 7 8 for (const path of pathsToRevalidate) { 9 await revalidatePath(path); 10 } 11};

그 다음, 클라이언트 컴포넌트에서 useMutation 훅의 onSuccess 콜백 내에서 해당 서버 액션을 호출합니다.

1const updateMutation = useMutation({ 2 mutationFn: apiPutBlog, 3 onSuccess: () => { 4 revalidateBlog(["/blog", `/blog/${blogId}`]); 5 6 // 추가적인 처리 로직... 7 } 8});

revalidate = 0처럼 모든 요청마다 서버 컴포넌트를 재렌더링하는 대신, 데이터가 실제로 변경되었을 때만 관련 경로의 캐시를 무효화하는 방식인 것입니다.

⏺️ 결론

이번 업데이트를 통해 React QueryHydration 전략을 적용하여 클라이언트 컴포넌트에서 실시간 데이터를 반영하는 데는 성공했습니다. 그러나 이 과정에서 서버 컴포넌트의 캐싱 동작으로 인해 새로운 문제가 드러났습니다. 클라이언트에서 데이터를 업데이트한 후 페이지를 새로고침하면, 서버에서 정적 HTML로 렌더링되는 부분이 최신 데이터를 반영하지 않는 불일치가 발생한 것입니다.

이 문제의 핵심은 React Query의 클라이언트 캐시Next.js의 서버 캐시가 별도로 작동한다는 점에 있습니다. 클라이언트에서 invalidateQueries를 호출해도 서버 캐시는 영향을 받지 않기 때문에, 페이지를 새로고침해도 서버는 여전히 이전 데이터를 사용하게 됩니다.

이를 해결하기 위한 기초적인인 방법은 서버 컴포넌트 파일 최상단export const revalidate = 0을 설정하는 것입니다. 이 설정은 매 요청마다 서버 컴포넌트를 새로 렌더링하도록 하여 항상 최신 데이터를 가져오게 합니다. 즉각적인 문제 해결에 효과적이지만, 모든 요청마다 서버에서 데이터를 새로 가져오므로 캐싱의 성능상 이점을 포기하게 됩니다.

따라서 근본적인 해결책으로는 서버 액션과 revalidatePath 함수를 조합하여 useMutation 훅의 onSuccess 콜백에서 서버 액션 함수를 호출하는 것 입니다. 이 방법을 적용하면 클라이언트 캐시와 서버 캐시 모두 적절하게 무효화되어 데이터 일관성을 유지할 수 있습니다.