이번 포스팅에서는 NextJS에 tRPC를 적용한 후
서버 컴포넌트의 장점과 클라이언트 컴포넌트의 장점을 모두 지닌
하이브리드 컴포넌트를 구현하는 과정에 대해 정리해보려 한다.
우선 구현에 앞서 tRPC가 무엇인지에 대해 알아보자.
지난 포스팅에서 RPC에 대해 설명하였으니, RPC에 대한 설명은 아래 포스팅을 참고해주길 바란다.
REST가 만연한 세상에서 RPC를 외치다 | RPC란?, REST vs RPC
RPC란? RPC는 Remote Procedure Call의 약자로 원격 프로시저 호출이라는 뜻이다. 말 그대로 원격 서버의 프로시저를 호출하기 위한 방법이다. 예를 들어 내가 유튜브에서 영상을 본다면 아마 대략적으
yooputer-devlog.tistory.com
자 RPC와 tRPC의 차이점이 무엇일까.
마치 스프링과 스프링부트와 같은 관계이다.
tRPC는 RPC에 타입스크립트 기능을 추가한 라이브러리이다.
서버와 클라이언트가 통신할 때 타입스크립트로 정의된 타입의 객체들을 주고 받는다.
따라서 Typesafety 즉, 타입 오류로부터 안전하다.
우리는 NextJS에 tRPC를 적용함으로써 타입 오류로부터 안전한 풀스택 어플리케이션을 구현해볼 예정이다.
그리고 tRPC11부터 tRPC를 사용한 서버사이드 렌더링이 가능해졌다.
Server-Side Helpers | tRPC
The server-side helpers provides you with a set of helper functions that you can use to prefetch queries on the server. This is useful for SSG, but also for SSR if you opt not to use ssr: true.
trpc.io
그래서 우리는 서버 컴포넌트와 클라이언트 컴포넌트에서 각각 tRPC를 호출해보고,
서버 컴포넌트와 클라이언트 컴포넌트의 장점을 모두 모은 하이브리드 컴포넌트를 구현해볼 것이다.
tRPC 적용 실습을 위해 아래와 같은 프로젝트를 하나 생성하였다.
다음과 같이 라이브러리를 설치한다.
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query@latest zod superjson react-error-boundary
이제 trpc.hello({text: '이름'})를 호출하면 hello 이름을 반환하는 간단한 기능을 지닌 tRPC 라우터를 생성할 것이다.
이 샘플 코드들은 모두 아래 공식문서를 참고하였다.
https://trpc.io/docs/client/react/server-components
Set up with React Server Components | tRPC
These are the docs for our 'Classic' React Query integration, which (while still supported) is not the recommended way to start new tRPC projects with TanStack React Query. We recommend using the new TanStack React Query Integration instead.
trpc.io
/src/trpc/init.ts 파일을 생성한 후 아래 코드를 작성한다.
import { 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 helpers
export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
export const baseProcedure = t.procedure;
/src/trpc/routers/_app.ts 파일을 생성한 후 아래 코드를 작성한다.
import { 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 API
export type AppRouter = typeof appRouter;
/src/app/api/trpc/[trpc]/route.ts 파일을 생성한 후 아래 코드를 작성한다.
import { 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 };
/src/trpc/query-client.ts 파일을 생성한 후 아래 코드를 작성한다.
import {
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,
},
},
});
}
/src/trpc/client.tsx 파일을 생성한 후 아래 코드를 작성한다.
'use client';
// ^-- to make sure we can mount the Provider from a server component
import 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 client
return makeQueryClient();
}
// Browser: use singleton pattern to keep the same query client
return (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 boundary
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
// transformer: superjson, <-- if you use a data transformer
url: getUrl(),
}),
],
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
</trpc.Provider>
);
}
/src/app/layout.tsx의 children을 /src/trpc/client.tsx에서 export한 TRPCProvider로 감싼다.
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<TRPCProvider>
{children}
</TRPCProvider>
</body>
</html>
);
}
/src/trpc/server.tsx 파일을 생성한 후 아래 코드를 작성한다.
import 'server-only'; // <-- ensure this file cannot be imported from the client
import { 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를 위한 셋업이 끝났다.
이제 tRPC를 사용해보자!
인덱스 페이지인 /app/page.tsx를 다음과 같이 수정하였다.
import {trpc} from "@/trpc/server";
export default async function Home() {
const data = await trpc.hello({text:"유진"});
return (
<div>
Server Component : {data.greeting}
</div>
)
}
프로그램을 실행시켜보면 다음과 같이 trpc.hello가 잘 반환된 것을 알 수 있다.
다음과 같이 코드를 수정하여 클라이언트 컴포넌트로 바꾸고 비동기적으로 trpc.hello를 호출해보자.
"use client";
import {trpc} from "@/trpc/client";
export default function Home() {
const { data } = trpc.hello.useQuery({text:"유진이"});
return (
<div>
Client Component : {data?.greeting}
</div>
)
}
마찬가지로 잘 동작하는 것을 확인할 수 있다.
근데 실행해보면 알겠지만 클라이언트 컴포넌트에서 사용하였을 때
'hello 유진이' 문자열이 'Client Component : ' 문자열보다 늦게 렌더링되는 것을 확인할 수 있다.
trpc를 통해 비동기적으로 문자열을 조회하기 때문에 일부 문자열이 늦게 출력되는 것이다.
서버 컴포넌트를 사용하면 서버에서 렌더링하여 페이지 자체가 한번에 조회되기 때문에 모든 문자열들이 동시에 조회된다.
그리고 데이터들이 서버에 캐시되기 때문에 속도도 빠르다.
하지만 서버 컴포넌트를 사용하면 onClick과 같은 기능을 사용할 수 없게 되는 치명적 단점이 생긴다.
그래서 우리는 느리지만 유연한 클라이언트 컴포넌트를 사용해야할 것인가, 뻣뻣하지만 빠른 서버 컴포넌트를 사용할 것인가를 고민해야 했다.
하지만 tRPC11부터 이 둘의 장점을 합친 하이브리드 컴포넌트를 구현 할 수 있게 되었다.
하이브리드 컴포넌트는 기본적으로 서버 컴포넌트이지만 클라이언트 컴포넌트인 자식 컴포넌트를 가진다.
이 자식 클라이언트 컴포넌트는 부모 서버 컴포넌트가 prefetch한 쿼리를 useSuspenseQuery로 조회하여 사용한다.
prefetch할 때 서버 컴포넌트는 캐시를 사용할 수 있으므로 서버 컴포넌트의 빠른 속도와 클라이언트 컴포넌트의 유연함을 동시에 얻을 수 있다.
다음과 같이 서버 컴포넌트에서 prefetch를 하고
클라이언트 컴포넌트를 HydrateClient와 Suspense, ErrorBoundary 컴포넌트가 감싼 형태로 이루어진다.
import {HydrateClient, trpc} from "@/trpc/server";
import React, {Suspense} from "react";
import {ErrorBoundary} from "react-error-boundary";
import {PageClient} from "@/app/client";
export default async function Home() {
const name = 'yoojin';
void trpc.hello.prefetch({text:name});
return (
<HydrateClient>
<Suspense fallback={<p>Loading...</p>}>
<ErrorBoundary fallback={<p>Error....</p>}>
<PageClient name={name}/>
</ErrorBoundary>
</Suspense>
</HydrateClient>
)
}
클라이언트 컴포넌트인 /app/client.tsx의 내용은 다음과 같다
useSuspenseQuery를 사용하여 서버 컴포넌트에서 prefetch한 데이터를 조회하여 사용한다.
"use client";
import {trpc} from "@/trpc/client";
export const PageClient = ({ name }: { name:string }) => {
const [data] = trpc.hello.useSuspenseQuery({text: name});
return(
<div>
Page Client : { data.greeting}
</div>
)
}
실행 결과 새로고침을 하여도 문자열들의 출력이 빠르게 되는 것을 확인할 수 있다.
NextJS에 tRPC를 적용해보고
하이브리드 컴포넌트를 구현해보았다.
이후 RPC를 사용하게 될 때 유용하게 활용할 수 있을 것 같다.
REST가 만연한 세상에서 RPC를 외치다 | RPC란?, REST vs RPC (0) | 2025.06.13 |
---|---|
[ngrok] 무료로 내 로컬 웹서버 배포하기 | reverse proxy, local 터널링 (0) | 2025.06.12 |
Neon으로 3초만에 무료 DB 서버 만들기 & NextJS에 연동 (0) | 2025.06.11 |
Clerk으로 10분만에 로그인/회원가입 적용하기 | NextJS 15, 소셜로그인 구현 (0) | 2025.06.10 |
노션 API 연동 데모 코드 분석하기 | Express.js, Notion API (1) | 2025.05.28 |