tRPC와 함께 Server Actions 사용하기
이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →
tRPC v10에서 도입된 프로시저 생성용 빌더 패턴은 커뮤니티로부터 폭넓은 호응을 받았으며, 많은 라이브러리가 유사한 패턴을 채택했습니다.
이 패턴의 인기가 높아짐에 따라 tRPC like XYZ라는 용어까지 생겨났을 정도입니다. 실제로 최근 누군가가 tRPC와 유사한 API로 CLI 애플리케이션을 작성할 수 있는 방법이 있는지 궁금해하는 글을 보았습니다.
사족이지만, tRPC를 직접 사용하여 이 작업을 수행할 수도 있습니다. 하지만 오늘 주제는 이게 아닙니다.
오늘은 Next.js의 server actions와 함께 tRPC를 사용하는 방법에 대해 이야기해보겠습니다.
Server Action이란 무엇인가요?
최신 React 및 Next.js 기능을 따라가지 못했다면, server actions를 통해 서버에서 실행되는 일반 함수를 작성하고 클라이언트에서 가져와 마치 일반 함수처럼 호출할 수 있습니다. 이것이 tRPC와 비슷하게 들릴 수 있으며 사실입니다. Dan Abramov에 따르면, server actions는 번들러 기능으로서의 tRPC입니다:
이 설명은 완전히 정확합니다. server actions는 tRPC와 유사하며, 결국 둘 다 RPC입니다. 둘 다 백엔드에 함수를 작성하고 네트워크 계층을 추상화한 상태로 프론트엔드에서 완전한 타입 안전성을 갖고 호출할 수 있게 해줍니다.
그렇다면 tRPC는 어디서 등장할까요? 왜 tRPC와 server actions를 함께 사용해야 할까요? Server actions는 프리미티브(primitive)이며, 모든 프리미티브가 그렇듯 상당히 기본적이라 API 구축에 필요한 근본적인 측면이 부족합니다. 네트워크를 통해 노출되는 모든 API 엔드포인트는 악의적인 사용을 방지하기 위해 요청의 유효성 검증 및 인가가 필요합니다. 앞서 언급했듯이, tRPC의 API는 커뮤니티로부터 인정받고 있으므로, tRPC를 사용해 server actions를 정의하고 입력 검증, 미들웨어를 통한 인증 및 인가, 출력 검증, 데이터 변환기 등 tRPC에 내장된 모든 훌륭한 기능을 활용할 수 있다면 좋지 않을까요? 저는 그렇게 생각합니다. 자세히 알아보겠습니다.
tRPC로 Server Actions 정의하기
필수 조건: Server actions를 사용하려면 Next.js App Router를 사용해야 합니다. 또한 우리가 사용할 모든 tRPC 기능은 tRPC v11에서만 사용 가능하므로, tRPC의 베타 릴리스 채널을 사용 중인지 확인하세요:
- npm
- yarn
- pnpm
- bun
- deno
npm install @trpc/server
yarn add @trpc/server
pnpm add @trpc/server
bun add @trpc/server
deno add npm:@trpc/server
먼저 tRPC를 초기화하고 기본 server actions 프로시저를 정의해봅시다.
프로시저 빌더의 experimental_caller 메서드를 사용할 것입니다. 이는 함수가 호출될 때 프로시저 실행 방식을 사용자 정의할 수 있게 해주는 새로운 메서드입니다. 또한 Next.js와 호환되도록 experimental_nextAppDirCaller 어댑터를 사용할 것입니다. 이 어댑터는 클라이언트에서 server action이 useActionState로 래핑된 경우를 처리하며, 이는 server action의 호출 시그니처를 변경합니다.
라우터를 사용할 때(user.byId 등)와 달리 일반 경로가 없으므로 메타데이터로 span 속성을 사용할 것입니다. span 속성은 로깅이나 관측 가능성(observability) 도중에 프로시저를 구분하는 데 사용할 수 있습니다.
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),);
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),);
다음으로 컨텍스트(context)를 추가해봅시다. 일반 HTTP 어댑터를 사용해 라우터를 호스팅하지 않을 것이므로, 어댑터의 createContext 메서드를 통해 주입되는 컨텍스트가 없을 것입니다. 대신 미들웨어를 사용해 컨텍스트를 주입할 것입니다. 이 예시에서는 세션에서 현재 사용자를 가져와 컨텍스트에 주입해보겠습니다.
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});
마지막으로, 인증되지 않은 사용자로부터 액션을 보호하는 protectedAction 절차를 생성합니다. 이미 이를 수행하는 미들웨어가 있다면 사용하면 되지만, 이 예제에서는 인라인으로 정의하겠습니다.
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});export constprotectedAction =serverActionProcedure .use ((opts ) => {if (!opts .ctx .user ) {throw newTRPCError ({code : 'UNAUTHORIZED',});}returnopts .next ({ctx : {...opts .ctx ,user :opts .ctx .user , // <-- ensures type is non-nullable},});});
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});export constprotectedAction =serverActionProcedure .use ((opts ) => {if (!opts .ctx .user ) {throw newTRPCError ({code : 'UNAUTHORIZED',});}returnopts .next ({ctx : {...opts .ctx ,user :opts .ctx .user , // <-- ensures type is non-nullable},});});
자, 이제 실제 서버 액션을 작성해 보겠습니다. _actions.ts 파일을 생성하고 "use server" 지시문을 추가한 후 액션을 정의합니다.
app/_actions.tsts'use server';import {z } from 'zod';import {protectedAction } from '../server/trpc';export constcreatePost =protectedAction .input (z .object ({title :z .string (),}),).mutation (async (opts ) => {// Do something with the input});// Since we're using the `experimental_caller`,// our procedure is now just an ordinary function:createPost ;
app/_actions.tsts'use server';import {z } from 'zod';import {protectedAction } from '../server/trpc';export constcreatePost =protectedAction .input (z .object ({title :z .string (),}),).mutation (async (opts ) => {// Do something with the input});// Since we're using the `experimental_caller`,// our procedure is now just an ordinary function:createPost ;
와우! 인증되지 않은 사용자로부터 보호되고 SQL 인젝션 같은 공격을 방지하는 입력 검증이 적용된 서버 액션을 정의하는 게 이렇게 쉽습니다. 이제 클라이언트에서 이 함수를 불러와 호출해 보세요.
app/post-form.tsxtsx'use client';import { createPost } from '~/_actions';export function PostForm() {return (<form// Use `action` to make form progressively enhancedaction={createPost}// `Using `onSubmit` allows building rich interactive// forms once JavaScript has loadedonSubmit={async (e) => {e.preventDefault();const title = new FormData(e.target).get('title');// Maybe show loading toast, etc etc. Endless possibilitiesawait createPost({ title });}}><input type="text" name="title" /><button type="submit">Create Post</button></form>);}
app/post-form.tsxtsx'use client';import { createPost } from '~/_actions';export function PostForm() {return (<form// Use `action` to make form progressively enhancedaction={createPost}// `Using `onSubmit` allows building rich interactive// forms once JavaScript has loadedonSubmit={async (e) => {e.preventDefault();const title = new FormData(e.target).get('title');// Maybe show loading toast, etc etc. Endless possibilitiesawait createPost({ title });}}><input type="text" name="title" /><button type="submit">Create Post</button></form>);}
더 나아가기
tRPC 빌더와 재사용 가능한 절차를 정의하는 합성 방식을 활용하면 더 복잡한 서버 액션도 쉽게 구축할 수 있습니다. 몇 가지 예시를 살펴보겠습니다:
관측 가능성(Observability)
@baselime/node-opentelemtry의 tRPC 플러그인을 사용하면 단 몇 줄의 코드로 관측 가능성을 추가할 수 있습니다:
diff--- server/trpc.ts+++ server/trpc.ts+ import { tracing } from '@baselime/node-opentelemetry/trpc';export const serverActionProcedure = t.procedure.experimental_caller(experimental_nextAppDirCaller({pathExtractor: (meta: Meta) => meta.span,}),).use(async (opts) => {// Inject user into contextconst user = await currentUser();return opts.next({ ctx: { user } });})+ .use(tracing());--- app/_actions.ts+++ app/_actions.tsexport const createPost = protectedAction+ .meta({ span: 'create-post' }).input(z.object({title: z.string(),}),).mutation(async (opts) => {// Do something with the input});
diff--- server/trpc.ts+++ server/trpc.ts+ import { tracing } from '@baselime/node-opentelemetry/trpc';export const serverActionProcedure = t.procedure.experimental_caller(experimental_nextAppDirCaller({pathExtractor: (meta: Meta) => meta.span,}),).use(async (opts) => {// Inject user into contextconst user = await currentUser();return opts.next({ ctx: { user } });})+ .use(tracing());--- app/_actions.ts+++ app/_actions.tsexport const createPost = protectedAction+ .meta({ span: 'create-post' }).input(z.object({title: z.string(),}),).mutation(async (opts) => {// Do something with the input});
자세한 내용은 Baselime tRPC 통합 문서를 참고하세요. 사용 중인 관측 가능성 플랫폼에 따라 유사한 패턴을 적용할 수 있습니다.
속도 제한(Rate Limiting)
Unkey 같은 서비스를 사용해 서버 액션의 요청 속도를 제한할 수 있습니다. 다음은 사용자별 요청 수를 제한하는 Unkey를 사용하는 보호된 서버 액션 예시입니다:
server/trpc.tstsimport {Ratelimit } from '@unkey/ratelimit';export constrateLimitedAction =protectedAction .use (async (opts ) => {constunkey = newRatelimit ({rootKey :process .env .UNKEY_ROOT_KEY !,async : true,duration : '10s',limit : 5,namespace : `trpc_${opts .path }`,});constratelimit = awaitunkey .limit (opts .ctx .user .id );if (!ratelimit .success ) {throw newTRPCError ({code : 'TOO_MANY_REQUESTS',message :JSON .stringify (ratelimit ),});}returnopts .next ();});
server/trpc.tstsimport {Ratelimit } from '@unkey/ratelimit';export constrateLimitedAction =protectedAction .use (async (opts ) => {constunkey = newRatelimit ({rootKey :process .env .UNKEY_ROOT_KEY !,async : true,duration : '10s',limit : 5,namespace : `trpc_${opts .path }`,});constratelimit = awaitunkey .limit (opts .ctx .user .id );if (!ratelimit .success ) {throw newTRPCError ({code : 'TOO_MANY_REQUESTS',message :JSON .stringify (ratelimit ),});}returnopts .next ();});
app/_actions.tsts'use server';import {z } from 'zod';import {rateLimitedAction } from '../server/trpc';export constcommentOnPost =rateLimitedAction .input (z .object ({postId :z .string (),content :z .string (),}),).mutation (async (opts ) => {console .log (`${opts .ctx .user .name } commented on ${opts .input .postId } saying ${opts .input .content }`,);});
app/_actions.tsts'use server';import {z } from 'zod';import {rateLimitedAction } from '../server/trpc';export constcommentOnPost =rateLimitedAction .input (z .object ({postId :z .string (),content :z .string (),}),).mutation (async (opts ) => {console .log (`${opts .ctx .user .name } commented on ${opts .input .postId } saying ${opts .input .content }`,);});
tRPC 절차 속도 제한에 대한 자세한 내용은 Unkey 팀의 게시물에서 확인하세요.
가능성은 무궁무진하며, 여러분은 이미 현재 tRPC 애플리케이션에서 유용한 유틸리티 미들웨어를 사용 중일 겁니다. 아직 없다면 npm install로 설치할 수 있는 것들을 찾아보세요!
마무리
서버 액션은 만능 해결책이 아닙니다. 더 동적인 데이터가 필요한 경우 클라이언트 측 React Query 캐시에 데이터를 유지하고 useMutation을 사용하는 것이 더 적합할 수 있습니다. 이는 완전히 타당한 접근 방식입니다. 이러한 새로운 기본 요소는 점진적으로 도입하기 쉬우므로, 기존 tRPC API의 개별 절차를 서버 액션으로 전환할 수 있습니다. 전체 API를 재작성할 필요는 없습니다.
tRPC를 사용해 서버 액션을 정의하면 현재 사용 중인 로직을 대부분 공유할 수 있으며, 변이를 서버 액션으로 노출할지 전통적인 변이로 노출할지 선택할 수 있습니다. 개발자에게 애플리케이션에 가장 적합한 패턴을 선택할 수 있는 권한이 주어집니다. 현재 tRPC를 사용하지 않는다면 타입 안전성과 입력 검증이 적용된 서버 액션을 정의할 수 있는 패키지(next-safe-action이나 zsa 등)도 살펴볼 가치가 있습니다.
실제로 이를 적용한 앱을 확인하려면 제가 최근에 만든 Trellix tRPC를 참고하세요.
의견을 남겨주세요
어떻게 생각하시나요? Github에서 의견을 공유해 주시면 이 기본 요소들을 안정화하는 데 도움이 됩니다.
오류 처리와 관련해 특히 해결해야 할 과제가 남아있습니다. Next.js는 오류 반환을 권장하며, 이를 최대한 타입 안전하게 만들고자 합니다. Alex의 WIP PR에서 초기 작업을 확인할 수 있습니다.
그럼 다음에 뵙겠습니다. 즐거운 코딩 되세요!
