Next.js 13에서 React Query SSR 적용하는 방법

기운찬곰·2023년 6월 22일
35

Next.js 이모저모

목록 보기
3/8
post-thumbnail

Overview

이번 시간에는 Next.js 13에서 React Query SSR 을 적용하는 방법에 대해 알아보도록 하겠습니다. 아. 이왕 하는김에 React Query가 무엇이고 왜 사용하는지 간단하게 알아보고 넘어가도록 하겠습니다.


React Query 란

React Query는 정말 유명한 라이브러리 입니다. 여러 테크 블로그에서 Redux같은 전역 상태 도구를 버리고 React Query로 전환했다는 글이 있을 정도니까요. 뭐... 그만큼 React Query는 이미 많은 곳에서 사용 중이라는 것입니다.

공식문서 첫 소개

“Powerful asynchronous state management for TS/JS, React, Solid, Vue and Svelte” - 참고

TS/JS, React, Solid, Vue, Svelte를 위한 강력한 비동기 상태 관리 도구 라고 합니다.

“Toss out that granular state management, manual refetching and endless bowls of async-spaghetti code. TanStack Query gives you declarative, always-up-to-date auto-managed queries and mutations that directly improve both your developer and user experiences.”

해석) 세분화된 상태관리, 수동 refetching, 끝없는 비동기 스파게티 코드들을 버려라. React Query는 개발자 및 사용자 경험을 직접 개선하는 선언적(declarative)이고 항상 최신의 자동 관리 queries와 mutations를 제공합니다.

공식문서 개요 - 왜 만들어졌는가

참고 : https://tanstack.com/query/latest/docs/react/overview

“TanStack Query (FKA React Query) is often described as the missing data-fetching library for web applications, but in more technical terms, it makes fetching, caching, synchronizing and updating server state in your web applications a breeze.”

해석) React Query를 흔히 누락된(?) 데이터 fetching 라이브러리로 설명되지만, 더 전문적인 용어로는 웹 애플리케이션의 서버 상태 가져오기, 캐싱, 동기화 및 업데이트하는 작업을 간편하게 수행할 수 있습니다.

대부분의 핵심 프레임워크는 데이터를 fetching하거나 updating하는 독점적인 방법을 제공하고 있지 않습니다. 이러한 이유로 개발자들은 캡슐화해서 구축하거나 자체적인 데이터 fetching 방법을 개발합니다. 이는 일반적으로 컴포넌트 기반 상태와 사이드 이펙트를 함께 사용하거나, 앱 전체에 비동기 데이터를 저장하고 제공하기 위해 보다 범용적인 상태 관리 라이브러리를 사용하는 것을 의미합니다. (Redux나 Recoil 같은 상태 도구를 말하는건가?)

대부분의 상태 관리 라이브러리는 클라이언트 상태를 사용하는데 유용하지만 비동기 또는 서버 상태를 사용하는데는 그다지 적합하지 않습니다. 서버 상태가 완전히 다르기 때문입니다. (음… 일리 있어… 🧐)


우선 서버 상태는 다음은 특징이 있습니다.

  • 공유 소유권을 의미하며 사용자가 모르는 사이에 다른 사용자가 변경할 수 있습니다.
  • fetching and updating을 위해 비동기 API가 필요합니다.
  • 사용자가 제어하거나 소유하지 않은 위치에서 원격으로 유지됩니다.
  • 주의하지 않으면 애플리케이션이 “out of date” (구식)이 될 수 있습니다. ⇒ 그니까 이미 서버 상태는 바뀌었는데 클라이언트 상태는 안바뀌는 상황을 말하는 거 같습니다.

애플리케이션에서 서버 상태의 특성을 파악하면 다음과 같은 문제가 발생합니다. 도전에 직면하게 됩니다.

  • Caching… (프로그래밍에서 가장 하기 힘든 일임을 기억하세요)
  • 동일한 데이터에 대한 여러 요청을 단일 요청으로 중복 제거
  • “오래된” 데이터를 업데이트
  • 데이터가 “오래된” 경우 파악
  • 데이터 업데이트를 최대한 신속하게 반영
  • 페이징 및 lazy loading 데이터와 같은 성능 최적화
  • 서버 상태의 메모리 및 가비지 수집 관리
  • 구조 공유를 사용하여 쿼리 결과 메모(memoizing)

만약 당신이 이 목록에 압도되지 않았다면, 그것은 당신이 이미 모든 서버 상태 문제를 해결했다는 것을 의미하고 상을 받을 자격이 있다는 것을 의미합니다. 하지만, 만약 여러분이 대다수의 사람들과 같다면, 여러분은 아직 이러한 도전들의 전부 또는 대부분을 해결하지 못했고 우리는 단지 표면을 긁고 있을 뿐입니다!


React Query는 서버 상태를 관리하기 위한 최고의 라이브러리 중 하나입니다. 제로 구성으로 즉시 사용할 수 있으며, 애플리케이션 성장에 따라 원하는 대로 사용자 지정할 수 있습니다. 리액트 쿼리를 사용하면 서버 상태의 까다로운 문제와 장애물을 극복하고 당신이 앱 데이터를 제어하기 전에 제어할 수 있습니다.


좀 더 기술적인 측면에서, React Query는 다음과 같은 기능을 제공합니다:

  • 애플리케이션에서 복잡하고 잘못 이해된 코드의 많은 줄을 제거하고 React Query 로직의 몇 줄로 대체할 수 있습니다.
  • 새로운 서버 상태 데이터 소스를 연결할 필요 없이 애플리케이션의 유지 관리성을 높이고 새로운 기능을 쉽게 구축할 수 있습니다
  • 애플리케이션이 그 어느 때보다 빠르고 응답성이 높아짐으로써 최종 사용자에게 직접적인 영향을 미칩니다.
  • 대역폭을 절약하고 메모리 성능을 향상시킬 수 있습니다.

💡 결국 React Query는 서버 상태 동기화를 위한 라이브러리라고 보면 됩니다. 기존 전역상태 관리 라이브러리는 전부 클라이언트 위주이고, React Query는 전역상태 라이브러리 필요 없이 서버 상태를 동기화해주는 거.

React Query Quick Start

참고 : https://tanstack.com/query/latest/docs/react/quick-start

React Query에서 가장 중요한 3가지 부분에 대해 예시를 들고 있습니다.

  • Queries : 서버에서 데이터를 가져올 때 사용됩니다. 제공한 고유 키(queryKey)는 애플리케이션 전체에서 쿼리를 다시 가져오기, 캐싱 및 공유하는 데 내부적으로 사용됩니다.
  • Mutations : 데이터를 생성/업데이트/삭제하거나 서버 사이드 이펙트를 수행하는 데 사용됩니다. 예를 들어, 제가 블로그 포스팅을 했는데 캐시가 되어있다면 서버에서는 저장이 되었어도 클라이언트에서는 포스팅 목록에 안보이는 상황이 발생할 수 있습니다. 따라서 일반적으로 생성/업데이트/삭제 시에 성공이 되었다면 Query Invalidation을 하거나 수동으로 refetch를 해주는게 좋습니다.
  • Query Invalidation : 오래된 데이터를 무효화하기 위해 사용. 강제로 stale 상태로 바꾸고 자동으로 refetch가 일어납니다.
import {
  useQuery,
  useMutation,
  useQueryClient,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
import { getTodos, postTodo } from '../my-api'

// Create a client
const queryClient = new QueryClient()

function App() {
  return (
    // Provide the client to your App
    <QueryClientProvider client={queryClient}>
      <Todos />
    </QueryClientProvider>
  )
}

function Todos() {
  // Access the client
  const queryClient = useQueryClient()

  // Queries
  const query = useQuery({ queryKey: ['todos'], queryFn: getTodos })

  // Mutations
  const mutation = useMutation({
    mutationFn: postTodo,
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  return (
    <div>
      <ul>
        {query.data?.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>

      <button
        onClick={() => {
          mutation.mutate({
            id: Date.now(),
            title: 'Do Laundry',
          })
        }}
      >
        Add Todo
      </button>
    </div>
  )
}

render(<App />, document.getElementById('root'))

React Query의 라이프 사이클

이미지 출처 : [LIVE] React Query와 상태관리 :: 2월 우아한테크세미나

참고 : https://tanstack.com/query/latest/docs/react/guides/important-defaults

React Query 를 제대로 이해하기 위해서는 staleTime, cacheTime에 대해 알아야 합니다.

  1. 먼저 A 라는 리액트 쿼리가 마운트되어 실행된다고 해봅시다.
  2. 이 때 상태는 fetching 이고, A라는 query key로 캐싱됩니다.
  3. 데이터 호출이 끝나면 fresh 상태가 됩니다. 하지만, staleTime 기본값이 0이기 때문에 곧바로 stale 상태가 됩니다.
  4. 이제 다른 화면으로 나가게 되면 언마운트가 됩니다. 스크린에서 사용되지 않으면 inactive 상태가 됩니다.
  5. inactive 상태에서 cacheTime이 만료되기 전까지는 캐시에 존재합니다. cacheTime은 기본값이 5분입니다. 5분이 지나면 가비지 컬렉터에 의해 수집됩니다.
  6. 만약 5분이 지나기 전에 다시 A라는 리액트 쿼리가 마운트되면 어떤 설정이냐에 따라 다르겠지만 기본적으로는 다시 fetching이 일어날 것입니다.

@tanstack/react-query-devtools 를 설치해보면 아래처럼 상태 변화를 볼 수 있습니다.

그렇다면 staleTime을 5분으로 설정해보면 어떻게 될까요? 오. refetch 되지 않습니다. Last Updated도 그대로 인 것을 알 수 있습니다. 이런식으로 캐시 옵션을 조정할 수 있습니다. 다만 무리하게 캐시를 사용하게 되면 사용자가 최신 데이터를 못 볼 수도 있으니 적절한 조치를 취해야 할 것입니다.

그 외에도 아래 3가지 옵션에 대해 알아놓으면 좋습니다. 3개 옵션 전부 기본값은 true 입니다. stale 되었을 때 해당 이벤트가 발생한다면 refetch 하게 됩니다.

  • refetchMount : 마운트 되었을 때
  • refetchOnWindowFocus : 윈도우가 다시 포커스 되었을 때
  • refetchOnReconnect : 네트워크가 끊겼다가 다시 연결되었을 때

해당 쿼리 옵션은 전역에서 설정할 수 있고, 개별 쿼리에서도 설정이 가능합니다. 저는 보통 전역으로 refetchOnWindowFocus는 false로 해놓고 시작합니다. 저걸 true로 사용하다보면 너무 많이 요청되는거 같아서 그렇습니다.

그 외 retry 및 retryDelay 옵션도 있습니다. 리액트 쿼리가 실패할 때 기본적으로 3번은 다시 요청이 일어나게 되는데 앞선 retry 기본값이 3이기 때문입니다.


Using the app Directory in Next.js 13

참고 : https://tanstack.com/query/v4/docs/react/guides/ssr#using-the-app-directory-in-nextjs-13

React Query 공식 문서를 참고해보면 next.js 13에서 어떻게 사용하라고 나온 가이드가 있습니다. 이대로 한번 진행해보도록 하겠습니다.

두 가지 prefetching 방식

initialData 또는 <Hydrate>를 사용하는 두 가지 prefetching 방식 모두 app Directory 내에서 사용할 수 있습니다.

initialData : 서버 컴포넌트에서 데이터 prefetch 및 클라이언트 컴포넌트로 initialData prop 를 전달하는 방법

  • 간단한 케이스에 대한 신속한 설정이 가능.
  • 다만, 클라이언트 컴포넌트의 여러 계층에 걸친 drilling 이 필요할 수 있습니다.
  • 동일한 쿼리를 사용하여 여러 클라이언트 컴포넌트에 prop drill 이 필요할 수 있습니다.
  • 쿼리 refetching는 데이터가 서버에서 prefetch 된 시간 대신 페이지가 로드되는 시간을 기준으로 합니다.

<Hydrate> : 서버에서 쿼리를 prefetch 하고 캐시를 dehydrate 한 후, <Hydrate> 로 클라이언트에게 rehydrate 해줍니다.

  • initialData 에 비해 더 많은 설정이 요구됩니다.
  • 대신에 prop drill 이 필요없습니다.
  • 쿼리 refetch는 쿼리가 서버에서 prefetch 된 시간을 기준으로 합니다

저는 이 중에서 Hydrate 방식을 사용하도록 하겠습니다. 보통 권장하기로는 <Hydrate> 를 사용하는게 일반적인거 같습니다.

QueryClientProvider 설정

react-query 패키지에서 제공하는 hooks는 context에서 queryClient를 검색해야 합니다. 컴포넌트 트리를 로 QueryClientProvider로 래핑하고 QueryClient 인스턴스를 전달합니다.

React Hooks는 Next.js 클라이언트 컴포넌트에서만 사용 가능하니 "use client;"를 맨 앞에 적어줍니다.

// Provider.tsx

"use client";

import React from "react";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

type Props = {
  children: React.ReactNode;
};

function Providers({ children }: Props) {
  const [client] = React.useState(
    new QueryClient({  
      defaultOptions: {  // react-query 전역 설정
        queries: {
          refetchOnWindowFocus: false,
          retry: false,
        },
      },
    })
  );

  return (
    <QueryClientProvider client={client}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

export default Providers;

그리고 나서 Providers 로 래핑해주면 react-query 기본 설정이 다 되었습니다.

// layout.tsx

import Providers from "@/components/Providers";
import "./globals.css";

export const metadata = {
  title: "React Query SSR",
  description: "React Query SSR 실습",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Using <Hydrate>

QueryClient의 request-scoped 싱글톤 인스턴스를 만듭니다. 이렇게 하면 서로 다른 사용자 요청 간에 데이터가 공유되지 않고 요청당 한 번만 쿼리 클라이언트를 만들 수 있습니다.

// app/getQueryClient.jsx

import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'

const getQueryClient = cache(() => new QueryClient())
export default getQueryClient

prefetched queries를 사용하는 클라이언트 컴포넌트보다 component tree의 높은 위치에 있는 서버 컴포넌트에서 데이터를 가져옵니다.

  • QueryClient singleton 인스턴스 검색
  • 클라이언트의 prefetchQuery 메서드를 사용하여 데이터를 prefetch하고 완료될 때까지 기다립니다.
  • dehydrate를 사용하여 쿼리 캐시로부터 프리페치된 쿼리의 dehydrate 된 상태를 가져옵니다. 🤔
  • prefetched queries가 필요한 컴포넌트 트리를 내부에 래핑하여 dehydrated state를 제공합니다.
  • 여러 서버 컴포넌트 내부로 가져와서 여러 위치에서 <Hydrate> 을 사용할 수 있습니다.
// app/posts/hydratedPosts.jsx
import { dehydrate, Hydrate } from '@tanstack/react-query'
import getQueryClient from './getQueryClient'

export default async function HydratedPosts() {
  const queryClient = getQueryClient()
  await queryClient.prefetchQuery(['posts'], getPosts)
  const dehydratedState = dehydrate(queryClient)

  return (
    <Hydrate state={dehydratedState}>
      <Posts />
    </Hydrate>
  )
}

참고: TypeScript는 현재 비동기 서버 컴포넌트 요소를 사용할 때 타입 오류가 발생합니다. 임시 해결 방법으로 다른 컴포넌트 내부에서 이 컴포넌트를 호출할 때 {/* @ts-expect-error Server Component */}을(를) 사용하십시오.

간단하게 jsonplaceholder API를 사용해서 post를 호출해서 사용해보겠습니다.

// app/posts/getPosts.ts
export interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}

export async function getPosts() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts");
  const posts = (await response.json()) as Post[];
  return posts;
}

서버 렌더링(SSR) 중에 <Hydrate> 내 클라이언트 컴포넌트에서 nested useQuery 호출은 state property에서 제공되는 prefetch 된 데이터에 액세스할 수 있습니다.

밑에서는 비교를 위해 하나는 HydratedPosts에서 사용한 querykey와 동일한 키를 가진 useQuery를 사용했고, 또 다른 하나는 동일하지 않은 키를 가진 useQuery를 사용했습니다.

// app/posts/posts.tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import { getPosts } from "./getPosts";

export default function Posts() {
  // This useQuery could just as well happen in some deeper child to
  // the "HydratedPosts"-component, data will be available immediately either way
  const { data } = useQuery({ queryKey: ["posts"], queryFn: getPosts });

  // This query was not prefetched on the server and will not start
  // fetching until on the client, both patterns are fine to mix
  const { data: otherData } = useQuery({
    queryKey: ["posts-not-ssr"],
    queryFn: getPosts,
  });

  return (
    <div className="flex">
      <div>
        <h2 className="text-lg text-center font-bold mb-6">
          React Query SSR 적용
        </h2>
        <ul>
          {data?.map((post) => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      </div>
      <div>
        <h2 className="text-lg text-center font-bold mb-6">
          React Query SSR 적용 안함
        </h2>
        <ul>
          {otherData?.map((post) => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      </div>
    </div>
  );
}

결과를 보면 왼쪽이 제대로 SSR이 적용된 것을 알 수 있고, 오른쪽은 SSR이 적용이 안된 CSR 방식으로 처리된 것을 알 수 있습니다. 이는 위에 코드 주석에도 나와있는 것처럼 서버에서 prefetch를 해서 dehydrate한 다음, 클라이언트에게 rehydrate를 해주는지 안해주는지 차이에서 비롯됩니다.

그렇다면 뭐가 더 좋을까요? 만약 해당 데이터가 검색 엔진 최적화(SEO)에 중요하거나 사용자에게 초반에 미리 보이는게 좋다면 SSR을 적용하면 될 거 같습니다. 하지만 서버에서 prefetch 하는 시간이 오래걸리게 되면 그만큼 페이지에서는 아무것도 보여줄 수 없을 것입니다. 따라서 오래 걸리는 작업은 차라리 오른쪽 처럼 CSR 방식대로 사용하는게 나아보입니다. 그래야 로딩 중이라는 표시라도 해줄 수 있기 때문이죠.


마치면서

이번 시간에는 React Query란 무엇이고 왜 사용하는지 배워봤습니다. 그리고 간단 사용법과 Next.js 13 버전에서 React Query SSR 적용하는 방법도 알아봤습니다. 처음에 봤을때 prefetch, dehydrate 이런 개념을 잘 몰라서 어려웠던 경험이 있었는데 그래도 이제는 어느정도 이해가 되는거 같습니다. 가면 갈수록 어려워지는 프론트엔드... 😂


참고 자료

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

4개의 댓글

comment-user-thumbnail
2023년 8월 26일

감사합니다 너무 도움이 됐어요 🤩

1개의 답글
comment-user-thumbnail
2023년 9월 21일

Providers 만들 때는 refetchOnWindowFocus: false, retry: false로 옵션을 주고
const getQueryClient = cache(() => new QueryClient()) 이 부분에서는 옵션을 안주셨는데
둘이 옵션 맞춰줄 필요는 없나요??

답글 달기
comment-user-thumbnail
2024년 1월 11일

많은 도움이 되었습니다. 감사합니다.

답글 달기