React Server Components로 설정하기
이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →
이 문서는 '클래식' React Query 통합 방식에 대한 설명입니다. (현재도 지원되지만) 새로운 tRPC 프로젝트 시작 시 권장하는 방식은 아닙니다. 대신 새로운 TanStack React Query 통합 방식을 사용하시길 권장합니다.
이 가이드는 Next.js App Router와 같은 React Server Components(RSC) 프레임워크에서 tRPC를 사용하는 방법에 대한 개요입니다. RSC 자체로도 tRPC가 해결하려던 많은 문제를 처리할 수 있으므로, tRPC가 전혀 필요하지 않을 수 있음에 유의하세요.
또한 tRPC와 RSC를 통합하는 단일한 표준 방법은 없으므로, 본 가이드를 시작점으로 삼아 필요와 선호도에 맞게 조정하시기 바랍니다.
tRPC를 Server Actions와 함께 사용하는 방법을 찾고 계신다면 Julius의 이 블로그 포스트를 참조하세요.
진행 전에 React Query의 고급 서버 렌더링 문서를 꼭 읽어주세요. 다양한 서버 렌더링 유형과 피해야 할 함정을 이해하는 데 도움이 됩니다.
기존 프로젝트에 tRPC 추가하기
1. 의존성 설치
- npm
- yarn
- pnpm
- bun
- deno
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query@latest zod client-only server-only
yarn add @trpc/server @trpc/client @trpc/react-query @tanstack/react-query@latest zod client-only server-only
pnpm add @trpc/server @trpc/client @trpc/react-query @tanstack/react-query@latest zod client-only server-only
bun add @trpc/server @trpc/client @trpc/react-query @tanstack/react-query@latest zod client-only server-only
deno add npm:@trpc/server npm:@trpc/client npm:@trpc/react-query npm:@tanstack/react-query@latest npm:zod npm:client-only npm:server-only
2. tRPC 라우터 생성
trpc/init.ts에서 initTRPC 함수를 사용해 tRPC 백엔드를 초기화하고 첫 번째 라우터를 생성하세요. 여기서는 간단한 "hello world" 라우터와 프로시저를 만들겠습니다. tRPC API 생성에 대한 더 깊은 내용은 퀵스타트 가이드와 백엔드 사용 문서를 참조하세요.
여기 사용된 파일명은 tRPC에서 강제하는 것이 아닙니다. 원하는 파일 구조를 자유롭게 사용할 수 있습니다.
View sample backend
trpc/init.tstsimport { initTRPC } from '@trpc/server';import { cache } from 'react';export const createTRPCContext = cache(async () => {/*** @see: https://trpc.io/docs/server/context*/return { userId: 'user_123' };});// Avoid exporting the entire t-object// since it's not very descriptive.// For instance, the use of a t variable// is common in i18n libraries.const t = initTRPC.create({/*** @see https://trpc.io/docs/server/data-transformers*/// transformer: superjson,});// Base router and procedure helpersexport const createTRPCRouter = t.router;export const createCallerFactory = t.createCallerFactory;export const baseProcedure = t.procedure;
trpc/init.tstsimport { initTRPC } from '@trpc/server';import { cache } from 'react';export const createTRPCContext = cache(async () => {/*** @see: https://trpc.io/docs/server/context*/return { userId: 'user_123' };});// Avoid exporting the entire t-object// since it's not very descriptive.// For instance, the use of a t variable// is common in i18n libraries.const t = initTRPC.create({/*** @see https://trpc.io/docs/server/data-transformers*/// transformer: superjson,});// Base router and procedure helpersexport const createTRPCRouter = t.router;export const createCallerFactory = t.createCallerFactory;export const baseProcedure = t.procedure;
trpc/routers/_app.tstsimport { z } from 'zod';import { baseProcedure, createTRPCRouter } from '../init';export const appRouter = createTRPCRouter({hello: baseProcedure.input(z.object({text: z.string(),}),).query((opts) => {return {greeting: `hello ${opts.input.text}`,};}),});// export type definition of APIexport type AppRouter = typeof appRouter;
trpc/routers/_app.tstsimport { z } from 'zod';import { baseProcedure, createTRPCRouter } from '../init';export const appRouter = createTRPCRouter({hello: baseProcedure.input(z.object({text: z.string(),}),).query((opts) => {return {greeting: `hello ${opts.input.text}`,};}),});// export type definition of APIexport type AppRouter = typeof appRouter;
The backend adapter depends on your framework and how it sets up API routes. The following example sets up GET and POST routes at /api/trpc/* using the fetch adapter in Next.js.
app/api/trpc/[trpc]/route.tstsimport { fetchRequestHandler } from '@trpc/server/adapters/fetch';import { createTRPCContext } from '~/trpc/init';import { appRouter } from '~/trpc/routers/_app';const handler = (req: Request) =>fetchRequestHandler({endpoint: '/api/trpc',req,router: appRouter,createContext: createTRPCContext,});export { handler as GET, handler as POST };
app/api/trpc/[trpc]/route.tstsimport { fetchRequestHandler } from '@trpc/server/adapters/fetch';import { createTRPCContext } from '~/trpc/init';import { appRouter } from '~/trpc/routers/_app';const handler = (req: Request) =>fetchRequestHandler({endpoint: '/api/trpc',req,router: appRouter,createContext: createTRPCContext,});export { handler as GET, handler as POST };
3. Query Client 팩토리 생성
trpc/query-client.ts 공유 파일을 생성하고 QueryClient 인스턴스를 생성하는 함수를 내보내세요.
trpc/query-client.tstsimport {defaultShouldDehydrateQuery,QueryClient,} from '@tanstack/react-query';import superjson from 'superjson';export function makeQueryClient() {return new QueryClient({defaultOptions: {queries: {staleTime: 30 * 1000,},dehydrate: {// serializeData: superjson.serialize,shouldDehydrateQuery: (query) =>defaultShouldDehydrateQuery(query) ||query.state.status === 'pending',},hydrate: {// deserializeData: superjson.deserialize,},},});}
trpc/query-client.tstsimport {defaultShouldDehydrateQuery,QueryClient,} from '@tanstack/react-query';import superjson from 'superjson';export function makeQueryClient() {return new QueryClient({defaultOptions: {queries: {staleTime: 30 * 1000,},dehydrate: {// serializeData: superjson.serialize,shouldDehydrateQuery: (query) =>defaultShouldDehydrateQuery(query) ||query.state.status === 'pending',},hydrate: {// deserializeData: superjson.deserialize,},},});}
여기서 몇 가지 기본 옵션을 설정합니다:
-
staleTime: SSR 환경에서는 클라이언트에서 즉시 재요청(refetch)하는 것을 방지하기 위해 일반적으로 기본 staleTime을 0보다 큰 값으로 설정합니다. -
shouldDehydrateQuery: 쿼리 dehydrate 여부를 결정하는 함수입니다. RSC 전송 프로토콜은 네트워크를 통해 프로미스 하이드레이션을 지원하므로,defaultShouldDehydrateQuery함수를 확장하여 아직 처리 중인(pending) 쿼리도 포함시킵니다. 이렇게 하면 상위 트리의 서버 컴포넌트에서 프리페칭을 시작한 후, 하위 트리의 클라이언트 컴포넌트에서 해당 프로미스를 소비할 수 있습니다. -
serializeData및deserializeData(선택 사항): 이전 단계에서 데이터 변환기를 설정했다면, 서버-클라이언트 경계를 넘어 Query Client를 하이드레이션할 때 데이터가 올바르게 직렬화되도록 이 옵션을 설정하세요.
4. 클라이언트 컴포넌트용 tRPC 클라이언트 생성
trpc/client.tsx는 클라이언트 컴포넌트에서 tRPC API를 사용할 때의 진입점입니다. 여기서 tRPC 라우터의 타입 정의를 가져오고 createTRPCReact로 타입 안전 훅을 생성하세요. 또한 이 파일에서 컨텍스트 프로바이더도 내보냅니다.
trpc/client.tsxtsx'use client';// ^-- to make sure we can mount the Provider from a server componentimport type { QueryClient } from '@tanstack/react-query';import { QueryClientProvider } from '@tanstack/react-query';import { httpBatchLink } from '@trpc/client';import { createTRPCReact } from '@trpc/react-query';import { useState } from 'react';import { makeQueryClient } from './query-client';import type { AppRouter } from './routers/_app';export const trpc = createTRPCReact<AppRouter>();let clientQueryClientSingleton: QueryClient;function getQueryClient() {if (typeof window === 'undefined') {// Server: always make a new query clientreturn makeQueryClient();}// Browser: use singleton pattern to keep the same query clientreturn (clientQueryClientSingleton ??= makeQueryClient());}function getUrl() {const base = (() => {if (typeof window !== 'undefined') return '';if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;return 'http://localhost:3000';})();return `${base}/api/trpc`;}export function TRPCProvider(props: Readonly<{children: React.ReactNode;}>,) {// NOTE: Avoid useState when initializing the query client if you don't// have a suspense boundary between this and the code that may// suspend because React will throw away the client on the initial// render if it suspends and there is no boundaryconst queryClient = getQueryClient();const [trpcClient] = useState(() =>trpc.createClient({links: [httpBatchLink({// transformer: superjson, <-- if you use a data transformerurl: getUrl(),}),],}),);return (<trpc.Provider client={trpcClient} queryClient={queryClient}><QueryClientProvider client={queryClient}>{props.children}</QueryClientProvider></trpc.Provider>);}
trpc/client.tsxtsx'use client';// ^-- to make sure we can mount the Provider from a server componentimport type { QueryClient } from '@tanstack/react-query';import { QueryClientProvider } from '@tanstack/react-query';import { httpBatchLink } from '@trpc/client';import { createTRPCReact } from '@trpc/react-query';import { useState } from 'react';import { makeQueryClient } from './query-client';import type { AppRouter } from './routers/_app';export const trpc = createTRPCReact<AppRouter>();let clientQueryClientSingleton: QueryClient;function getQueryClient() {if (typeof window === 'undefined') {// Server: always make a new query clientreturn makeQueryClient();}// Browser: use singleton pattern to keep the same query clientreturn (clientQueryClientSingleton ??= makeQueryClient());}function getUrl() {const base = (() => {if (typeof window !== 'undefined') return '';if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;return 'http://localhost:3000';})();return `${base}/api/trpc`;}export function TRPCProvider(props: Readonly<{children: React.ReactNode;}>,) {// NOTE: Avoid useState when initializing the query client if you don't// have a suspense boundary between this and the code that may// suspend because React will throw away the client on the initial// render if it suspends and there is no boundaryconst queryClient = getQueryClient();const [trpcClient] = useState(() =>trpc.createClient({links: [httpBatchLink({// transformer: superjson, <-- if you use a data transformerurl: getUrl(),}),],}),);return (<trpc.Provider client={trpcClient} queryClient={queryClient}><QueryClientProvider client={queryClient}>{props.children}</QueryClientProvider></trpc.Provider>);}
애플리케이션 루트(예: Next.js 사용 시 app/layout.tsx)에 프로바이더를 마운트하세요.
5. 서버 컴포넌트용 tRPC 호출자 생성
서버 컴포넌트에서 쿼리를 프리페치하기 위해 tRPC 호출자(caller)를 사용합니다. @trpc/react-query/rsc 모듈은 React Query 클라이언트와 통합되는 createCaller의 경량 래퍼를 내보냅니다.
trpc/server.tsxtsximport 'server-only'; // <-- ensure this file cannot be imported from the clientimport { createHydrationHelpers } from '@trpc/react-query/rsc';import { cache } from 'react';import { createCallerFactory, createTRPCContext } from './init';import { makeQueryClient } from './query-client';import { appRouter } from './routers/_app';// IMPORTANT: Create a stable getter for the query client that// will return the same client during the same request.export const getQueryClient = cache(makeQueryClient);const caller = createCallerFactory(appRouter)(createTRPCContext);export const { trpc, HydrateClient } = createHydrationHelpers<typeof appRouter>(caller,getQueryClient,);
trpc/server.tsxtsximport 'server-only'; // <-- ensure this file cannot be imported from the clientimport { createHydrationHelpers } from '@trpc/react-query/rsc';import { cache } from 'react';import { createCallerFactory, createTRPCContext } from './init';import { makeQueryClient } from './query-client';import { appRouter } from './routers/_app';// IMPORTANT: Create a stable getter for the query client that// will return the same client during the same request.export const getQueryClient = cache(makeQueryClient);const caller = createCallerFactory(appRouter)(createTRPCContext);export const { trpc, HydrateClient } = createHydrationHelpers<typeof appRouter>(caller,getQueryClient,);
API 사용하기
이제 앱에서 tRPC API를 사용할 수 있습니다. 다른 React 앱에서와 마찬가지로 클라이언트 컴포넌트에서 React Query 훅을 사용할 수 있지만, 트리 상위의 서버 컴포넌트에서 쿼리를 프리페치하여 RSC의 기능을 활용할 수 있습니다. 이 개념은 로더로 구현되는 "렌더링 중 데이터 가져오기(render as you fetch)"로 익숙할 수 있습니다. 이는 useQuery 또는 useSuspenseQuery 훅을 사용해 데이터가 필요할 때까지 중단하지 않고 가능한 한 빨리 요청을 시작한다는 의미입니다.
app/page.tsxtsximport { trpc } from '~/trpc/server';import { ClientGreeting } from './client-greeting';export default async function Home() {void trpc.hello.prefetch();return (<HydrateClient><div>...</div>{/** ... */}<ClientGreeting /></HydrateClient>);}
app/page.tsxtsximport { trpc } from '~/trpc/server';import { ClientGreeting } from './client-greeting';export default async function Home() {void trpc.hello.prefetch();return (<HydrateClient><div>...</div>{/** ... */}<ClientGreeting /></HydrateClient>);}
app/client-greeting.tsxtsx'use client';// <-- hooks can only be used in client componentsimport { trpc } from '~/trpc/client';export function ClientGreeting() {const greeting = trpc.hello.useQuery();if (!greeting.data) return <div>Loading...</div>;return <div>{greeting.data.greeting}</div>;}
app/client-greeting.tsxtsx'use client';// <-- hooks can only be used in client componentsimport { trpc } from '~/trpc/client';export function ClientGreeting() {const greeting = trpc.hello.useQuery();if (!greeting.data) return <div>Loading...</div>;return <div>{greeting.data.greeting}</div>;}
서스펜스 활용하기
로딩 및 오류 상태를 서스펜스(Suspense)와 에러 바운더리(Error Boundaries)를 사용해 처리하는 것을 선호할 수 있습니다. useSuspenseQuery 훅을 사용하면 이를 구현할 수 있습니다.
app/page.tsxtsximport { trpc } from '~/trpc/server';import { Suspense } from 'react';import { ErrorBoundary } from 'react-error-boundary';import { ClientGreeting } from './client-greeting';export default async function Home() {void trpc.hello.prefetch();return (<HydrateClient><div>...</div>{/** ... */}<ErrorBoundary fallback={<div>Something went wrong</div>}><Suspense fallback={<div>Loading...</div>}><ClientGreeting /></Suspense></ErrorBoundary></HydrateClient>);}
app/page.tsxtsximport { trpc } from '~/trpc/server';import { Suspense } from 'react';import { ErrorBoundary } from 'react-error-boundary';import { ClientGreeting } from './client-greeting';export default async function Home() {void trpc.hello.prefetch();return (<HydrateClient><div>...</div>{/** ... */}<ErrorBoundary fallback={<div>Something went wrong</div>}><Suspense fallback={<div>Loading...</div>}><ClientGreeting /></Suspense></ErrorBoundary></HydrateClient>);}
app/client-greeting.tsxtsx'use client';import { trpc } from '~/trpc/client';export function ClientGreeting() {const [data] = trpc.hello.useSuspenseQuery();return <div>{data.greeting}</div>;}
app/client-greeting.tsxtsx'use client';import { trpc } from '~/trpc/client';export function ClientGreeting() {const [data] = trpc.hello.useSuspenseQuery();return <div>{data.greeting}</div>;}
서버 컴포넌트에서 데이터 가져오기
서버 컴포넌트에서 데이터에 접근해야 하는 경우, 일반 서버 호출자를 사용할 때처럼 .prefetch() 대신 프로시저를 직접 호출할 수 있습니다. 이 방법은 쿼리 클라이언트와 분리되어 있으며 데이터를 캐시에 저장하지 않습니다. 즉 서버 컴포넌트에서 데이터를 사용하더라도 클라이언트에서 해당 데이터를 사용할 수 있을 것이라고 기대할 수 없습니다. 이는 의도된 동작이며, 고급 서버 렌더링 가이드에서 자세히 설명합니다.
app/page.tsxtsximport { trpc } from '~/trpc/server';export default async function Home() {// Use the caller directly without using `.prefetch()`const greeting = await trpc.hello();// ^? { greeting: string }return <div>{greeting.greeting}</div>;}
app/page.tsxtsximport { trpc } from '~/trpc/server';export default async function Home() {// Use the caller directly without using `.prefetch()`const greeting = await trpc.hello();// ^? { greeting: string }return <div>{greeting.greeting}</div>;}