Next.js의 Server Actions와 폼 처리를 다룹니다. next/form, useActionState, 보안, revalidation 통합, after() API, 고급 뮤테이션 패턴을 살펴봅니다.
7장까지 데이터를 가져오고 표시하는 방법을 다루었습니다. 이번 장에서는 반대 방향, 즉 사용자의 입력을 받아 서버에서 데이터를 변경하는 Server Actions와 폼 처리를 살펴봅니다. React 19 시리즈에서 다룬 Server Actions의 기본 개념 위에, Next.js가 추가하는 기능과 고급 패턴에 집중합니다.
Server Actions는 "use server" 디렉티브로 선언한 비동기 함수로, 클라이언트에서 호출하면 서버에서 실행됩니다. Next.js는 이를 자동으로 POST 엔드포인트로 변환합니다.
'use server';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.posts.create({
data: { title, content },
});
revalidatePath('/posts');
}import { createPost } from '@/actions/posts';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" type="text" required />
<textarea name="content" required />
<button type="submit">게시</button>
</form>
);
}이 코드는 JavaScript가 비활성화된 환경에서도 동작합니다. 이것이 Server Actions의 핵심 가치 중 하나인 점진적 향상(Progressive Enhancement)입니다.
Next.js는 next/form에서 향상된 Form 컴포넌트를 제공합니다. HTML <form>의 기능에 클라이언트 사이드 탐색, prefetching, 점진적 향상을 추가합니다.
import Form from 'next/form';
export default function SearchPage() {
return (
<Form action="/search">
<input name="q" type="text" placeholder="검색어 입력" />
<button type="submit">검색</button>
</Form>
);
}| 기능 | HTML <form> | next/form <Form> |
|---|---|---|
| 제출 시 동작 | 전체 페이지 리로드 | 클라이언트 사이드 탐색 |
| 레이아웃 유지 | 리셋됨 | 유지됨 |
| 로딩 UI | loading.tsx 미적용 | loading.tsx 적용 |
| Prefetch | 없음 | 자동 prefetch |
| JS 비활성 시 | 정상 동작 | 전체 리로드로 fallback |
import Form from 'next/form';
export default function SearchForm() {
return (
<Form
action="/search"
className="flex gap-2"
>
<input
name="q"
type="text"
placeholder="검색어를 입력하세요"
className="flex-1 rounded-lg border px-4 py-2"
/>
<select name="category" className="rounded-lg border px-4 py-2">
<option value="">전체</option>
<option value="tech">기술</option>
<option value="design">디자인</option>
</select>
<button
type="submit"
className="rounded-lg bg-blue-600 px-6 py-2 text-white"
>
검색
</button>
</Form>
);
}이 폼이 제출되면 /search?q=입력값&category=tech 같은 URL로 클라이언트 사이드 탐색이 이루어집니다. 전체 페이지가 리로드되지 않고, 레이아웃이 유지되며, loading.tsx가 적용됩니다.
React 19의 useActionState 훅은 Server Action의 실행 상태를 관리합니다. 로딩, 에러, 성공 상태를 선언적으로 처리할 수 있습니다.
'use server';
import { db } from '@/lib/db';
import { redirect } from 'next/navigation';
interface LoginState {
error?: string;
success?: boolean;
}
export async function login(
prevState: LoginState,
formData: FormData
): Promise<LoginState> {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
if (!email || !password) {
return { error: '이메일과 비밀번호를 모두 입력하세요.' };
}
const user = await db.users.findUnique({ where: { email } });
if (!user || !await verifyPassword(password, user.passwordHash)) {
return { error: '이메일 또는 비밀번호가 올바르지 않습니다.' };
}
await createSession(user.id);
redirect('/dashboard');
}'use client';
import { useActionState } from 'react';
import { login } from '@/actions/auth';
export function LoginForm() {
const [state, formAction, isPending] = useActionState(login, {});
return (
<form action={formAction} className="space-y-4">
{state.error && (
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-600
dark:bg-red-900/20 dark:text-red-400">
{state.error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium">
이메일
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 w-full rounded-lg border px-4 py-2"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
비밀번호
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 w-full rounded-lg border px-4 py-2"
/>
</div>
<button
type="submit"
disabled={isPending}
className="w-full rounded-lg bg-blue-600 py-2 text-white
disabled:opacity-50"
>
{isPending ? '로그인 중...' : '로그인'}
</button>
</form>
);
}useActionState의 세 번째 반환값 isPending은 액션이 실행 중인지를 나타냅니다. 이를 활용하여 제출 버튼을 비활성화하거나 로딩 스피너를 표시할 수 있습니다.
useFormStatus는 부모 <form>의 제출 상태를 자식 컴포넌트에서 접근할 수 있게 합니다.
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="rounded-lg bg-blue-600 px-6 py-2 text-white
disabled:opacity-50"
>
{pending ? '처리 중...' : children}
</button>
);
}import { createPost } from '@/actions/posts';
import { SubmitButton } from '@/components/SubmitButton';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" type="text" required />
<textarea name="content" required />
<SubmitButton>게시하기</SubmitButton>
</form>
);
}useFormStatus는 반드시 <form> 요소의 자식 컴포넌트에서 호출해야 합니다. 같은 컴포넌트에서 <form>을 렌더링하면서 useFormStatus를 호출하면 동작하지 않습니다.
useOptimistic는 서버 응답을 기다리지 않고 UI를 먼저 업데이트하는 낙관적 업데이트 패턴을 구현합니다.
'use client';
import { useOptimistic } from 'react';
import { toggleTodo } from '@/actions/todos';
interface Todo {
id: string;
text: string;
completed: boolean;
}
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state: Todo[], updatedId: string) =>
state.map(todo =>
todo.id === updatedId
? { ...todo, completed: !todo.completed }
: todo
)
);
return (
<ul className="space-y-2">
{optimisticTodos.map(todo => (
<li key={todo.id} className="flex items-center gap-3">
<form
action={async () => {
addOptimistic(todo.id); // UI 즉시 업데이트
await toggleTodo(todo.id); // 서버 요청
}}
>
<button type="submit">
<span
className={todo.completed
? 'text-gray-400 line-through'
: 'text-gray-900 dark:text-gray-100'}
>
{todo.text}
</span>
</button>
</form>
</li>
))}
</ul>
);
}체크박스를 클릭하면 UI가 즉시 반영되고, 백그라운드에서 서버 요청이 진행됩니다. 서버 요청이 실패하면 자동으로 이전 상태로 롤백됩니다.
Next.js는 기본적으로 Server Action 요청의 Origin 헤더를 확인합니다. 호스트와 일치하지 않는 요청은 거부하여 CSRF 공격을 방지합니다.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
serverActions: {
allowedOrigins: [
'my-app.com',
'staging.my-app.com',
],
},
},
};
export default nextConfig;서버 컴포넌트 안에서 인라인으로 정의한 Server Action이 외부 변수를 참조하면, 해당 변수는 클라이언트로 전송될 때 자동으로 암호화됩니다.
export default async function UserPage() {
const userId = await getCurrentUserId(); // 민감한 서버 데이터
async function deleteAccount() {
'use server';
// userId는 클라이언트에 전송될 때 암호화됨
await db.users.delete({ where: { id: userId } });
}
return (
<form action={deleteAccount}>
<button type="submit">계정 삭제</button>
</form>
);
}암호화된 클로저가 보안을 제공하지만, Server Action 내에서 항상 사용자 권한을 직접 확인하는 것이 최선의 관행입니다. 클라이언트에서 전달되는 모든 데이터는 잠재적으로 조작될 수 있습니다.
빌드 시점에 사용되지 않는 Server Action은 제거됩니다. 사용되는 Action에는 고유한 ID가 부여되어, 클라이언트는 ID로만 Action을 참조합니다. 함수 이름이나 구현 세부사항이 클라이언트에 노출되지 않습니다.
Server Action의 입력은 항상 서버 측에서 유효성을 검증해야 합니다.
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const CreatePostSchema = z.object({
title: z.string().min(1, '제목을 입력하세요').max(200, '제목은 200자 이내'),
content: z.string().min(10, '내용을 10자 이상 입력하세요'),
categoryId: z.string().uuid('올바른 카테고리를 선택하세요'),
});
interface CreatePostState {
errors?: {
title?: string[];
content?: string[];
categoryId?: string[];
};
message?: string;
}
export async function createPost(
prevState: CreatePostState,
formData: FormData
): Promise<CreatePostState> {
const validatedFields = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
categoryId: formData.get('categoryId'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
try {
await db.posts.create({
data: validatedFields.data,
});
} catch {
return { message: '게시물 생성에 실패했습니다.' };
}
revalidatePath('/posts');
return { message: '게시물이 생성되었습니다.' };
}Server Action에서 데이터를 변경한 후, 관련 캐시를 갱신하는 패턴입니다.
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function updatePost(id: string, formData: FormData) {
await db.posts.update({
where: { id },
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
},
});
// 태그 기반 무효화 (권장)
revalidateTag(`post-${id}`);
revalidateTag('posts');
// 경로 기반 무효화
revalidatePath(`/posts/${id}`);
revalidatePath('/posts');
}'use server';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
const post = await db.posts.create({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
},
});
revalidateTag('posts');
redirect(`/posts/${post.id}`);
}redirect()는 내부적으로 에러를 throw하여 동작하므로, try/catch 블록 바깥에서 호출해야 합니다. try 블록 안에서 호출하면 catch에서 잡혀 리디렉션이 되지 않습니다.
Next.js 15.1에서 안정화된 after() API는 응답이 클라이언트에 전송된 이후에 실행할 작업을 예약합니다. 로깅, 분석, 외부 시스템 동기화 등 사용자 응답을 지연시키지 않아야 하는 작업에 적합합니다.
'use server';
import { after } from 'next/server';
import { revalidateTag } from 'next/cache';
export async function createOrder(formData: FormData) {
const order = await db.orders.create({
data: {
productId: formData.get('productId') as string,
quantity: Number(formData.get('quantity')),
},
});
revalidateTag('orders');
// 응답 전송 후 실행되는 작업들
after(async () => {
// 주문 확인 이메일 발송
await sendOrderConfirmationEmail(order);
// 재고 동기화
await syncInventory(order.productId);
// 분석 이벤트 전송
await analytics.track('order_created', {
orderId: order.id,
amount: order.totalAmount,
});
});
return { success: true, orderId: order.id };
}after()는 Server Actions뿐만 아니라 Route Handlers, Middleware, 서버 컴포넌트에서도 사용할 수 있습니다.
after() 콜백 내에서 에러가 발생해도 클라이언트에는 영향을 주지 않습니다. 다만, 에러 로깅은 서버 로그에 기록되므로 모니터링 시스템에서 확인할 수 있습니다.
지금까지 다룬 내용을 종합하여, 게시물 CRUD의 완전한 구현을 살펴봅니다.
'use server';
import { z } from 'zod';
import { after } from 'next/server';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
const PostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
published: z.boolean().default(false),
});
interface ActionState {
errors?: Record<string, string[]>;
message?: string;
}
export async function createPost(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// 1. 권한 확인
const session = await getSession();
if (!session) {
return { message: '로그인이 필요합니다.' };
}
// 2. 입력 유효성 검증
const validatedFields = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on',
});
if (!validatedFields.success) {
return { errors: validatedFields.error.flatten().fieldErrors };
}
// 3. 데이터 생성
let post;
try {
post = await db.posts.create({
data: {
...validatedFields.data,
authorId: session.userId,
},
});
} catch {
return { message: '게시물 생성에 실패했습니다.' };
}
// 4. 캐시 무효화
revalidateTag('posts');
// 5. 응답 후 부수 효과
after(async () => {
await notifySubscribers(post);
await updateSearchIndex(post);
});
// 6. 리디렉션
redirect(`/posts/${post.id}`);
}
export async function updatePost(
id: string,
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const session = await getSession();
if (!session) {
return { message: '로그인이 필요합니다.' };
}
// 작성자 본인 확인
const existingPost = await db.posts.findUnique({ where: { id } });
if (existingPost?.authorId !== session.userId) {
return { message: '수정 권한이 없습니다.' };
}
const validatedFields = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on',
});
if (!validatedFields.success) {
return { errors: validatedFields.error.flatten().fieldErrors };
}
try {
await db.posts.update({
where: { id },
data: validatedFields.data,
});
} catch {
return { message: '게시물 수정에 실패했습니다.' };
}
revalidateTag(`post-${id}`);
revalidateTag('posts');
redirect(`/posts/${id}`);
}
export async function deletePost(id: string): Promise<ActionState> {
const session = await getSession();
if (!session) {
return { message: '로그인이 필요합니다.' };
}
const post = await db.posts.findUnique({ where: { id } });
if (post?.authorId !== session.userId) {
return { message: '삭제 권한이 없습니다.' };
}
try {
await db.posts.delete({ where: { id } });
} catch {
return { message: '게시물 삭제에 실패했습니다.' };
}
revalidateTag(`post-${id}`);
revalidateTag('posts');
after(async () => {
await removeFromSearchIndex(id);
});
redirect('/posts');
}'use client';
import { useActionState } from 'react';
import { createPost } from '@/actions/posts';
import { SubmitButton } from '@/components/SubmitButton';
export function PostForm() {
const [state, formAction] = useActionState(createPost, {});
return (
<form action={formAction} className="space-y-6">
{state.message && (
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-600
dark:bg-red-900/20 dark:text-red-400">
{state.message}
</div>
)}
<div>
<label htmlFor="title" className="block text-sm font-medium">
제목
</label>
<input
id="title"
name="title"
type="text"
required
className="mt-1 w-full rounded-lg border px-4 py-2"
/>
{state.errors?.title && (
<p className="mt-1 text-sm text-red-600">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium">
내용
</label>
<textarea
id="content"
name="content"
rows={10}
required
className="mt-1 w-full rounded-lg border px-4 py-2"
/>
{state.errors?.content && (
<p className="mt-1 text-sm text-red-600">
{state.errors.content[0]}
</p>
)}
</div>
<div className="flex items-center gap-2">
<input id="published" name="published" type="checkbox" />
<label htmlFor="published" className="text-sm">
즉시 게시
</label>
</div>
<SubmitButton>게시하기</SubmitButton>
</form>
);
}Server Actions를 프로덕션에 배포하기 전에 확인해야 할 보안 체크리스트입니다.
serverActions.allowedOrigins를 설정합니다.'use server';
import { z } from 'zod';
import { getSession } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
export async function sensitiveAction(formData: FormData) {
// 1. 속도 제한
const rateLimitResult = await rateLimit('sensitive-action');
if (!rateLimitResult.success) {
return { error: '너무 많은 요청입니다. 잠시 후 다시 시도하세요.' };
}
// 2. 인증
const session = await getSession();
if (!session) {
return { error: '로그인이 필요합니다.' };
}
// 3. 입력 검증
const schema = z.object({ /* ... */ });
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
// 4. 권한 확인
const hasPermission = await checkPermission(session.userId, 'write');
if (!hasPermission) {
return { error: '권한이 없습니다.' };
}
// 5. 비즈니스 로직
try {
await performAction(result.data);
return { success: true };
} catch {
// 내부 에러 세부사항 미노출
return { error: '작업을 수행할 수 없습니다.' };
}
}"use server" 디렉티브로 선언하며, Next.js가 자동으로 POST 엔드포인트를 생성합니다. JavaScript 비활성 환경에서도 점진적 향상으로 동작합니다.next/form의 Form 컴포넌트는 클라이언트 사이드 탐색, prefetching, 레이아웃 유지를 제공합니다.useActionState로 액션의 상태를 관리하고, useFormStatus로 폼 제출 상태를 자식 컴포넌트에서 접근하며, useOptimistic로 낙관적 업데이트를 구현합니다.after() API로 응답 전송 후 이메일, 분석, 동기화 등의 부수 효과를 실행할 수 있습니다.다음 장에서는 Next.js의 미들웨어와 인증 패턴을 다룹니다. Server Actions와 미들웨어를 결합하여 인증 흐름을 완성합니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
Next.js의 차세대 번들러 Turbopack을 다룹니다. Rust 기반 아키텍처, 성능 벤치마크, FS 캐싱, Webpack 마이그레이션, 설정 방법을 살펴봅니다.
Next.js의 스트리밍 SSR 동작 원리를 살펴봅니다. loading.tsx, Suspense 경계, 스켈레톤 설계, 프로그레시브 렌더링 전략과 성능 지표 영향을 다룹니다.
Next.js의 미들웨어 진화를 다룹니다. middleware.ts에서 proxy.ts로의 전환, 인증, i18n, A/B 테스팅, 속도 제한 등 고급 패턴을 살펴봅니다.