Next.js에서 서버와 통신하는 방법을 선택하는 것은 프로젝트의 아키텍처를 결정하는 중요한 순간입니다. 전통적인 RESTful API Routes, 혁신적인 Server Actions, 그리고 타입 안정성의 끝판왕 tRPC – 각각은 언제, 왜 사용해야 할까요?
이 가이드에서는 세 가지 접근 방식의 철학, 구현 방법, 장단점을 실제 코드와 함께 깊이 있게 비교 분석하여, 여러분의 프로젝트에 최적의 선택을 할 수 있도록 돕겠습니다.
각 기술의 핵심 철학
Server Actions – “서버 함수를 직접 호출하자”
Server Actions는 클라이언트에서 서버 함수를 마치 일반 JavaScript 함수처럼 호출할 수 있게 해주는 Next.js의 혁신적인 기능입니다.
// Server Actions의 핵심: 서버 코드를 직접 호출하는 느낌
// app/_actions/user-actions.ts
'use server'; // 이 한 줄이 모든 걸 바꿉니다!
export async function createUser(name: string, email: string) {
console.log('🚀 서버에서 실행 중...');
// 데이터베이스에 직접 접근 (클라이언트는 이 코드를 볼 수 없음)
const user = await prisma.user.create({
data: { name, email }
});
// 자동으로 직렬화되어 클라이언트로 전송
return user;
}
// 클라이언트 컴포넌트
'use client';
import { createUser } from '@/actions/user-actions';
function SignupForm() {
const handleSubmit = async () => {
// 마치 일반 함수를 호출하는 것처럼!
// 하지만 실제로는 POST 요청이 자동으로 생성됨
const newUser = await createUser('홍길동', 'hong@example.com');
console.log('✅ 사용자 생성됨:', newUser);
};
return <button onClick={handleSubmit}>가입하기</button>;
}
Server Actions의 마법:
- API 엔드포인트 작성 불필요
- 타입 안정성 자동 보장
- 클라이언트 번들에 서버 코드 포함 안 됨
API Routes – “RESTful하게, 표준적으로”
API Routes는 Next.js가 제공하는 전통적인 방법으로, Express.js와 유사한 방식으로 API를 구축합니다.
// API Routes의 핵심: HTTP 메서드와 URL 경로 기반
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
// GET /api/users - 사용자 목록 조회
export async function GET(request: NextRequest) {
console.log('📥 GET 요청 받음');
// URL 파라미터 파싱
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1');
const users = await prisma.user.findMany({
skip: (page - 1) * 10,
take: 10
});
// JSON 응답 반환
return NextResponse.json({
users,
page,
total: await prisma.user.count()
});
}
// POST /api/users - 새 사용자 생성
export async function POST(request: NextRequest) {
console.log('📤 POST 요청 받음');
try {
// 요청 본문 파싱
const body = await request.json();
// 유효성 검증
if (!body.email || !body.name) {
return NextResponse.json(
{ error: '필수 필드가 누락되었습니다' },
{ status: 400 }
);
}
const user = await prisma.user.create({
data: body
});
// 201 Created 응답
return NextResponse.json(user, { status: 201 });
} catch (error) {
console.error('❌ 에러:', error);
return NextResponse.json(
{ error: '사용자 생성 실패' },
{ status: 500 }
);
}
}
// 클라이언트에서 사용
async function fetchUsers() {
const response = await fetch('/api/users?page=1');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const data = await response.json();
return data.users;
}
API Routes의 특징:
- RESTful 원칙 준수
- 외부 시스템과 통합 용이
- 표준 HTTP 프로토콜 사용
tRPC – “타입 안정성의 극한”
tRPC는 TypeScript의 타입 시스템을 최대한 활용하여 클라이언트와 서버 간의 완벽한 타입 안정성을 제공합니다.
// tRPC의 핵심: End-to-End 타입 안정성
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod'; // 런타임 타입 검증
const t = initTRPC.create();
// server/routers/user.ts
export const userRouter = t.router({
// Query: 데이터 조회
getAll: t.procedure
.input(z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(10)
}))
.query(async ({ input }) => {
console.log(`📊 페이지 ${input.page} 조회 중...`);
const users = await prisma.user.findMany({
skip: (input.page - 1) * input.limit,
take: input.limit
});
return { users, page: input.page };
}),
// Mutation: 데이터 변경
create: t.procedure
.input(z.object({
name: z.string().min(1, '이름은 필수입니다'),
email: z.string().email('올바른 이메일 형식이 아닙니다')
}))
.mutation(async ({ input }) => {
console.log('🆕 새 사용자 생성:', input);
const user = await prisma.user.create({
data: input
});
return user;
})
});
// 클라이언트에서 사용
import { trpc } from '@/utils/trpc';
function UserList() {
// 자동 타입 추론! input과 output 모두 타입 체크
const { data, isLoading } = trpc.user.getAll.useQuery({
page: 1, // ✅ 타입 체크
limit: 10 // ✅ 타입 체크
// pagee: 1 // ❌ 컴파일 에러! 오타 방지
});
if (isLoading) return <div>로딩 중...</div>;
// data.users는 자동으로 User[] 타입!
return (
<ul>
{data?.users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
tRPC의 강점:
- 완벽한 타입 추론
- API 스펙 문서 불필요
- 오타나 타입 불일치 컴파일 타임에 방지
상세 기능 비교
1. 타입 안정성 비교
타입 안정성은 버그를 줄이고 개발 생산성을 높이는 핵심 요소입니다.
// Server Actions - 좋은 타입 안정성
// app/_actions/typed-actions.ts
'use server';
// 타입 정의
interface CreateUserInput {
name: string;
email: string;
role?: 'USER' | 'ADMIN';
}
export async function createUser(input: CreateUserInput) {
// input은 타입이 보장됨
const user = await prisma.user.create({ data: input });
return user; // 반환 타입도 자동 추론
}
// 클라이언트
const user = await createUser({
name: 'John',
email: 'john@example.com'
// role: 'SUPERADMIN' // ❌ 타입 에러!
});
console.log(user.id); // ✅ user 타입이 자동 추론됨
// API Routes - 수동 타입 관리 필요
// app/api/users/route.ts
interface CreateUserBody {
name: string;
email: string;
role?: 'USER' | 'ADMIN';
}
export async function POST(request: Request) {
const body = await request.json(); // any 타입...
// 런타임 검증 필요
if (!isValidUserInput(body)) {
return NextResponse.json(
{ error: '유효하지 않은 입력' },
{ status: 400 }
);
}
const user = await prisma.user.create({
data: body as CreateUserBody // 타입 단언 필요
});
return NextResponse.json(user);
}
// 클라이언트 - 타입 안정성 없음
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify({
name: 'John',
email: 'john@example.com',
role: 'SUPERADMIN' // ⚠️ 런타임까지 에러 발견 못함
})
});
const user = await response.json(); // any 타입...
// tRPC - 완벽한 End-to-End 타입 안정성
// server/routers/user.ts
const createUserInput = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['USER', 'ADMIN']).optional()
});
export const userRouter = t.router({
create: t.procedure
.input(createUserInput)
.mutation(async ({ input }) => {
// input 타입이 완벽하게 추론됨
return await prisma.user.create({ data: input });
})
});
// 클라이언트 - 완벽한 타입 추론과 자동완성
const mutation = trpc.user.create.useMutation();
await mutation.mutateAsync({
name: 'John',
email: 'john@example.com'
// role: 'SUPERADMIN' // ❌ 컴파일 타임에 에러!
});
// mutation.data는 User 타입으로 자동 추론
2. 폼 처리 방식 비교
각 기술마다 폼을 처리하는 방식이 다릅니다.
// Server Actions - Progressive Enhancement 지원
// JavaScript 없이도 작동!
export function ServerActionForm() {
return (
<form action={createUserAction}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">제출</button>
</form>
);
}
// 향상된 버전 (로딩 상태, 에러 처리)
'use client';
import { useFormState, useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending}>
{pending ? '처리 중...' : '제출'}
</button>
);
}
export function EnhancedForm() {
const [state, formAction] = useFormState(createUserAction, null);
return (
<form action={formAction}>
{state?.error && (
<div className="error">⚠️ {state.error}</div>
)}
<input name="name" defaultValue={state?.values?.name} />
<input name="email" defaultValue={state?.values?.email} />
<SubmitButton />
</form>
);
}
// API Routes - 전통적인 방식
'use client';
function APIRouteForm() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.get('name'),
email: formData.get('email')
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message);
}
const user = await response.json();
console.log('✅ 생성 완료:', user);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
{error && <div className="error">⚠️ {error}</div>}
<input name="name" required disabled={loading} />
<input name="email" type="email" required disabled={loading} />
<button type="submit" disabled={loading}>
{loading ? '처리 중...' : '제출'}
</button>
</form>
);
}
// tRPC - React Hook Form과 완벽한 통합
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const schema = z.object({
name: z.string().min(1, '이름은 필수입니다'),
email: z.string().email('올바른 이메일 형식이 아닙니다')
});
function TRPCForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema)
});
const createUser = trpc.user.create.useMutation({
onSuccess: (user) => {
console.log('✅ 생성 완료:', user);
},
onError: (error) => {
console.error('❌ 에러:', error.message);
}
});
const onSubmit = (data) => {
createUser.mutate(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('name')} placeholder="이름" />
{errors.name && <span className="error">{errors.name.message}</span>}
</div>
<div>
<input {...register('email')} type="email" placeholder="이메일" />
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<button type="submit" disabled={createUser.isLoading}>
{createUser.isLoading ? '처리 중...' : '제출'}
</button>
</form>
);
}
3. 실시간 기능 구현
실시간 업데이트가 필요한 경우 각 기술의 접근 방식이 다릅니다.
// Server Actions - 폴링 방식
'use client';
function RealtimeWithServerActions() {
const [messages, setMessages] = useState([]);
useEffect(() => {
// 2초마다 서버에서 새 메시지 확인
const interval = setInterval(async () => {
const newMessages = await getLatestMessages();
setMessages(newMessages);
}, 2000);
return () => clearInterval(interval);
}, []);
const sendMessage = async (text: string) => {
await createMessage(text); // Server Action 호출
const updated = await getLatestMessages();
setMessages(updated);
};
return (
<div>
{messages.map(msg => <Message key={msg.id} {...msg} />)}
<MessageInput onSend={sendMessage} />
</div>
);
}
// API Routes - Server-Sent Events (SSE)
// app/api/messages/stream/route.ts
export async function GET(request: Request) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// 클라이언트에게 실시간 이벤트 전송
const sendEvent = (data: any) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
);
};
// 초기 데이터
const messages = await getMessages();
sendEvent({ type: 'initial', messages });
// 실시간 업데이트 구독 (예: Redis Pub/Sub)
const unsubscribe = subscribeToMessages((message) => {
sendEvent({ type: 'new', message });
});
// 연결 종료 시 정리
request.signal.addEventListener('abort', () => {
unsubscribe();
controller.close();
});
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
}
});
}
// 클라이언트
function SSEMessages() {
const [messages, setMessages] = useState([]);
useEffect(() => {
const eventSource = new EventSource('/api/messages/stream');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'initial') {
setMessages(data.messages);
} else if (data.type === 'new') {
setMessages(prev => [...prev, data.message]);
}
};
eventSource.onerror = (error) => {
console.error('SSE 에러:', error);
eventSource.close();
};
return () => eventSource.close();
}, []);
return <MessageList messages={messages} />;
}
// tRPC - WebSocket Subscriptions
// server/routers/message.ts
export const messageRouter = t.router({
// WebSocket을 통한 실시간 구독
onMessage: t.procedure
.subscription(async function* ({ ctx }) {
// 초기 메시지
const initial = await getMessages();
yield { type: 'initial', messages: initial };
// 실시간 업데이트 스트림
for await (const message of messageEventEmitter) {
yield { type: 'new', message };
}
}),
sendMessage: t.procedure
.input(z.object({ text: z.string() }))
.mutation(async ({ input, ctx }) => {
const message = await createMessage(input.text);
// 모든 구독자에게 브로드캐스트
messageEventEmitter.emit(message);
return message;
})
});
// 클라이언트
function TRPCRealtimeMessages() {
const [messages, setMessages] = useState([]);
// WebSocket 구독
trpc.message.onMessage.useSubscription(undefined, {
onData(data) {
if (data.type === 'initial') {
setMessages(data.messages);
} else if (data.type === 'new') {
setMessages(prev => [...prev, data.message]);
}
},
onError(err) {
console.error('구독 에러:', err);
}
});
const sendMessage = trpc.message.sendMessage.useMutation();
return (
<div>
<MessageList messages={messages} />
<MessageInput
onSend={(text) => sendMessage.mutate({ text })}
disabled={sendMessage.isLoading}
/>
</div>
);
}
보안과 인증
각 기술의 보안 접근 방식
// Server Actions - 자동 CSRF 보호
'use server';
import { auth } from '@/lib/auth';
export async function secureAction(data: any) {
// 세션 확인
const session = await auth();
if (!session) {
throw new Error('인증이 필요합니다');
}
console.log(`🔐 사용자 ${session.user.email} 요청 처리 중`);
// CSRF 토큰은 자동으로 처리됨 (Same-origin policy)
return performAction(data, session.user);
}
// 역할 기반 접근 제어
export async function adminAction(data: any) {
const session = await auth();
if (!session || session.user.role !== 'ADMIN') {
throw new Error('관리자 권한이 필요합니다');
}
return performAdminTask(data);
}
// API Routes - 수동 보안 구현
// app/api/secure/route.ts
export async function POST(request: NextRequest) {
// 1. 인증 토큰 확인
const token = request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json(
{ error: '인증 토큰이 없습니다' },
{ status: 401 }
);
}
// 2. 토큰 검증
const user = await verifyToken(token);
if (!user) {
return NextResponse.json(
{ error: '유효하지 않은 토큰' },
{ status: 401 }
);
}
// 3. CSRF 토큰 확인 (필요한 경우)
const csrfToken = request.headers.get('x-csrf-token');
if (!validateCSRFToken(csrfToken, user.id)) {
return NextResponse.json(
{ error: 'CSRF 토큰 검증 실패' },
{ status: 403 }
);
}
// 4. Rate Limiting
const { success } = await rateLimit(user.id);
if (!success) {
return NextResponse.json(
{ error: '너무 많은 요청' },
{ status: 429 }
);
}
// 비즈니스 로직 실행
const result = await performAction(await request.json(), user);
return NextResponse.json(result);
}
// tRPC - 미들웨어 기반 보안
// server/trpc.ts
import { TRPCError } from '@trpc/server';
// Context 생성 (모든 procedure에서 사용)
export async function createContext({ req }: { req: Request }) {
const session = await getSession({ req });
return {
session,
req
};
}
// 인증 미들웨어
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: '로그인이 필요합니다'
});
}
console.log(`🔐 인증된 사용자: ${ctx.session.user.email}`);
return next({
ctx: {
...ctx,
session: ctx.session // 타입 narrowing
}
});
});
// 관리자 권한 미들웨어
const isAdmin = t.middleware(({ ctx, next }) => {
if (!ctx.session || ctx.session.user.role !== 'ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: '관리자 권한이 필요합니다'
});
}
return next({ ctx });
});
// Procedure 생성
export const publicProcedure = t.procedure; // 공개
export const protectedProcedure = t.procedure.use(isAuthed); // 인증 필요
export const adminProcedure = t.procedure.use(isAuthed).use(isAdmin); // 관리자
// 사용 예시
export const userRouter = t.router({
// 누구나 접근 가능
getPublicProfile: publicProcedure
.input(z.string())
.query(({ input }) => getProfile(input)),
// 로그인 사용자만
getMyData: protectedProcedure
.query(({ ctx }) => getUserData(ctx.session.user.id)),
// 관리자만
deleteUser: adminProcedure
.input(z.string())
.mutation(({ input }) => deleteUser(input))
});
성능과 번들 사이즈
각 기술의 성능 영향
// 번들 사이즈 비교
const bundleImpact = {
serverActions: {
clientBundle: '~0 KB', // 서버 코드는 클라이언트에 포함 안 됨
serverBundle: 'N/A', // 서버에만 존재
notes: '✅ 클라이언트 번들에 영향 없음'
},
apiRoutes: {
clientBundle: '0-15 KB', // fetch 래퍼나 axios 등
serverBundle: '최소',
notes: '📦 HTTP 클라이언트 라이브러리에 따라 다름'
},
trpc: {
clientBundle: '~43 KB', // @trpc/client + @trpc/react-query
serverBundle: '~25 KB', // @trpc/server
additionalDeps: '~30 KB', // zod, superjson 등
notes: '⚠️ 초기 로딩 시간 증가'
}
};
// 실제 성능 측정 예시
console.log(`
초기 페이지 로드 시간 (3G 네트워크):
- Server Actions: 1.2초
- API Routes: 1.3초
- tRPC: 1.8초 (추가 라이브러리 다운로드)
API 호출 속도:
- Server Actions: ~50ms (직접 함수 호출)
- API Routes: ~60ms (HTTP 오버헤드)
- tRPC: ~55ms (최적화된 RPC)
`);
캐싱 전략
// Server Actions - unstable_cache 활용
import { unstable_cache } from 'next/cache';
const getCachedUsers = unstable_cache(
async () => {
console.log('🔍 데이터베이스 조회 중...');
return prisma.user.findMany();
},
['users'], // 캐시 키
{
revalidate: 3600, // 1시간
tags: ['users'] // 태그 기반 무효화
}
);
export async function getUsersAction() {
'use server';
return getCachedUsers();
}
// API Routes - 수동 캐싱
let cache = null;
let cacheTime = 0;
export async function GET() {
const now = Date.now();
// 캐시가 유효한지 확인 (1시간)
if (cache && now - cacheTime < 3600000) {
console.log('✅ 캐시 히트');
return NextResponse.json(cache);
}
console.log('❌ 캐시 미스, DB 조회');
const users = await prisma.user.findMany();
// 캐시 업데이트
cache = users;
cacheTime = now;
return NextResponse.json(users);
}
// tRPC - React Query 자동 캐싱
function UserList() {
const { data, isLoading } = trpc.user.getAll.useQuery(
{ page: 1 },
{
staleTime: 5 * 60 * 1000, // 5분간 fresh
gcTime: 10 * 60 * 1000, // 10분간 캐시 유지
refetchOnWindowFocus: false, // 포커스 시 재요청 안 함
}
);
// React Query가 자동으로 캐싱 관리
return <div>{/* UI */}</div>;
}
언제 무엇을 선택해야 할까?
Server Actions를 선택하세요:
// Server Actions가 최적인 경우
// 1. 폼 제출과 데이터 변경
export function ContactForm() {
return (
<form action={submitContactForm}>
{/* Progressive Enhancement - JS 없이도 작동 */}
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">전송</button>
</form>
);
}
// 2. 간단한 CRUD 작업
async function TodoApp() {
const todos = await getTodos(); // Server Action
return (
<div>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onDelete={() => deleteTodo(todo.id)} // Server Action
/>
))}
</div>
);
}
// 3. Server Components와 긴밀한 통합
async function Dashboard() {
const data = await getDashboardData(); // 서버에서 직접 실행
return <DashboardUI data={data} />;
}
Server Actions 장점:
- API 엔드포인트 작성 불필요
- 자동 타입 안정성
- 작은 번들 사이즈
- Progressive Enhancement
Server Actions 단점:
- Next.js에만 종속
- 외부 클라이언트 접근 불가
- 실시간 기능 제한적
API Routes를 선택하세요:
// API Routes가 최적인 경우
// 1. Webhook 엔드포인트
export async function POST(request: Request) {
const signature = request.headers.get('stripe-signature');
const event = await stripe.webhooks.constructEvent(
await request.text(),
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
// Stripe 이벤트 처리
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data);
break;
}
return NextResponse.json({ received: true });
}
// 2. 파일 업로드/다운로드
export async function GET(request: Request) {
const file = await getFile(request.url);
return new Response(file, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="document.pdf"'
}
});
}
// 3. RESTful API (모바일 앱, 외부 서비스)
export async function GET(request: Request) {
// 표준 REST API - 모든 클라이언트가 사용 가능
const users = await prisma.user.findMany();
return NextResponse.json({
data: users,
meta: {
total: users.length,
timestamp: new Date().toISOString()
}
});
}
API Routes 장점:
- 표준 HTTP 프로토콜
- 외부 시스템 통합 용이
- 유연한 응답 형식
- SSE/WebSocket 지원
API Routes 단점:
- 타입 안정성 수동 관리
- 보일러플레이트 코드
- API 문서 별도 작성
tRPC를 선택하세요:
// tRPC가 최적인 경우
// 1. 복잡한 데이터 페칭 로직
export const dashboardRouter = t.router({
getStats: protectedProcedure
.input(z.object({
startDate: z.date(),
endDate: z.date(),
metrics: z.array(z.enum(['revenue', 'users', 'orders']))
}))
.query(async ({ input, ctx }) => {
// 복잡한 집계 로직
const stats = await calculateStats(input, ctx.session.user.orgId);
return stats;
})
});
// 2. 실시간 기능 (Subscriptions)
export const chatRouter = t.router({
onMessage: protectedProcedure
.subscription(async function* ({ ctx }) {
// WebSocket을 통한 실시간 메시지
for await (const message of messageStream) {
if (message.roomId === ctx.session.user.currentRoom) {
yield message;
}
}
})
});
// 3. 모노레포 구조 (백엔드와 프론트엔드 공유)
// packages/api/src/router.ts
export const appRouter = t.router({
user: userRouter,
post: postRouter,
comment: commentRouter
});
export type AppRouter = typeof appRouter; // 타입 공유
tRPC 장점:
- 완벽한 타입 안정성
- API 문서 자동 생성
- React Query 통합
- 실시간 구독 지원
tRPC 단점:
- 큰 번들 사이즈
- TypeScript 필수
- 학습 곡선
실전 의사결정 가이드
프로젝트 시작점
│
├─ 외부 API 제공 필요? (모바일 앱, 파트너사)
│ └─ API Routes
│
├─ 타입 안정성이 최우선?
│ ├─ 실시간 기능 필요?
│ │ └─ tRPC
│ └─ 단순 CRUD?
│ └─ Server Actions
│
├─ Progressive Enhancement 중요? (SEO, 접근성)
│ └─ Server Actions
│
├─ 번들 사이즈 최소화 필요?
│ └─ Server Actions
│
└─ 복잡한 데이터 관계와 쿼리?
└─ tRPC
하이브리드 접근법
실제로는 여러 기술을 함께 사용하는 것이 일반적입니다:
// 프로젝트 구조 예시
app/
├── _actions/ # Server Actions (폼, 간단한 mutations)
│ ├── user.ts
│ └── post.ts
├── api/ # API Routes (webhooks, 파일 처리)
│ ├── webhook/
│ └── upload/
└── trpc/ # tRPC (복잡한 쿼리, 실시간)
├── routers/
└── client.ts
결론
Server Actions, API Routes, tRPC는 각각 고유한 강점과 적합한 사용 사례를 가지고 있습니다.
핵심 요약:
- Server Actions: Next.js의 미래, 간단하고 효율적
- API Routes: 표준적이고 유연한 선택
- tRPC: 타입 안정성의 끝판왕
최선의 선택은 프로젝트의 요구사항, 팀의 경험, 그리고 미래 확장성을 고려하여 결정해야 합니다. 때로는 하나의 기술만 사용하기보다는 각 기술의 장점을 살려 조합하여 사용하는 것이 최적의 솔루션이 될 수 있습니다.
기억하세요: 도구는 목적을 달성하기 위한 수단일 뿐입니다. 가장 화려한 기술이 아닌, 프로젝트에 가장 적합한 기술을 선택하세요.