Ch 06. Server Actions vs Route Handlers — 무엇을 쓸까
같은 듯 다른 둘
Server Actions와 Route Handlers는 둘 다 서버에서 실행되는 코드입니다. 데이터베이스에 접근하고, 환경변수를 사용하고, 클라이언트에서 호출됩니다. 그런데 왜 두 가지가 있을까요.
설계 의도가 다르기 때문입니다. Server Actions는 Next.js 앱 내부에서 사용하도록 설계되었습니다. Route Handlers는 외부에서 접근할 수 있는 HTTP API를 만들기 위해 설계되었습니다.
비교 표
| 항목 | Server Actions | Route Handlers |
|---|---|---|
| 호출 방법 | 함수 직접 호출, form action | HTTP 요청 (GET, POST 등) |
| 반환 형태 | 직렬화 가능한 값 | NextResponse (JSON, HTML 등) |
| 외부 접근 | 불가 (Next.js 앱 내부 전용) | 가능 (URL로 직접 접근) |
| TypeScript 타입 | 함수 인자/반환 타입 그대로 | 요청/응답 파싱 필요 |
| 폼 제출 | 자연스러움 (action prop) | fetch + event.preventDefault() 필요 |
| 캐시 제어 | revalidateTag, revalidatePath |
HTTP 캐시 헤더 |
| 인증 | 세션/쿠키 직접 접근 | 헤더/쿠키 직접 파싱 |
Server Actions — Next.js 앱 내부 폼/뮤테이션
게시글 작성, 댓글 달기, 좋아요 누르기, 설정 변경처럼 Next.js 앱 내부에서 발생하는 데이터 변경 작업에는 Server Actions를 씁니다.
// 파일: app/posts/[slug]/components/CommentForm.tsx
'use client';
import { useActionState } from 'react';
import { addComment } from '@/actions/comment';
const initialState = { error: '', success: false };
export default function CommentForm({ postId }: { postId: number }) {
const [state, formAction, isPending] = useActionState(
addComment.bind(null, postId),
initialState
);
return (
<form action={formAction}>
<textarea name="content" placeholder="댓글을 입력하세요" required />
<button type="submit" disabled={isPending}>
{isPending ? '작성 중...' : '댓글 달기'}
</button>
{state.error && <p style={{ color: 'red' }}>{state.error}</p>}
</form>
);
}
```text
### Route Handlers — 외부 클라이언트, 모바일 앱, 웹훅
Next.js 앱 밖에서 접근해야 한다면 Route Handlers를 씁니다.
```ts
// 파일: app/api/posts/route.ts
// 모바일 앱이나 외부 서비스가 호출하는 API
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const posts = await fetchPostsFromDB();
return NextResponse.json(posts);
}
```text
모바일 앱에서는 `https://myapp.com/api/posts`를 직접 호출합니다. Server Actions는 이런 방식으로 외부에서 호출할 수 없습니다.
### 실무에서 함께 쓰는 패턴
Next.js 앱을 만들 때 두 가지를 함께 쓰는 경우가 많습니다.
**웹 앱의 폼 → Server Actions**
```tsx
// 웹 앱 내부의 게시글 작성 폼
<form action={createPost}>
<input name="title" />
<button type="submit">작성</button>
</form>
```text
**모바일 앱 또는 외부 서비스 → Route Handlers**
```ts
// 모바일 앱에서 호출
const response = await fetch('https://api.myapp.com/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ title, content }),
});
```text
**같은 비즈니스 로직을 공유하는 방법**
```ts
// 파일: lib/posts.ts
// 핵심 비즈니스 로직은 별도 파일에
export async function createPostInDB(title: string, content: string) {
// 데이터베이스 저장 로직
return { id: 1, title, content };
}
```text
```ts
// 파일: app/actions/post.ts
// Server Action에서 사용
'use server';
import { createPostInDB } from '@/lib/posts';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
return createPostInDB(title, content);
}
```text
```ts
// 파일: app/api/posts/route.ts
// Route Handler에서 동일한 로직 사용
import { createPostInDB } from '@/lib/posts';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const { title, content } = await request.json();
const post = await createPostInDB(title, content);
return NextResponse.json(post, { status: 201 });
}
핵심 로직을 lib/ 폴더에 분리하면, Server Actions와 Route Handlers 모두에서 같은 코드를 재사용할 수 있습니다.
다음 챕터에서는
다음 챕터에서는 실제 데이터베이스와 연결하는 방법을 배웁니다. Prisma ORM으로 스키마를 정의하고, 마이그레이션을 실행하고, Server Component에서 데이터를 조회합니다.