React Server Components로 설정하기
Next.js를 사용 중이신가요? Next.js에 최적화된 상세 가이드를 위해 전용 Next.js App Router 설정 가이드를 확인해 보세요.
이 가이드는 Next.js App Router와 같은 React Server Components(RSC) 프레임워크에서 tRPC를 사용하는 방법에 대한 개요입니다. RSC 자체는 tRPC가 해결하고자 했던 많은 문제를 해결하므로, tRPC가 전혀 필요하지 않을 수 있습니다.
tRPC와 RSC를 통합하는 단 하나의 정답은 없습니다. 이 가이드를 시작점으로 삼아 프로젝트의 요구 사항과 선호에 맞게 조정하세요.
Server Actions와 함께 tRPC를 사용하는 방법을 찾고 있다면 Server Actions 가이드를 확인해 보세요.
진행하기 전에 다양한 서버 렌더링 유형과 피해야 할 함정을 이해하기 위해 React Query의 Advanced Server Rendering 문서를 먼저 읽어 보세요.
기존 프로젝트에 tRPC 추가하기
1. 의존성 설치
- npm
- yarn
- pnpm
- bun
- deno
npm install @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query@latest zod client-only server-only
yarn add @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query@latest zod client-only server-only
pnpm add @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query@latest zod client-only server-only
bun add @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query@latest zod client-only server-only
deno add npm:@trpc/server npm:@trpc/client npm:@trpc/tanstack-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 관련 Quickstart guide 및 Backend usage docs를 참고하세요.
여기서 사용된 파일 이름은 tRPC에서 강제하지 않습니다. 원하는 파일 구조를 자유롭게 사용할 수 있습니다.
샘플 백엔드 보기
trpc/init.tstsimport {initTRPC } from '@trpc/server';import {cache } from 'react';export constcreateTRPCContext =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.constt =initTRPC .create ({/*** @see https://trpc.io/docs/server/data-transformers*/// transformer: superjson,});// Base router and procedure helpersexport constcreateTRPCRouter =t .router ;export constcreateCallerFactory =t .createCallerFactory ;export constbaseProcedure =t .procedure ;
trpc/init.tstsimport {initTRPC } from '@trpc/server';import {cache } from 'react';export constcreateTRPCContext =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.constt =initTRPC .create ({/*** @see https://trpc.io/docs/server/data-transformers*/// transformer: superjson,});// Base router and procedure helpersexport constcreateTRPCRouter =t .router ;export constcreateCallerFactory =t .createCallerFactory ;export constbaseProcedure =t .procedure ;
trpc/routers/_app.tstsimport {z } from 'zod';import {baseProcedure ,createTRPCRouter } from '../init';export constappRouter =createTRPCRouter ({hello :baseProcedure .input (z .object ({text :z .string (),}),).query ((opts ) => {return {greeting : `hello ${opts .input .text }`,};}),});// export type definition of APIexport typeAppRouter = typeofappRouter ;
trpc/routers/_app.tstsimport {z } from 'zod';import {baseProcedure ,createTRPCRouter } from '../init';export constappRouter =createTRPCRouter ({hello :baseProcedure .input (z .object ({text :z .string (),}),).query ((opts ) => {return {greeting : `hello ${opts .input .text }`,};}),});// export type definition of APIexport typeAppRouter = typeofappRouter ;
백엔드 어댑터는 프레임워크와 API 라우트 설정 방식에 따라 달라집니다. 다음 예제는 Next.js에서 fetch adapter를 사용하여 /api/trpc/*에 GET 및 POST 라우트를 설정합니다.
app/api/trpc/[trpc]/route.tstsimport {fetchRequestHandler } from '@trpc/server/adapters/fetch';import {createTRPCContext } from '../../../../trpc/init';import {appRouter } from '../../../../trpc/routers/_app';consthandler = (req :Request ) =>fetchRequestHandler ({endpoint : '/api/trpc',req ,router :appRouter ,createContext :createTRPCContext ,});export {handler asGET ,handler asPOST };
app/api/trpc/[trpc]/route.tstsimport {fetchRequestHandler } from '@trpc/server/adapters/fetch';import {createTRPCContext } from '../../../../trpc/init';import {appRouter } from '../../../../trpc/routers/_app';consthandler = (req :Request ) =>fetchRequestHandler ({endpoint : '/api/trpc',req ,router :appRouter ,createContext :createTRPCContext ,});export {handler asGET ,handler asPOST };
3. Query Client 팩토리 생성
QueryClient 인스턴스를 생성하는 함수를 내보내는 공유 파일 trpc/query-client.ts를 생성합니다.
trpc/query-client.tstsimport {defaultShouldDehydrateQuery ,QueryClient ,} from '@tanstack/react-query';importsuperjson from 'superjson';export functionmakeQueryClient () {return newQueryClient ({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';importsuperjson from 'superjson';export functionmakeQueryClient () {return newQueryClient ({defaultOptions : {queries : {staleTime : 30 * 1000,},dehydrate : {// serializeData: superjson.serialize,shouldDehydrateQuery : (query ) =>defaultShouldDehydrateQuery (query ) ||query .state .status === 'pending',},hydrate : {// deserializeData: superjson.deserialize,},},});}
여기서 몇 가지 기본 옵션을 설정합니다:
staleTime: SSR 환경에서는 클라이언트에서 즉시 다시 가져오기를 방지하기 위해 staleTime을 0보다 큰 값으로 설정하는 것이 일반적입니다.shouldDehydrateQuery: 쿼리를 탈수화(dehydrate)할지 여부를 결정하는 함수입니다. RSC 전송 프로토콜은 네트워크를 통한 promise 수화(hydrate)를 지원하므로,defaultShouldDehydrateQuery함수를 확장하여 아직 대기 중인 쿼리도 포함하도록 합니다. 이를 통해 트리 상단의 서버 컴포넌트에서 사전 가져오기를 시작하고, 트리 하단의 클라이언트 컴포넌트에서 해당 promise를 소비할 수 있습니다.serializeData및deserializeData(선택 사항): 이전 단계에서 data transformer를 설정했다면, 이 옵션을 설정하여 서버-클라이언트 경계를 넘어 query client를 수화할 때 데이터가 올바르게 직렬화되도록 합니다.
4. Client Components용 tRPC 클라이언트 생성
trpc/client.tsx 파일은 클라이언트 컴포넌트에서 tRPC API를 소비할 때의 진입점입니다. 여기에서 tRPC 라우터의 타입 정의를 가져오고 createTRPCContext를 사용하여 타입 안전한 훅을 생성합니다. 또한 이 파일에서 컨텍스트 프로바이더도 내보냅니다.
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 {createTRPCClient ,httpBatchLink } from '@trpc/client';import {createTRPCContext } from '@trpc/tanstack-react-query';import {useState } from 'react';import {makeQueryClient } from './query-client';import type {AppRouter } from './routers/_app';export const {TRPCProvider ,useTRPC } =createTRPCContext <AppRouter >();letbrowserQueryClient :QueryClient ;functiongetQueryClient () {if (typeofwindow === 'undefined') {// Server: always make a new query clientreturnmakeQueryClient ();}// Browser: make a new query client if we don't already have one// This is very important, so we don't re-make a new client if React// suspends during the initial render. This may not be needed if we// have a suspense boundary BELOW the creation of the query clientif (!browserQueryClient )browserQueryClient =makeQueryClient ();returnbrowserQueryClient ;}functiongetUrl () {constbase = (() => {if (typeofwindow !== 'undefined') return '';if (process .env .VERCEL_URL ) return `https://${process .env .VERCEL_URL }`;return 'http://localhost:3000';})();return `${base }/api/trpc`;}export functionTRPCReactProvider (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 boundaryconstqueryClient =getQueryClient ();const [trpcClient ] =useState (() =>createTRPCClient <AppRouter >({links : [httpBatchLink ({// transformer: superjson, <-- if you use a data transformerurl :getUrl (),}),],}),);return (<QueryClientProvider client ={queryClient }><TRPCProvider trpcClient ={trpcClient }queryClient ={queryClient }>{props .children }</TRPCProvider ></QueryClientProvider >);}
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 {createTRPCClient ,httpBatchLink } from '@trpc/client';import {createTRPCContext } from '@trpc/tanstack-react-query';import {useState } from 'react';import {makeQueryClient } from './query-client';import type {AppRouter } from './routers/_app';export const {TRPCProvider ,useTRPC } =createTRPCContext <AppRouter >();letbrowserQueryClient :QueryClient ;functiongetQueryClient () {if (typeofwindow === 'undefined') {// Server: always make a new query clientreturnmakeQueryClient ();}// Browser: make a new query client if we don't already have one// This is very important, so we don't re-make a new client if React// suspends during the initial render. This may not be needed if we// have a suspense boundary BELOW the creation of the query clientif (!browserQueryClient )browserQueryClient =makeQueryClient ();returnbrowserQueryClient ;}functiongetUrl () {constbase = (() => {if (typeofwindow !== 'undefined') return '';if (process .env .VERCEL_URL ) return `https://${process .env .VERCEL_URL }`;return 'http://localhost:3000';})();return `${base }/api/trpc`;}export functionTRPCReactProvider (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 boundaryconstqueryClient =getQueryClient ();const [trpcClient ] =useState (() =>createTRPCClient <AppRouter >({links : [httpBatchLink ({// transformer: superjson, <-- if you use a data transformerurl :getUrl (),}),],}),);return (<QueryClientProvider client ={queryClient }><TRPCProvider trpcClient ={trpcClient }queryClient ={queryClient }>{props .children }</TRPCProvider ></QueryClientProvider >);}
프로바이더를 애플리케이션 루트에 마운트합니다(예: Next.js를 사용하는 경우 app/layout.tsx).
5. Server Components용 tRPC 콜러 생성
서버 컴포넌트에서 쿼리를 사전 가져오려면 라우터에서 프록시를 생성합니다. 라우터가 별도의 서버에 있는 경우 클라이언트를 전달할 수도 있습니다.
trpc/server.tsxtsximport 'server-only'; // <-- ensure this file cannot be imported from the clientimport {createTRPCOptionsProxy } from '@trpc/tanstack-react-query';import {createTRPCClient ,httpLink } from '@trpc/client';import {cache } from 'react';import {createTRPCContext } from './init';import {makeQueryClient } from './query-client';import {appRouter } from './routers/_app';import type {AppRouter } from './routers/_app';// IMPORTANT: Create a stable getter for the query client that// will return the same client during the same request.export constgetQueryClient =cache (makeQueryClient );export consttrpc =createTRPCOptionsProxy ({ctx :createTRPCContext ,router :appRouter ,queryClient :getQueryClient ,});// If your router is on a separate server, pass a client:createTRPCOptionsProxy <AppRouter >({client :createTRPCClient <AppRouter >({links : [httpLink ({url : '...' })],}),queryClient :getQueryClient ,});
trpc/server.tsxtsximport 'server-only'; // <-- ensure this file cannot be imported from the clientimport {createTRPCOptionsProxy } from '@trpc/tanstack-react-query';import {createTRPCClient ,httpLink } from '@trpc/client';import {cache } from 'react';import {createTRPCContext } from './init';import {makeQueryClient } from './query-client';import {appRouter } from './routers/_app';import type {AppRouter } from './routers/_app';// IMPORTANT: Create a stable getter for the query client that// will return the same client during the same request.export constgetQueryClient =cache (makeQueryClient );export consttrpc =createTRPCOptionsProxy ({ctx :createTRPCContext ,router :appRouter ,queryClient :getQueryClient ,});// If your router is on a separate server, pass a client:createTRPCOptionsProxy <AppRouter >({client :createTRPCClient <AppRouter >({links : [httpLink ({url : '...' })],}),queryClient :getQueryClient ,});
API 사용
이제 앱에서 tRPC API를 사용할 수 있습니다. 다른 React 앱처럼 클라이언트 컴포넌트에서 React Query 훅을 사용할 수 있고, 컴포넌트 트리 상단의 서버 컴포넌트에서 쿼리를 미리 가져와 RSC의 장점도 활용할 수 있습니다. 이 패턴은 일반적으로 로더(loader)로 구현하는 "render as you fetch"와 같습니다. useQuery나 useSuspenseQuery 훅에서 데이터가 필요해질 때까지 기다리지 않고 요청을 가능한 한 일찍 시작하는 방식입니다.
이 접근 방식은 Next.js App Router의 스트리밍 기능을 활용하여 서버에서 쿼리를 시작하고 데이터가 사용 가능해짐에 따라 클라이언트로 스트리밍합니다.
브라우저의 첫 바이트 시간(TTFB)과 데이터 가져오기 시간을 모두 최적화하여 더 빠른 페이지 로딩을 실현합니다.
그러나 데이터가 스트리밍되기 전에 greeting.data가 초기에는 undefined일 수 있습니다.
이러한 초기 undefined 상태를 피하고 싶다면 prefetchQuery 호출을 await할 수 있습니다.
이를 통해 클라이언트에서 쿼리가 항상 첫 번째 렌더링 시 데이터를 가지게 되지만, 트레이드오프가 있습니다 -
서버가 클라이언트로 HTML을 보내기 전에 쿼리를 완료해야 하므로 페이지 로딩이 더 느려질 수 있습니다.
app/page.tsxtsximport {dehydrate ,HydrationBoundary } from '@tanstack/react-query';import {getQueryClient ,trpc } from '../trpc/server';import {ClientGreeting } from './client-greeting';export default async functionHome () {constqueryClient =getQueryClient ();voidqueryClient .prefetchQuery (trpc .hello .queryOptions ({/** input */}),);return (<HydrationBoundary state ={dehydrate (queryClient )}><div >...</div >{/** ... */}<ClientGreeting /></HydrationBoundary >);}
app/page.tsxtsximport {dehydrate ,HydrationBoundary } from '@tanstack/react-query';import {getQueryClient ,trpc } from '../trpc/server';import {ClientGreeting } from './client-greeting';export default async functionHome () {constqueryClient =getQueryClient ();voidqueryClient .prefetchQuery (trpc .hello .queryOptions ({/** input */}),);return (<HydrationBoundary state ={dehydrate (queryClient )}><div >...</div >{/** ... */}<ClientGreeting /></HydrationBoundary >);}
app/client-greeting.tsxtsx'use client';// <-- hooks can only be used in client componentsimport {useQuery } from '@tanstack/react-query';import {useTRPC } from '../trpc/client';export functionClientGreeting () {consttrpc =useTRPC ();constgreeting =useQuery (trpc .hello .queryOptions ({text : 'world' }));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 {useQuery } from '@tanstack/react-query';import {useTRPC } from '../trpc/client';export functionClientGreeting () {consttrpc =useTRPC ();constgreeting =useQuery (trpc .hello .queryOptions ({text : 'world' }));if (!greeting .data ) return <div >Loading...</div >;return <div >{greeting .data .greeting }</div >;}
더 간결하고 재사용할 수 있도록 prefetch 및 HydrateClient 헬퍼 함수를 생성할 수도 있습니다:
trpc/server.tsxtsxexport functionHydrateClient (props : {children :React .ReactNode }) {constqueryClient =getQueryClient ();return (<HydrationBoundary state ={dehydrate (queryClient )}>{props .children }</HydrationBoundary >);}export functionprefetch <T extendsReturnType <TRPCQueryOptions <any>>>(queryOptions :T ,) {constqueryClient =getQueryClient ();if (queryOptions .queryKey [1]?.type === 'infinite') {voidqueryClient .prefetchInfiniteQuery (queryOptions as any);} else {voidqueryClient .prefetchQuery (queryOptions );}}
trpc/server.tsxtsxexport functionHydrateClient (props : {children :React .ReactNode }) {constqueryClient =getQueryClient ();return (<HydrationBoundary state ={dehydrate (queryClient )}>{props .children }</HydrationBoundary >);}export functionprefetch <T extendsReturnType <TRPCQueryOptions <any>>>(queryOptions :T ,) {constqueryClient =getQueryClient ();if (queryOptions .queryKey [1]?.type === 'infinite') {voidqueryClient .prefetchInfiniteQuery (queryOptions as any);} else {voidqueryClient .prefetchQuery (queryOptions );}}
이렇게 사용할 수 있습니다:
tsximport {HydrateClient ,prefetch ,trpc } from '../trpc/server';import {ClientGreeting } from './client-greeting';functionHome () {prefetch (trpc .hello .queryOptions ({/** input */}),);return (<HydrateClient ><div >...</div >{/** ... */}<ClientGreeting /></HydrateClient >);}
tsximport {HydrateClient ,prefetch ,trpc } from '../trpc/server';import {ClientGreeting } from './client-greeting';functionHome () {prefetch (trpc .hello .queryOptions ({/** input */}),);return (<HydrateClient ><div >...</div >{/** ... */}<ClientGreeting /></HydrateClient >);}
Suspense 활용
Suspense와 Error Boundaries를 사용하여 로딩 및 오류 상태를 처리하는 방식을 선호할 수 있습니다. useSuspenseQuery 훅을 사용하면 이를 구현할 수 있습니다.
app/page.tsxtsximport {HydrateClient ,prefetch ,trpc } from '../trpc/server';import {Suspense } from 'react';import {ErrorBoundary } from 'react-error-boundary';import {ClientGreeting } from './client-greeting';export default async functionHome () {prefetch (trpc .hello .queryOptions ());return (<HydrateClient ><div >...</div >{/** ... */}<ErrorBoundary fallback ={<div >Something went wrong</div >}><Suspense fallback ={<div >Loading...</div >}><ClientGreeting /></Suspense ></ErrorBoundary ></HydrateClient >);}
app/page.tsxtsximport {HydrateClient ,prefetch ,trpc } from '../trpc/server';import {Suspense } from 'react';import {ErrorBoundary } from 'react-error-boundary';import {ClientGreeting } from './client-greeting';export default async functionHome () {prefetch (trpc .hello .queryOptions ());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 {useSuspenseQuery } from '@tanstack/react-query';import {useTRPC } from '../trpc/client';export functionClientGreeting () {consttrpc =useTRPC ();const {data } =useSuspenseQuery (trpc .hello .queryOptions ());return <div >{data .greeting }</div >;}
app/client-greeting.tsxtsx'use client';import {useSuspenseQuery } from '@tanstack/react-query';import {useTRPC } from '../trpc/client';export functionClientGreeting () {consttrpc =useTRPC ();const {data } =useSuspenseQuery (trpc .hello .queryOptions ());return <div >{data .greeting }</div >;}
서버 컴포넌트에서 데이터 가져오기
서버 컴포넌트에서 데이터에 접근해야 하는 경우, 서버 콜러를 생성하여 직접 사용하는 것을 권장합니다. 이 방법은 쿼리 클라이언트와 분리되어 있으며 데이터를 캐시에 저장하지 않습니다. 즉, 서버 컴포넌트에서 데이터를 사용한다고 해서 클라이언트에서 해당 데이터가 사용 가능하다고 기대할 수 없습니다. 이는 의도된 동작이며 Advanced Server Rendering 가이드에서 자세히 설명되어 있습니다.
trpc/server.tsxtsx// ...export constcaller =appRouter .createCaller (createTRPCContext );
trpc/server.tsxtsx// ...export constcaller =appRouter .createCaller (createTRPCContext );
app/page.tsxtsximport {caller } from '../trpc/server';export default async functionHome () {constgreeting = awaitcaller .hello ();// ^? { greeting: string }return <div >{greeting .greeting }</div >;}
app/page.tsxtsximport {caller } from '../trpc/server';export default async functionHome () {constgreeting = awaitcaller .hello ();// ^? { greeting: string }return <div >{greeting .greeting }</div >;}
서버와 클라이언트 컴포넌트 양쪽에서 데이터를 사용해야 하고 Advanced Server Rendering
가이드에 설명된 트레이드오프를 이해하는 경우, prefetch 대신 fetchQuery를 사용하여 서버에서 데이터를 사용하고 클라이언트로 하이드레이션할 수 있습니다:
app/page.tsxtsximport {getQueryClient ,HydrateClient ,trpc } from '../trpc/server';import {ClientGreeting } from './client-greeting';export default async functionHome () {constqueryClient =getQueryClient ();constgreeting = awaitqueryClient .fetchQuery (trpc .hello .queryOptions ());// Do something with greeting on the serverreturn (<HydrateClient ><div >...</div >{/** ... */}<ClientGreeting /></HydrateClient >);}
app/page.tsxtsximport {getQueryClient ,HydrateClient ,trpc } from '../trpc/server';import {ClientGreeting } from './client-greeting';export default async functionHome () {constqueryClient =getQueryClient ();constgreeting = awaitqueryClient .fetchQuery (trpc .hello .queryOptions ());// Do something with greeting on the serverreturn (<HydrateClient ><div >...</div >{/** ... */}<ClientGreeting /></HydrateClient >);}