Next.js App Router에서 Server Action과 Route Handler 제대로 나눠 쓰기 — mutation 처리와 에러 핸들링 전략
이 글은 Next.js Pages Router나 기존 React SPA를 주로 다루다가 App Router로 넘어온 중급 프론트엔드 개발자를 염두에 두고 썼습니다. 폼 하나 제출하는 데 app/api/ 아래에 파일 세 개가 생겨버린 경험이 있다면, 지금 겪고 있는 혼란이 여기서 정리될 겁니다. 저도 한동안 "폼 제출도 그냥 Route Handler로 짜면 되지 않나?" 하면서 Pages Router 시절 패턴을 그대로 가져갔다가, 불필요한 fetch URL 관리와 캐시 무효화 이슈로 꽤 고생했습니다.
이 글에서는 "사람이 UI에서 트리거하면 Server Action, 기계(외부 서비스)가 HTTP로 트리거하면 Route Handler" 라는 단 하나의 판단 기준을 중심으로, 각각 언제 어떻게 쓰는지를 실제 코드와 함께 풀어봅니다. React 19 + Next.js 15 기준의 최신 패턴도 함께 담았으니, 지금 프로젝트를 시작하거나 기존 코드를 정리하려는 분들께 참고가 됐으면 합니다.
useActionState, Server Component 같은 개념이 처음이라면, Next.js 공식 문서의 Getting Started 섹션을 먼저 살펴보시는 게 좋습니다. 이 글은 그 개념들을 어느 정도 알고 있지만 두 메커니즘을 언제 나눠 써야 할지 아직 헷갈리는 분들을 대상으로 합니다.
핵심 개념
Server Action과 Route Handler, 어떻게 다른가
둘 다 서버에서 실행되지만 설계 철학이 완전히 다릅니다.
Server Action은 React 컴포넌트에서 직접 호출하는 서버 함수입니다. "use server" 지시어 하나로 선언하면, 네트워크 레이어는 Next.js가 알아서 처리해줍니다. 개발자 입장에서는 그냥 함수 호출처럼 느껴지지만, 실제로는 서버에서 실행되고 revalidatePath로 캐시를 날린 뒤 UI까지 단일 라운드트립으로 갱신됩니다.
Route Handler는 app/api/... 경로에 위치하는 전통적인 HTTP 엔드포인트입니다. GET, POST, PUT, DELETE 같은 HTTP 메서드를 명시적으로 처리하고, 헤더·상태 코드·스트리밍 응답 등 HTTP 레이어를 직접 제어할 수 있습니다. React 앱 바깥의 모든 HTTP 클라이언트, 즉 웹훅이나 모바일 앱, 외부 서비스도 호출할 수 있다는 점이 Server Action과 결정적으로 다릅니다.
핵심 판단 기준: "사람이 UI에서 트리거하면 → Server Action, 기계(외부 서비스)가 HTTP로 트리거하면 → Route Handler"
이 기준 하나면 대부분의 상황에서 고민 없이 결정됩니다. 아래 의사결정 흐름을 참고해보시면 더 명확해집니다.
요청의 출처가 누구인가?
├── 사용자가 UI를 직접 조작한다 (폼 제출, 버튼 클릭)
│ └── → Server Action
└── 외부 시스템이 HTTP로 호출한다 (웹훅, 모바일 앱, 서드파티)
└── → Route Handler
├── GET + CDN 캐싱이 필요한 공개 데이터 → Route Handler
├── 스트리밍 응답이 필요하다 → Route Handler
└── OAuth 콜백 처리 → Route HandlerReact 19에서 달라진 것들
Next.js 15와 React 19가 맞물리면서 Server Action 주변 API가 꽤 정비됐습니다.
useFormState가 useActionState로 이름이 바뀌었고, 폼 제출 결과·에러 상태·pending 상태를 하나의 훅으로 통합 관리합니다. useFormStatus는 submit 버튼의 로딩 인디케이터 전용으로 역할이 명확하게 분리됐고, useOptimistic으로 낙관적 UI 업데이트 패턴도 공식 지원됩니다.
에러 처리 측면에서도 업계 표준이 자리잡히고 있습니다. 예상 가능한 에러는 throw 대신 반환값으로 처리하는 패턴입니다. 유효성 검사 실패나 권한 오류 같은 경우는 { success: false, error: "..." } 형태로 반환하고, 정말 예측 불가능한 오류(DB 크래시, 네트워크 장애)에만 throw를 씁니다.
실전 적용
예시 1: 게시글 작성 폼 — Server Action의 정석
사용자가 폼을 작성하고 제출하는 케이스입니다. 회원가입, 댓글 작성, 프로필 수정 모두 여기에 해당됩니다. 솔직히 이 케이스에서 Route Handler를 쓰고 싶은 충동이 생길 수 있는데, 그렇게 짜면 fetch URL 관리와 캐시 무효화가 갑자기 복잡해집니다.
한 가지 더 — App Router에서 권장하는 패턴은 page.tsx를 Server Component로 유지하고, 상호작용이 필요한 폼 부분만 별도 Client Component로 분리하는 것입니다. 처음엔 번거롭게 느껴질 수 있지만, Server Component에서 초기 데이터를 미리 패칭해 폼에 내려주는 구조가 가능해지고 번들 크기도 줄어듭니다.
// app/actions/post.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
type ActionState = {
success: boolean;
errors?: {
title?: string[];
content?: string[];
};
} | null;
const schema = z.object({
title: z.string().min(1, "제목을 입력해 주세요"),
content: z.string().min(10, "내용은 10자 이상 입력해 주세요"),
});
export async function createPost(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const parsed = schema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!parsed.success) {
return { success: false, errors: parsed.error.flatten().fieldErrors };
}
await db.post.create({ data: parsed.data });
revalidatePath("/posts");
return { success: true };
}// app/posts/new/_components/PostForm.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "@/actions/post";
export function PostForm() {
const [state, action, isPending] = useActionState(createPost, null);
return (
<form action={action}>
<input name="title" placeholder="제목" />
{state?.errors?.title && (
<p className="text-red-500 text-sm">{state.errors.title[0]}</p>
)}
<textarea name="content" placeholder="내용" />
{state?.errors?.content && (
<p className="text-red-500 text-sm">{state.errors.content[0]}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? "저장 중..." : "저장"}
</button>
</form>
);
}// app/posts/new/page.tsx — "use client" 없음, Server Component 유지
import { PostForm } from "./_components/PostForm";
export default function NewPostPage() {
return (
<main>
<h1>새 게시글 작성</h1>
<PostForm />
</main>
);
}| 코드 포인트 | 설명 |
|---|---|
ActionState 타입 명시 |
prevState와 반환 타입을 통일해 타입 불일치 오류 방지 |
schema.safeParse |
유효성 검사 실패 시 throw 대신 반환값으로 처리 |
revalidatePath("/posts") |
DB 저장 후 /posts 경로의 캐시를 즉시 무효화 |
useActionState |
React 19의 표준 훅, pending·에러·결과를 한 번에 관리 |
page.tsx Server Component 유지 |
Client Component를 분리해야 서버 렌더링 이점을 살릴 수 있음 |
예시 2: Stripe 웹훅 수신 — Route Handler가 필요한 순간
이건 Server Action으로 절대 처리할 수 없는 케이스입니다. Stripe 서버가 우리 서버에 직접 POST 요청을 보내는 건데, React 컴포넌트가 개입할 여지가 없습니다. 게다가 웹훅 서명 검증을 위해 raw body가 필요한데, 이건 Route Handler에서만 가능합니다. 개발 초기에 서명 검증을 "일단 나중에"로 미뤘다가 그대로 배포되는 경우가 생각보다 많습니다. 처음부터 넣어두는 게 훨씬 낫습니다.
// app/api/webhooks/stripe/route.ts
import { headers } from "next/headers";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text(); // 서명 검증을 위해 raw body 필요
const signature = (await headers()).get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "checkout.session.completed":
await handlePaymentSuccess(event.data.object);
break;
case "customer.subscription.deleted":
await handleSubscriptionCanceled(event.data.object);
break;
}
return new Response("OK", { status: 200 });
}| 코드 포인트 | 설명 |
|---|---|
request.text() |
raw body 유지 — JSON으로 파싱하면 서명 검증 실패 |
status: 400 |
HTTP 상태 코드 직접 제어 — Server Action에선 불가 |
stripe.webhooks.constructEvent |
Stripe 서명 검증, 반드시 구현해야 위변조 방어 가능 |
Response 직접 반환 |
Route Handler의 특성, HTTP 레이어 완전 제어 |
예시 3: AI 응답 스트리밍 — Route Handler의 또 다른 활용
AI 챗봇처럼 응답을 스트리밍으로 받아야 할 때도 Route Handler가 적합합니다. Server Action은 HTTP chunked transfer(청크 단위 스트리밍 전송)를 반환할 수 없어서 응답 전체가 완료돼야만 클라이언트가 받을 수 있습니다. React Server Components의 스트리밍 렌더링과는 다른 얘기입니다. 타이핑 효과처럼 텍스트를 점진적으로 보여주고 싶다면 Route Handler가 유일한 선택입니다.
// app/api/chat/route.ts
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = new ReadableStream({
async start(controller) {
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages,
stream: true,
});
for await (const chunk of response) {
const text = chunk.choices[0]?.delta?.content ?? "";
controller.enqueue(new TextEncoder().encode(text));
}
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream" },
});
}장단점 분석
장점
두 메커니즘 모두 각자의 강점이 뚜렷합니다.
| 항목 | Server Action | Route Handler |
|---|---|---|
| 타입 안전성 | 서버-클라이언트 간 직접 함수 호출처럼 타입 공유 | 별도 타입 정의 또는 OpenAPI 스펙 필요 |
| 라운드트립 | 뮤테이션 + 캐시 무효화 + UI 갱신이 한 번에 | 기본적으로 요청-응답 각각 처리 |
| CSRF 보호 | Same-Origin 검증 자동 처리 | 직접 구현 또는 미들웨어 필요 |
| 클라이언트 호환성 | React 컴포넌트 전용 | 모든 HTTP 클라이언트에서 호출 가능 |
| HTTP 제어 | 상태 코드·헤더 직접 제어 불가 | 완전한 HTTP 레이어 제어 |
| Progressive Enhancement | JS 없이도 HTML form으로 동작 | 클라이언트 JS 필수 |
단점 및 주의사항
실무에서 이 부분 때문에 야근한 경험이 있습니다. 특히 Server Action 보안은 "어차피 서버에서 실행되니까 괜찮겠지"라고 넘기기 쉬운데 꼼꼼히 챙겨야 합니다.
| 항목 | 설명 | 대응 방안 |
|---|---|---|
bind 인자 클라이언트 노출 |
bind로 바인딩된 클로저 인자가 직렬화돼 클라이언트 번들에 포함될 수 있음 |
민감한 값(사용자 ID, 권한 정보)은 bind 인자로 넘기지 않고 서버에서 직접 세션 조회 |
| Server Action은 GET 불가 | 뮤테이션 전용, 데이터 조회에 사용 불가 | 조회는 Server Component에서 직접 fetch 또는 Route Handler |
| Route Handler의 이중 라운드트립 | 내부 뮤테이션에 쓰면 불필요한 왕복이 발생 | 내부 UI 뮤테이션은 Server Action으로 교체 |
| 스트리밍 불가 (Server Action) | HTTP chunked transfer를 반환할 수 없어 전체 응답 완료 후 전달 | AI 스트리밍, SSE 등은 Route Handler 사용 |
| 웹훅 서명 검증 누락 | 검증 없이 받으면 위변조 공격에 취약 | Stripe·GitHub 등 각 서비스의 서명 검증 로직 필수 구현 |
용어 보충 —
revalidatePath: Next.js가 특정 경로의 캐시를 즉시 무효화하는 함수. Server Actions, Route Handlers, Server Components 모두에서 호출 가능하며, 호출 직후 해당 경로를 다시 렌더링할 때 새 데이터를 가져오게 됩니다.
용어 보충 —
Progressive Enhancement: JavaScript가 비활성화된 환경에서도 HTML의 기본 form 동작으로 Server Action이 실행됩니다. 접근성과 견고성 측면에서 Server Action의 큰 장점입니다.
실무에서 가장 흔한 실수
- 모든 뮤테이션을 Route Handler로 처리하는 것 — Pages Router 시절 습관이 남아있는 경우 자주 발생합니다. App Router에서 UI 뮤테이션은 Server Action이 훨씬 간결하고 타입 안전합니다.
- Server Action에서 예상 가능한 에러를
throw하는 것 — 유효성 검사 실패나 "이미 존재하는 사용자" 같은 에러를throw하면 Error Boundary가 받아서 전체 UI가 에러 화면으로 바뀝니다.{ success: false, errors: ... }형태로 반환하는 패턴을 권장합니다. - 웹훅 엔드포인트에 서명 검증을 빠뜨리는 것 — 개발 초기에 "일단 동작하는지 보자"며 넘어갔다가 그대로 프로덕션에 배포되는 경우가 생각보다 많습니다. 점검 방법: IDE에서
app/api/webhooks아래 파일을 열고constructEvent또는 각 서비스의 서명 검증 함수 호출이 있는지 검색해보시면 즉시 목록이 나옵니다.
마치며
두 메커니즘을 구분해서 쓰다 보면 어느 순간부터 코드가 달라지는 게 느껴집니다. 불필요한 fetch 보일러플레이트가 줄고, 타입 에러가 미리 잡히고, 캐시 무효화 타이밍이 예측 가능해집니다. Server Action은 "사용자가 UI에서 하는 모든 상태 변경"에, Route Handler는 "외부 세계와의 HTTP 계약"에 — 이 구분이 App Router 아키텍처의 설계 의도와 가장 잘 맞습니다.
지금 바로 시작해볼 수 있는 세 가지:
- 현재 프로젝트의
app/api폴더를 열어보시고, 각 Route Handler가 "외부 서비스의 요청을 받는지" 아니면 "자기 앱의 UI에서만 호출되는지" 분류해보시면 좋습니다. 후자라면 Server Action으로 교체할 후보입니다. - 교체 대상으로 선정된 Route Handler 하나를 골라
app/actions/디렉토리에"use server"파일로 옮기고, 클라이언트 컴포넌트에서useActionState로 연결해보시면 코드가 얼마나 단순해지는지 바로 체감됩니다. - Server Action 안에서 유효성 검사 실패를
throw로 처리하는 곳이 있다면 반환 패턴으로 바꿔보시면 좋습니다. IDE에서app/actions디렉토리를 대상으로throw를 검색하면 즉시 목록이 나옵니다.{ success: false, errors: ... }반환 패턴으로 바꾸면 Error Boundary가 불필요하게 발동하는 문제가 사라집니다.
참고 자료
- Server Actions vs Route Handlers: When to Use Each in Next.js | MakerKit
- Next.js 15 Server Actions vs Route Handlers (I Got This Wrong for 3 Months) | DEV Community
- Route Handler vs Server Action in Production for Next.js | Wisp CMS
- Building APIs with Next.js | Next.js 공식 블로그
- Data Fetching: Server Actions and Mutations | Next.js 공식 문서
- Getting Started: Error Handling | Next.js 공식 문서
- Next.js Server Actions Error Handling: A Production-Ready Guide | Medium
- next-safe-action 공식 문서
- Next.js Server Actions: The Complete Guide (2026) | MakerKit
- Getting Started: Revalidating | Next.js 공식 문서