Next.js Server Actions 실전 가이드 — useActionState·useOptimistic으로 폼 제출부터 낙관적 업데이트까지
이 글은 Next.js App Router 기초와 React Server Components(RSC)의 개념을 알고 있는 프론트엔드 개발자를 대상으로 합니다. /api/todos를 만들고, fetch로 호출하고, 타입을 따로 맞추는 과정이 반복적으로 느껴지신다면, Server Actions가 그 고민을 해소해 줄 수 있습니다. RSC가 "서버에서 데이터를 읽어오는" 방식을 바꿨다면, Server Actions는 "클라이언트에서 서버로 데이터를 보내는" 방식 자체를 새롭게 정의합니다.
2024년 12월 React 19가 정식 출시되면서 Server Actions는 더 이상 실험적 기능이 아닙니다. useActionState, useFormStatus, useOptimistic이 React 코어에 공식 편입되었습니다. 단, 이 글에서 다루는 revalidatePath, revalidateTag 등의 캐시 API와 파일 시스템 라우팅은 Next.js 전용 기능입니다. Remix나 순수 React 19 환경에서는 동작하지 않는 내용이 포함되어 있습니다.
이 글을 읽고 나면 기존 API 라우트를 Server Action으로 전환하고, useActionState로 폼 상태를 관리하며, useOptimistic으로 낙관적 UI를 구현할 수 있게 됩니다. 핵심 동작 원리부터 인증 미들웨어 구성, 캐시 무효화 전략까지 실무에서 바로 쓸 수 있는 패턴을 한 번에 살펴봅니다.
핵심 개념
Server Actions란 무엇인가
Server Actions는 서버에서 실행되는 비동기 함수를 클라이언트 컴포넌트에서 마치 일반 함수처럼 호출할 수 있게 해주는 패턴입니다. "use server" 디렉티브를 붙이는 것만으로 선언할 수 있습니다.
// app/actions/todo.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function createTodo(formData: FormData) {
const title = formData.get("title") as string;
try {
await db.todo.create({ data: { title } });
} catch {
return { error: "할 일 생성에 실패했습니다." };
}
revalidatePath("/todos");
return { success: true };
}이 함수를 Server Component의 <form action={...}>에 바로 연결할 수 있습니다.
// app/todos/page.tsx
import { createTodo } from "@/actions/todo";
export default function TodoPage() {
return (
<form action={createTodo}>
<input name="title" />
<button type="submit">추가</button>
</form>
);
}내부 동작 원리 — "함수 호출처럼 보이지만 HTTP다"
핵심 인사이트:
"use server"함수는 빌드 타임에 Next.js가 고유 식별자를 부여하고 POST 엔드포인트로 자동 변환합니다. 클라이언트에서 함수를 호출하면Next-Action헤더를 포함한 HTTP POST 요청이 자동으로 생성되어 서버로 전송되고, 결과는 직렬화된 React 트리 형식(text/x-component)으로 반환됩니다.
개발자 눈에는 함수 호출이지만, 네트워크 탭을 열면 POST 요청이 날아가고 있습니다. 이 추상화 덕분에 API 라우트 파일 생성 → fetch 호출 → 타입 선언의 반복 작업이 사라집니다.
| 기존 방식 (API Route) | Server Actions |
|---|---|
/api/todos/route.ts 파일 생성 |
actions/todo.ts에 함수 작성 |
fetch('/api/todos', { method: 'POST' }) |
createTodo(formData) 직접 호출 |
| 요청/응답 타입 별도 선언 | 함수 시그니처에서 자동 추론 |
| CSRF 토큰 별도 관리 | Next.js 내장 처리 |
"use server" 파일 분리 패턴
파일 최상단에 "use server"를 선언하면 해당 파일의 모든 export가 Server Action으로 처리됩니다. 이 방식이 현재 업계 표준으로 자리잡았습니다.
// actions/post.ts — 파일 전체가 Server Actions
"use server";
export async function createPost(data: PostInput) { /* ... */ }
export async function updatePost(id: string, data: PostInput) { /* ... */ }
export async function deletePost(id: string) { /* ... */ }Progressive Enhancement: JavaScript가 비활성화되었거나 hydration이 완료되기 전에도 HTML form의 기본 동작이 보장됩니다. Next.js가 전통적인 HTML form POST로 처리하고 Server Action을 실행합니다.
실전 예시를 보기 전에 알아두어야 할 제약
Server Actions는 강력하지만 모든 상황에 적합하지는 않습니다. 아래 제약을 미리 파악해 두면 예시 코드의 맥락을 이해하는 데 도움이 됩니다.
- POST 전용: GET 요청은 지원하지 않으므로 데이터 조회는 Server Component에서 직접 처리하는 것이 적합합니다.
- 외부 클라이언트 호출 불가: 모바일 앱, Stripe 웹훅 등 외부 시스템과의 연동은 Route Handler를 사용합니다.
- 공개 HTTP 엔드포인트:
"use server"함수는 누구나 POST 요청으로 호출할 수 있습니다. 인증·권한 확인과 입력 검증이 필수입니다. - 직렬화 제약: 함수 인자와 반환값은 직렬화 가능한 타입이어야 합니다. 일반 class 인스턴스는 사용할 수 없습니다.
실전 적용
예시 1: 폼 유효성 검사 패턴 (기초)
가장 흔한 시나리오입니다. 문의 폼을 제출하고, 유효성 오류는 인라인으로 표시하며, 성공 메시지도 보여주는 패턴입니다.
서버 측 — 검증과 처리
// actions/contact.ts
"use server";
import { z } from "zod";
const schema = z.object({
email: z.string().email("올바른 이메일 형식이 아닙니다"),
message: z.string().min(10, "메시지는 10자 이상이어야 합니다"),
});
export async function sendContact(prevState: unknown, formData: FormData) {
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}
await sendEmail(result.data);
return { success: true };
}클라이언트 측 — useActionState로 상태 연결
// components/ContactForm.tsx
"use client";
import { useActionState } from "react";
import { sendContact } from "@/actions/contact";
export function ContactForm() {
const [state, action, isPending] = useActionState(sendContact, null);
return (
<form action={action}>
<input name="email" type="email" />
{state?.error?.email && (
<p className="text-red-500">{state.error.email}</p>
)}
<textarea name="message" />
{state?.error?.message && (
<p className="text-red-500">{state.error.message}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? "전송 중..." : "전송"}
</button>
{state?.success && <p className="text-green-500">전송 완료!</p>}
</form>
);
}| 코드 포인트 | 역할 |
|---|---|
useActionState(sendContact, null) |
action, 반환값(state), pending 상태를 한 번에 제공 |
prevState 파라미터 |
이전 action 결과를 받아 누적·비교 가능 |
form action={action} |
JS 없이도 동작하는 Progressive Enhancement 보장 |
disabled={isPending} |
중복 제출 방지 |
예시 2: 낙관적 업데이트 패턴 (중급)
좋아요 버튼처럼 즉각적인 반응이 중요한 UI에서는 서버 응답을 기다리지 않고 먼저 UI를 업데이트한 뒤, 실패하면 자동으로 롤백하는 패턴을 활용할 수 있습니다.
// components/LikeButton.tsx
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleLike } from "@/actions/like";
export function LikeButton({ postId, initialLiked, likeCount }) {
const [optimisticLike, addOptimisticLike] = useOptimistic(
{ liked: initialLiked, count: likeCount },
(currentState, _optimisticValue) => ({
liked: !currentState.liked,
count: currentState.count + (currentState.liked ? -1 : 1),
})
);
const [, startTransition] = useTransition();
const handleClick = () => {
startTransition(async () => {
addOptimisticLike(null); // 즉시 UI 반영
try {
await toggleLike(postId); // 서버 동기화
} catch {
// toggleLike 실패 시 startTransition 완료 후 자동 롤백됩니다.
// 실제 운영 코드에서는 사용자에게 에러 메시지(토스트 등)를 추가하는 것을 권장합니다.
}
});
};
return (
<button onClick={handleClick}>
{optimisticLike.liked ? "♥" : "♡"} {optimisticLike.count}
</button>
);
}
useOptimistic시그니처 이해: 업데이터 함수의 실제 시그니처는(currentState, optimisticValue) => newState입니다.addOptimisticLike(null)을 호출하면null이 두 번째 인자(_optimisticValue)로 전달됩니다. 이 예시에서는 현재 상태만으로 다음 상태를 결정할 수 있어 해당 인자를 사용하지 않습니다. 목록에 항목을 낙관적으로 추가하는 경우라면addOptimistic(newItem)처럼 실제 값을 넘기고 업데이터에서 활용하면 됩니다.
예시 3: 인증 미들웨어 계층 구성 패턴 (고급)
참고: 이 예시는
next-safe-action외부 라이브러리를 사용합니다. Server Actions 기초에 충분히 익숙해진 후 적용을 고려하시는 것을 권장합니다.pnpm add next-safe-action으로 설치할 수 있습니다.
규모가 커질수록 모든 Server Action에 개별적으로 인증 코드를 추가하기 어려워집니다. next-safe-action을 활용하면 미들웨어 계층을 한 번만 정의하고 재사용할 수 있습니다.
// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";
import { auth } from "@/lib/auth";
export const authAction = createSafeActionClient().use(async ({ next }) => {
const session = await auth();
if (!session) throw new Error("Unauthorized");
return next({ ctx: { user: session.user } });
});// actions/post.ts
"use server";
import { z } from "zod";
import { authAction } from "@/lib/safe-action";
// .schema()는 미들웨어 실행 전에 Zod 파싱을 먼저 수행합니다.
// 입력 검증이 실패하면 인증 미들웨어까지 진입하지 않아 불필요한 처리를 줄일 수 있습니다.
export const createPost = authAction
.schema(z.object({
title: z.string().min(1),
content: z.string(),
}))
.action(async ({ parsedInput, ctx }) => {
return db.post.create({
data: { ...parsedInput, authorId: ctx.user.id },
});
});authAction으로 감싼 모든 action은 자동으로 세션 검사를 거칩니다. ctx.user에는 타입 안전하게 사용자 정보가 주입됩니다.
예시 4: 캐시 무효화 전략 (중급)
데이터를 변경한 후 화면을 최신 상태로 갱신하려면 revalidatePath와 revalidateTag를 적절히 조합하면 됩니다.
// actions/product.ts
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function updateProduct(id: string, data: ProductInput) {
await db.product.update({ where: { id }, data });
revalidateTag(`product-${id}`); // 해당 상품 데이터만 정밀하게 무효화
revalidatePath("/products"); // 목록 페이지 전체 갱신
}| 함수 | 동작 범위 | 적합한 상황 |
|---|---|---|
revalidateTag(tag) |
해당 태그가 붙은 캐시만 | 특정 리소스 수정 |
revalidatePath(path) |
특정 경로 전체 | 목록·레이아웃 갱신 |
redirect(path) |
페이지 이동 + 갱신 | 제출 후 다른 페이지로 이동 |
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 코드 감소 | API 라우트 파일, fetch 호출, 타입 선언을 별도로 작성할 필요가 없습니다 |
| E2E 타입 안전성 | 직렬화 경계 없이 서버 함수와 클라이언트 사이 타입이 자동 추론됩니다 |
| CSRF 자동 방어 | POST 전용 + Same-Origin 요청에 대해 Origin/Host 헤더 검증을 내장 처리합니다 |
| Progressive Enhancement | JS 없이도 HTML form 기본 동작이 보장됩니다 |
| 캐시 통합 | revalidatePath / revalidateTag로 Next.js 캐시와 직접 연동됩니다 |
| 번들 크기 절감 | 서버에서 실행되므로 관련 로직이 클라이언트 번들에 포함되지 않습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 보안 함정 | "use server" 함수는 공개 HTTP 엔드포인트입니다 |
Zod 검증, 인증·권한 확인을 모든 action에 적용 |
| CSRF 보호 범위 | Origin/Host 헤더 검증은 Same-Origin 요청에만 적용됩니다 | 크로스 오리진 시나리오에서는 allowedOrigins 설정이 필요합니다 |
| 외부 클라이언트 호출 불가 | 모바일 앱, 서드파티 연동에는 사용할 수 없습니다 | 외부 연동은 Route Handler를 별도로 작성 |
| POST 전용 | GET 요청이 불가합니다 | 데이터 조회는 Server Component에서 직접 fetch |
| 직렬화 제약 | 일반 class 인스턴스는 인자·반환값으로 사용 불가합니다 | Date, Map, Set 등 지원 타입 범위 안에서 설계 |
| 디버깅 복잡성 | 네트워크 경계가 추상화되어 에러 추적이 어려울 수 있습니다 | 로깅 미들웨어 적용, 네트워크 탭 활용 |
Route Handler vs Server Actions: 데이터를 변경하는 내부 앱 로직에는 Server Actions, 외부에 공개하거나 GET이 필요한 엔드포인트(Stripe 웹훅, 모바일 앱 API 등)에는
app/api/하위의 Route Handler를 활용하는 것이 실무에서 통용되는 기준입니다.
실무에서 가장 흔한 실수
- 인증 없는 Server Action 노출 —
"use server"함수는 누구나 POST 요청으로 호출할 수 있습니다. 세션 확인과 권한 검사를 함수 내부에 반드시 포함시키거나,next-safe-action같은 미들웨어 계층으로 일괄 적용하는 것이 안전합니다. - 입력 검증 생략 — 클라이언트에서 넘어온 FormData나 인자값을 그대로 DB에 전달하는 경우가 있습니다. Zod 등으로 항상 서버 측 검증을 추가하는 것이 안전하며, 클라이언트 검증만으로는 충분하지 않습니다.
- Server Action으로 데이터 조회 시도 — POST 전용이므로 데이터 조회에는 적합하지 않습니다. 읽기 작업은 Server Component에서 직접
fetch하거나 ORM을 호출하는 방식을 활용할 수 있습니다.
마치며
Server Actions는 데이터 변경 로직을 별도의 API 레이어 없이 함수 하나로 표현하고, 타입 안전성과 Progressive Enhancement를 동시에 제공하는 Next.js 풀스택 개발의 핵심 패턴입니다. 모든 상황을 대체하는 만능 도구는 아니지만, 내부 앱의 데이터 변경 흐름에 있어서는 기존 API 라우트 방식보다 훨씬 간결하고 안전한 대안이 됩니다.
아래 순서로 시작해 보시면 전체 흐름을 자연스럽게 익힐 수 있습니다.
- 기존 API 라우트 하나를 Server Action으로 전환해 보기 — 가장 단순한 POST 엔드포인트를 골라
actions/디렉터리에"use server"파일로 옮겨보면 전체 흐름을 체감할 수 있습니다.pnpm add zod로 Zod를 미리 설치해 두면 검증 코드 작성도 수월합니다. useActionState로 폼을 처음부터 작성해 보기 — 새로운 폼을useActionState기반으로 작성하거나, 기존useState+ fetch 패턴의 폼을 리팩터링해 pending, error, success 흐름을 직접 익혀볼 수 있습니다.next-safe-action도입으로 인증 미들웨어 한 번에 적용하기 —pnpm add next-safe-action설치 후 인증이 필요한 action들을authAction클라이언트로 감싸면, 각 함수에 세션 확인 코드를 반복 작성하지 않아도 됩니다.
다음 글: Server Actions와 함께 쓰면 더욱 강력해지는 React 19의
use훅과 Suspense — 데이터 로딩 상태를 선언적으로 관리하는 새로운 패턴을 소개합니다.
참고 자료
- Server Actions and Mutations | Next.js 공식 문서 — Server Actions의 공식 API 레퍼런스
- useActionState | React 공식 문서 — 폼 상태 관리 훅 상세 설명
- useOptimistic | React 공식 문서 — 낙관적 업데이트 훅 및 시그니처 상세 설명
- React v19 릴리스 노트 — React 19에서 공식화된 기능 전체 목록
- next-safe-action 공식 문서 — 타입 안전 미들웨어 라이브러리 사용법
- How to Think About Security in Next.js | Vercel — CSRF 보호 범위와
allowedOrigins설정 심화 - Server Actions vs Route Handlers | MakerKit — 두 방식의 사용 시나리오 비교