initialData
에 바인딩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)}
부모 컴포넌트에서 fetch한 데이터를 자식에게 props로 전달해주는 패턴의 경우, 컴포넌트 트리가 깊어질수록 관리가 어려워지고, 불필요한 (혹은 복잡한) props drilling
이 발생할 수 있습니다.
initialData
를 전달받는 클라이언트 컴포넌트가 이곳저곳에서 재사용
되는 경우, 매번 부모 컴포넌트에서 데이터를 fetch하여 props로 전달해줘야합니다. 이로 인해 서버컴포넌트는 불필요하게 데이터를 중복 fetch하게 될 수 있습니다.
그로 인해 부모 컴포넌트는 반드시 서버 컴포넌트
여야합니다. 컴포넌트 계층의 하부 Node에 클라이언트 컴포넌트가 존재하는게 올바른 방식이지만 이러한 제약이 생기는건 분명 단점이라고 생각합니다.
서버에서 fetch한 데이터를 props로 넘겨 initialData에 바인딩해주는 경우, useQuery에 지정해준 query key
에 initialData가 캐싱됩니다. 하지만 이미 캐시에 데이터가 존재하는 경우 초기값을 덮어쓰지 않습니다. 즉 useQuery가 처음 실행될 때 캐시가 비어있다면 컴포넌트가 마운트될때 비로소 initialData로 캐시를 구성하지만, 이미 캐시가 있다면 그 값을 그대로 사용하게 됩니다.
더 중요한 점은, 서버 컴포넌트에서 새로운 데이터를 fetch하여 props로 전달하더라도, 이미 React Query 캐시가 초기화된 이후에는 이 새로운 initialData 값이 기존 캐시를 갱신하지 않는다는 것입니다. 데이터는 오직 클라이언트 측 refetch나 invalidateQueries를 통해서만 갱신되며, 이는 staleTime 설정에 따라 자동으로 또는 수동으로 트리거될 수 있습니다. (이러한 점이 특히 치명적이라고 생각합니다.)
Dehydrate
와 hydrate
함수를 제공합니다.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)}
기존에 initialData를 이용해 서버에서 패칭한 데이터를 props로 전달하는 방식은 단순해 보이지만, 앱이 복잡해질수록 관리와 유지보수에 한계가 생길 수 밖에 없다고 생각합니다. React Query의 Dehydrate/hydrate API를 활용하면 서버에서 미리 패칭한 데이터를 캐싱하고, 클라이언트에서 이를 손쉽게 복원할 수 있으므로 다음과 같은 장점이 존재합니다.
최근 서버 컴포넌트에서 dehydrate로 직렬화된 데이터를 prefetch하여 특정 query key에 캐싱하고, 클라이언트 컴포넌트에서는 그 query key를 사용해 useQuery로 데이터를 불러오는 패턴을 적용했습니다.
하지만, 글 수정 후 query key를 invalidate하고 브라우저에서 새로고침했을 때, 수정 이전의 글이 계속 나타나는 현상을 발견했습니다. Supabase 데이터베이스에는 최신 데이터가 저장되어있었습니다.
클라이언트 컴포넌트 내 invalidate 정상 작동 확인
서버와 클라이언트의 데이터 불일치 문제
해결 시도: revalidate = 0 적용
export const revalidate = 0
을 추가하여 매 요청마다 서버가 HTML을 재생성하도록 함으로써 문제를 해결할 수 있지만, 이는 근본적인 해결책이 아니라고 생각합니다.정적 캐싱의 한계
Next.js의 서버는 정적 HTML을 캐싱하여 빠른 응답을 제공하는 장점이 있지만, 실시간 데이터 업데이트가 중요한 경우 최신 데이터를 반영하지 못하는 단점이 있습니다.
실시간 데이터 업데이트와 정적 HTML의 조화
만약 수정된 내용을 즉시 반영해야 한다면, 서버 측에서 HTML을 매번 재생성해야할 수 있습니다.
revalidate = 0
은 개발 환경이나 데이터 변경 빈도가 매우 높은 경우에 유용할 수 있지만, 서비스 규모나 트래픽에 따라서는 그다지 효과적인 방법이 아닐 수 있을 것 같습니다.
서버 컴포넌트 파일 최상단에
revalidate = 0
을 설정하는 대신, React Query의useMutation
과 서버 액션을 조합하는 방법으로 해결할 수 있었습니다.
구체적인 구현은 다음과 같습니다.
먼저, 특정 경로를 재검증하는 서버 액션 함수를 생성합니다. (revalidate할 pathname을 프롭스로 전달받는데 string
과 string[]
타입을 전달받을 수 있도록 했습니다.)
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 Query
의 Hydration 전략을 적용하여 클라이언트 컴포넌트에서 실시간 데이터를 반영하는 데는 성공했습니다. 그러나 이 과정에서 서버 컴포넌트의 캐싱 동작으로 인해 새로운 문제가 드러났습니다. 클라이언트에서 데이터를 업데이트한 후 페이지를 새로고침하면, 서버에서 정적 HTML로 렌더링되는 부분이 최신 데이터를 반영하지 않는 불일치가 발생한 것입니다.
이 문제의 핵심은 React Query의 클라이언트 캐시
와 Next.js의 서버 캐시
가 별도로 작동한다는 점에 있습니다. 클라이언트에서 invalidateQueries
를 호출해도 서버 캐시는 영향을 받지 않기 때문에, 페이지를 새로고침
해도 서버는 여전히 이전 데이터를 사용하게 됩니다.
이를 해결하기 위한 기초적인인 방법은 서버 컴포넌트 파일 최상단
에 export const revalidate = 0을 설정하는 것입니다. 이 설정은 매 요청마다 서버 컴포넌트를 새로 렌더링하도록 하여 항상 최신 데이터를 가져오게 합니다. 즉각적인 문제 해결에 효과적이지만, 모든 요청마다 서버에서 데이터를 새로 가져오므로 캐싱의 성능상 이점을 포기
하게 됩니다.
따라서 근본적인 해결책으로는 서버 액션과 revalidatePath 함수를 조합하여 useMutation 훅의 onSuccess 콜백에서 서버 액션 함수를 호출하는 것 입니다. 이 방법을 적용하면 클라이언트 캐시와 서버 캐시 모두 적절하게 무효화되어 데이터 일관성을 유지할 수 있습니다.