React Query 환경에서 Next.js와 streaming 이용하기

스탁벅스·2024년 4월 14일
28
post-thumbnail

Next.js가 리액트의 메타 프레임워크로 단연 인기가 가장 많습니다. 리액트 18에서 새로운 주요 기능들이 추가되면서 이를 활용하기 위한 가장 좋은 템플릿을 제공하기 때문이지 않나 싶은데요. 리액트 18의 주요 기능인 '서버컴포넌트'를 정말 효과적으로 제공하면서 CSR, SSG 등 다양한 형태의 렌더링 방식을 엔지니어가 매우 쉽게 선택할 수 있게 하는 방식이 정말 좋은 것 같습니다.

이 중에, 최근에 넥스터즈에서 Next.js의 App router를 활용한 프로젝트를 진행하면서, 가장 만족도가 높았던 기능은 streaming 기능인데요, 특히 React Query를 사용하는 환경에서 서버컴포넌트와 streaming 기능을 어떻게 활용하고 코드를 작성해야하는지 많은 고민을 했습니다. 레퍼런스가 많지 않아 공식문서와 공식 깃허브 discussion에서 질문을 하며 해결했던 고민들을 공유해보겠습니다.

1. Streaming Server Rendering With Suspense

Streaming이 무엇을 해결하기 위한 것인지 부터 알아보겠습니다. 가령 다음과 같은 상황을 봅시다.

async function Page() {
  
  const data1 = await _3초_걸리는_함수();
  const data2 = await _1초_걸리는_함수();

  return (
    <div>
      <span>{data1}</span>
      <span>{data2}</span>
    </div>
  );
}

위 코드는 서버에서 렌더링 되는 코드이며, 1초 뒤, 3초 뒤에 받아와지는 데이터에 의존하고 있습니다. 1초만에 data2가 먼저 준비되지만, data1이 아직 준비가 되지 않아 최소 3초 동안 사용자는 빈 화면을 보게 됩니다.

이를 해결하기 위해 streaming이라는 개념이 리액트18에 도입되었습니다. 간단하게 설명하자면, 서버에서는 준비된 HTML을 먼저 보여주고, HTTP Stream과 renderToPipeableStream이라는 API를 활용하여 비동기 작업이 완료되는대로 chunk 단위로 HTML을 보내주는 기능입니다.
자세한 동작 원리는 이번글의 핵심은 아니니 다음 글에서 어떤 원리로 이루어지는지 분석해보도록 하겠습니다.

잘 이해가 가지 않는 다면 다음 설명을 통해 더 자세히 살펴봅시다.

1) Next.js에서의 streaming

Next.js App router에서는 디폴트로 페이지 단위의 streaming이 이루어집니다. 가령 아래와 같은 코드가 있다고 합시다.

// page.tsx
export default async function Page() {
  
  const data1 = await _3초_걸리는_함수();
  const data2 = await _1초_걸리는_함수();

  return (
    <div>
      <span>{data1}</span>
      <span>{data2}</span>
    </div>
  );
}

// layout.tsx
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html>
      <body>
        <nav>데이터 목록</nav>
        {children}
      </body>
    </html>
  );
}

// loading.tsx
export default function Loading() {
  return <div>loading...</div>;
}

App router가 제공하는 프로젝트 구조대로 page.tsx, layout.tsx, loading.tsx를 지정하면 하나의 페이지 단위로 streaming이 이루어집니다. 무슨 말이냐면 page.tsx의 결과물이 렌더링이 완료되지 않았더라도, layout.tsx 내에서 렌더링이 완료된 부분은 화면에 미리 나타나게 됩니다. 그리고, page.tsx가 준비되는 동안 loading.tsx의 렌더링 결과물이 fallback으로 대신 나타나게 됩니다.

유용하지만, 우리의 예시의 경우는 더 최적화 할 수 있을 것 같습니다. 여전히 1초만에 준비된 데이터가 3초 뒤에 준비된 데이터 때문에 뒤늦게 화면에 나타나는 것이 마음에 들지 않습니다. 즉, 페이지 단위가 아닌, 컴포넌트 단위로 더 쪼개서 렌더링하고 싶습니다.

아래와 같이 Suspense를 이용해 이를 달성할 수 있습니다.

// page.tsx
export default async function Page() {
  
  return (
    <div>
      <Suspense fallback={<div>loading data1...</div>} >
        <Data1 />
      </Suspense>
     <Suspense fallback={<div>loading data2...</div>} >
        <Data2 />
      </Suspense>
    </div>
  );
}

async function Data1(){
  const data1 = await _3초_걸리는_함수();

  return (
      <span>{data1}</span>
  );
}

async function Data2(){
  const data2 = await _1초_걸리는_함수();

  return (
      <span>{data2}</span>
  );
}

또 다른 리액트의 기능인 Suspense는, Suspense 바운더리가 감싸진 컴포넌트 내부가 특정 조건이 완료(e.g. 데이터 패칭 완료)가 될 때깨지 등장을 연기하게 해줍니다. 결과적으로 Suspense가 감싸진 단위로 chunk를 나누어 서버의 렌더링 결과물이 클라이언트로 전달되게 됩니다. 그리고 위 예시의 경우는 1초만에 준비된 chunk가 먼저 클라이언트로 전달되어 화면에 그려집니다.

위와 같이 3초라는 굉장히 긴 시간의 비동기 작업이 화면 전체의 등장을 블로킹 한다면, 해당 컴포넌트에 streaming을 적용하는 것은 TTFB(Time To First Byte)에 유의미한 개선을 가져다 줄 수 있으며, 먼저 렌더링된 부분부터 hydration이 이루어지기 때문에 TTI(Time To Interact) 지표 역시 개선이 됩니다.

2. React-Query와 서버 컴포넌트에서의 데이터 패칭

React Query를 활용하는 프로젝트에서 streaming을 어떻게 사용하는지 보기에 앞서 빠르게 서버컴포넌트에서 React Query를 사용하는 패턴에 대해 알아봅시다.

React Query는 훅(hook) 기반의 API를 제공하기 때문에 정석적인 useQuery를 사용하는 방식으로 서버컴포넌트에서 데이터 패칭을 하기 어렵습니다. react-query 공식문서는 이를 해결하기 위해 서버컴포넌트에서 데이터를 prefetch하는 패턴을 권장합니다.

공식문서에서 제공하는 예제 코드를 살펴봅시다. 우선 서버컴포넌트에서 이 데이터를 queryClient.prefetchQuery 메소드를 이용해 prefetch합니다.

// app/posts/page.jsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'

export default async function PostsPage() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}

서버컴포넌트에서 임의로 QueryClient를 생성하여 prefetch를 진행해줍니다. 그리고 이 결과가 담긴, dehydrated 상태의 QueryClient를 하위로 전달합니다.

// app/posts/posts.jsx
'use client'

export default function Posts() {
  const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })

  const { data: commentsData } = useQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })
	...
}

서버에서는 prefetch된 데이터를 활용해 클라이언트컴포넌트까지 pre-render을 진행하게 됩니다. prefetch를 하지 않으면, 서버에서는 useQuery 훅을 사용할 수 없어 쿼리 데이터가 항상 undefined이지만, 서버컴포넌트에서 prefetch를 하였으므로 올바른 데이터가 들어간채로 서버에서 완성된 페이지가 넘어오게 됩니다.

위 설명은 매우 간략화된 설명이므로, React Query를 Next.js App router에서 잘 활용하기 위한 방법과 주의사항은 공식문서를 필히 참고해주세요.

3. React-Query에서 streaming 하기

드디어 위 패턴을 기반으로 컴포넌트 단위의 streaming 기능을 이용해봅시다.
최근에 streaming 기능을 이용하고 싶었던 화면은 아래와 같습니다.

위 화면에서 상단 헤더와 검색 바, 하단 네비게이션 바를 빠르게 보여주고, 기록 리스트를 불러오는 동안 기록 리스트를 보여주는 영역을 스켈레톤 처리하고 싶습니다.
우선 아래와 같이 코드를 작성해 보았습니다.

// page.tsx (server component)
export default async function MemosPage() {

    const queryClient = new QueryClient();
  
    await queryClient.prefetchQuery({
        queryKey: ['allMemos'],
        queryFn: getAllMemos(0, 'DESC', 'createdAt');
        },
    });

    return (
        <PageContainerV2 hasNavigator>
            <Header text="기록" />
            <SearchBox />
            <HydrationBoundary state={dehydrate(queryClient)}>
                <MemosContainer />
            </HydrationBoundary>
        </PageContainerV2>
    );
}


// memos-container.tsx (client component)
'use client'

export function MemosContainer(){
      const {data} = useSuspenseQuery({
    	queryKey: ['allMemos'],
        queryFn: getAllMemos(0, 'DESC', 'createdAt');
        staleTime: STALE_TIME,
    });
  
  return (<>
          ... 
         </>)
}

streaming 기능을 붙이기에 앞서 위 코드를 개선하고 싶은데요, 우선 queryKey, queryFn이 벌써 두 군데에 작성되어 있으므로 이를 모듈화 하고 싶은 욕구가 생깁니다. React Query v5 부터 useQuery, useSuspenseQuery 등의 훅의 인자가 단일 객체로 넘어가기 때문에 이 값들을 하나로 묶어서 관리할 수 있습니다.

// store/queries/useAllMemosQuery.ts
export const allMemosQueryOptions = (
    sortOrder?: SortOrders,
    sortType?: SortTypes,
): BasicSuspenseQueryOptions<AllMemos> => ({
    	queryKey: ['allMemos'],
        queryFn: getAllMemos(0, sortOrder ?? 'DESC', sortType ?? 'createdAt');
    },
});

export function useAllMemosQuery(sortOrder?: SortOrders, sortType?: SortTypes) {
    return useSuspenseQuery({
        ...allMemosQueryOptions(sortOrder, sortType),
        staleTime: STALE_TIME,
    });
}

queryKeyqueryFn을 담은 객체를 리턴하는 allMemosQueryOptions 라는 함수를 만들어주었고, useSuspenseQuery를 컴포넌트에서 직접호출하는 것이 아니라 useAllMemosQuery라는 훅을 만들어 한 레이어 더 감싸서 호출하였습니다.

이를 활용한 코드는 아래와 같습니다.

// page.tsx (server component)
export default async function MemosPage() {

    const queryClient = new QueryClient();

    await queryClient.prefetchQuery(allMemosQueryOptions());

    return (
        <PageContainerV2 hasNavigator>
            <Header text="기록" />
            <SearchBox />
            <HydrationBoundary state={dehydrate(queryClient)}>
                <MemosContainer />
            </HydrationBoundary>
        </PageContainerV2>
    );
}

// memos-container.tsx (client component)
'use client'

export function MemosContainer(){
  
  const { data } = useAllMemosQuery();
  
  return (<>
          ... 
         </>)
}

이제 정말로 streaming 기능을 붙여봅시다. 사실 별 거 없습니다. 서버에서 데이터 패칭하는 부분, 즉, prefetchQuery를 호출하는 부분을 Suspense 바운더리로 감싸주면 되는데요, 여기서 살짝 불편한 점이 발생합니다.

이전 코드에서는 prefetchQuerypage.tsx라는 페이지의 최상위 컴포넌트에서 호출하고 있었습니다. 하지만 문제는 이 부분이 Suspense 바운더리 내부에 있어야 하므로 별도의 컴포넌트를 하나 더 생성하고 그 내부에서 prefetchQuery를 호출해주어야 합니다.

이를 위해 코드가 아래 처럼 바뀝니다.

// page.tsx (server component)
export default async function MemosPage() {
    return (
        <PageContainerV2 hasNavigator>
            <Header text="기록" />
            <SearchBox />
            <Suspense fallback={<MemosSkeleton />} >
                <PrefetchMemosContainer />
            </Suspense>
        </PageContainerV2>
    );
}

// prefetch-memos-container.tsx (server component)
export async function PrefetchMemosContainer() {

    const queryClient = new QueryClient();

    await queryClient.prefetchQuery(allMemosQueryOptions());

    return (
            <HydrationBoundary state={dehydrate(queryClient)}>
                <MemosContainer />
            </HydrationBoundary>
    );
}

단순히 prefetchQuery를 호출하기 위한 컴포넌트인 PrefetchMemosContainer가 생성되었는데, 이로 인해 MemosContainer 컴포넌트가 한 레이어 더 감싸지면서 page.tsx내의 컴포넌트의 추상화 레벨이 일관되지 않다는 문제점이 발생했습니다. 또한, 서비스 내에서 이러한 패턴으로 렌더링 되는 페이지가 여러개 있는데, 그럴 때마다 이를 위해 prefetchQuery를 호출하는 별도의 컴포넌트를 매번 하나씩 만드는 것이 굉장히 뼈아프게 느껴집니다. 이를 해결하기 위해 prefetchQuery를 호출해주는 추상화된 컴포넌트를 하나 만들어봅시다.

// prefetch-boundary.tsx (server component)
type Props = {
    prefetchOptions: PrefetchOptions[] | PrefetchOptions;
    children: React.ReactNode;
};

export async function PrefetchBoundary({ prefetchOptions, children }: Props) {
    const queryClient = new QueryClient();

    Array.isArray(prefetchOptions)
        ? await Promise.all(prefetchOptions.map((prefetchOption) => queryClient.prefetchQuery(prefetchOption)))
        : await queryClient.prefetchQuery(prefetchOptions);

    return <HydrationBoundary state={dehydrate(queryClient)}>{children}</HydrationBoundary>;
}

QueryClient를 생성해주고, prefetchQuery를 호출해주며, HydrationBoundary를 감싸주는 추상체인 PrefetchBoundary를 생성했습니다. prop으로 prefetch를 위한 쿼리 옵션인 prefetchOptions를 받으며, 여러 개의 쿼리를 prefetch 해야하는 경우에는 배열로 받고 Promise.all() 메소드를 활용하여 병렬적으로 prefetch를 진행하도록 합니다.

이를 활용하여 개선한 찐 최종 코드는 아래와 같습니다.

// page.tsx (server component)
export default async function MemosPage() {

    return (
        <PageContainerV2 hasNavigator>
            <Header text="기록" />
            <SearchBox />
            <Suspense fallback={<MemosSkeleton />}>
                <PrefetchBoundary prefetchOptions={allMemosQueryOptions()}>
                    <MemosContainer />
                </PrefetchBoundary>
            </Suspense>
        </PageContainerV2>
    );
}

기존에 만든 allMemosQueryOptions를 이용해 prefetchQuery를 위한 옵션을 받아오고, 이를 PrefetchBoundary 컴포넌트의 prefetchOptions prop으로 넘겨줍니다.
그리고 children으로 MemosContainer를 넣어줍니다.

결과적으로 추상화된 PrefetchBoundary 컴포넌트를 활용해
1. 매우 선언적인 코드로 prefetch 과정을 진행하였고, page.tsx 파일에서는 무엇이 어떻게 렌더링 되는지 한눈에 쉽게 파악할 수 있게 되었습니다.
2. prefetchQuery를 호출하는 컴포넌트를 Suspense 바운더리로 감싸기 위한 보일러플레이트 코드들을 줄일 수 있었습니다.

의도한 대로 기록 리스트를 보여주는 부분만 뒤늦게 화면에 등장하고, 나머지 부분은 미리 렌더링이 되는 것을 확인할 수 있습니다.

4. 마무리

App router에서 React Query를 사용할 필요가 있는지에 대한 의견이 많이 갈립니다. Next.js에서 제공하는 캐싱 기능과 Server Action 등을 활용하여도 충분하다는 사람이 있는 반면, 이를 활용할 수 있는 프로젝트들은 매우 제한적이며, 특히 데이터 mutation이 여러 유저들에 의해 자주 일어나는 서비스에서는 항상 최신의 데이터를 보여주기 힘들다는 의견이 있습니다.

이런 의견들과 무관하게, React Query 내부에서 이러한 빠른 변화에 발맞춰 준비하고 있는 것이 보입니다. 아직 정식 출시된 기능은 아니어서 이번 글에서 소개하지 않았지만, prefetch 과정 없이, streaming을 가능하게 해주는 ReactQueryStreamedHydration라는 experimental 기능이 있습니다. 이는 next/navigation에서 제공하는 useServerInsertedHTML이라는 API를 활용하는 방식인데, 관심있으신 분들은 한 번 코드 살펴보는 것도 좋을 것 같습니다 :)

profile
환영합니다. 스탁벅스입니다.

0개의 댓글