[Next.js] Server Actions vs API Routes vs tRPC 비교




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: 타입 안정성의 끝판왕

최선의 선택은 프로젝트의 요구사항, 팀의 경험, 그리고 미래 확장성을 고려하여 결정해야 합니다. 때로는 하나의 기술만 사용하기보다는 각 기술의 장점을 살려 조합하여 사용하는 것이 최적의 솔루션이 될 수 있습니다.

기억하세요: 도구는 목적을 달성하기 위한 수단일 뿐입니다. 가장 화려한 기술이 아닌, 프로젝트에 가장 적합한 기술을 선택하세요.




댓글 남기기