Server Actions의 동작 원리, 폼 처리 패턴, 데이터 뮤테이션, 에러 핸들링, 보안 고려사항을 실전 코드와 함께 다룹니다.
2장에서 Server Components가 데이터를 읽는 방법을 살펴보았습니다. 이번 장에서는 데이터를 쓰는(변경하는) 방법인 Server Actions를 다룹니다. Server Actions는 클라이언트에서 호출하면 서버에서 실행되는 비동기 함수로, 폼 제출, 데이터 변경, 파일 업로드 등의 서버 측 로직을 안전하고 간결하게 처리합니다.
Server Actions(공식 명칭: Server Functions)는 'use server' 디렉티브로 표시된 비동기 함수입니다. 이 함수는 서버에서만 실행되지만, 클라이언트에서 일반 함수처럼 호출할 수 있습니다.
'use server';
export async function createArticle(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const article = await db.articles.create({
data: { title, content, authorId: getCurrentUserId() },
});
revalidatePath('/articles');
return { success: true, id: article.id };
}'use server'와 'use client'의 비대칭성을 이해하는 것이 중요합니다.
'use client'는 컴포넌트 모듈의 진입점을 표시합니다 (파일 최상단에 선언).'use server'는 함수의 서버 실행을 표시합니다 (파일 최상단 또는 개별 함수에 선언).'use server'는 Server Component를 만드는 디렉티브가 아닙니다. Server Component에는 별도의 디렉티브가 필요 없습니다 (기본값).
클라이언트에서 Server Action을 호출하면 내부적으로 어떤 일이 발생하는지 살펴봅시다.
번들러는 'use server'로 표시된 함수를 발견하면 다음을 수행합니다.
// 서버 번들: 실제 구현이 포함됨
async function createArticle(formData: FormData) {
// ... 실제 로직
}
// 클라이언트 번들: 참조만 포함됨
const createArticle = {
$$typeof: Symbol.for('react.server.reference'),
$$id: 'actions.ts#createArticle',
};클라이언트에서 이 참조를 "호출"하면 다음이 발생합니다.
가장 권장되는 패턴입니다. 파일 최상단에 'use server'를 선언하면 해당 파일의 모든 export가 Server Action이 됩니다.
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createArticle(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.articles.create({
data: { title, content },
});
revalidatePath('/articles');
redirect('/articles');
}
export async function deleteArticle(id: string) {
await db.articles.delete({ where: { id } });
revalidatePath('/articles');
}
export async function updateArticle(id: string, formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.articles.update({
where: { id },
data: { title, content },
});
revalidatePath(`/articles/${id}`);
}Server Component 안에서 개별 함수에 'use server'를 선언할 수 있습니다. 이 방식은 해당 컴포넌트의 클로저에 접근할 수 있다는 장점이 있습니다.
async function ArticlePage({ params }: { params: { id: string } }) {
const article = await getArticle(params.id);
async function publishArticle() {
'use server';
// params.id를 클로저로 캡처
await db.articles.update({
where: { id: params.id },
data: { published: true, publishedAt: new Date() },
});
revalidatePath(`/articles/${params.id}`);
}
return (
<article>
<h1>{article.title}</h1>
<div>{article.content}</div>
{!article.published && (
<form action={publishArticle}>
<button type="submit">게시하기</button>
</form>
)}
</article>
);
}인라인 Server Action에서 클로저로 캡처된 변수는 직렬화되어 클라이언트로 전송된 후 서버 호출 시 다시 서버로 전달됩니다. 민감한 데이터(비밀번호, 토큰 등)를 클로저로 캡처하지 않도록 주의해야 합니다. Next.js에서는 taintUniqueValue를 사용하여 민감한 값의 클라이언트 전송을 방지할 수 있습니다.
Server Actions의 가장 자연스러운 사용처는 HTML 폼입니다. React 19에서는 <form>의 action 속성에 함수를 직접 전달할 수 있습니다.
// actions/contact.ts
'use server';
export async function submitContactForm(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
// 유효성 검사
if (!name || !email || !message) {
return { error: '모든 필드를 입력해주세요.' };
}
// 이메일 전송
await sendEmail({
to: 'admin@example.com',
subject: `문의: ${name}`,
body: `${name} (${email}):\n\n${message}`,
});
return { success: true };
}import { submitContactForm } from '@/actions/contact';
function ContactPage() {
return (
<form action={submitContactForm}>
<label>
이름
<input name="name" required />
</label>
<label>
이메일
<input name="email" type="email" required />
</label>
<label>
메시지
<textarea name="message" required />
</label>
<button type="submit">전송</button>
</form>
);
}Server Actions를 <form action>에 사용하면 Progressive Enhancement가 자동으로 적용됩니다. JavaScript가 로드되기 전에도 폼이 동작합니다.
// Client Component에서 useActionState와 함께 사용
'use client';
import { useActionState } from 'react';
import { submitContactForm } from '@/actions/contact';
function ContactForm() {
const [state, formAction, isPending] = useActionState(
submitContactForm,
{ error: null, success: false }
);
return (
<form action={formAction}>
<input name="name" required disabled={isPending} />
<input name="email" type="email" required disabled={isPending} />
<textarea name="message" required disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? '전송 중...' : '전송'}
</button>
{state.error && <p className="text-red-500">{state.error}</p>}
{state.success && <p className="text-green-500">전송되었습니다!</p>}
</form>
);
}JavaScript가 로드되기 전에 사용자가 폼을 제출하면, 브라우저는 기본 폼 제출 방식(POST 요청)으로 Server Action을 실행합니다. JavaScript가 로드된 후에는 fetch로 비동기 제출하여 페이지 이동 없이 처리합니다.
Server Actions는 폼 외에도 다양한 곳에서 사용할 수 있습니다.
'use client';
import { useTransition } from 'react';
import { toggleFavorite } from '@/actions/favorite';
function FavoriteButton({ articleId, isFavorited }: {
articleId: string;
isFavorited: boolean;
}) {
const [isPending, startTransition] = useTransition();
function handleClick() {
startTransition(async () => {
await toggleFavorite(articleId);
});
}
return (
<button
onClick={handleClick}
disabled={isPending}
aria-label={isFavorited ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
{isFavorited ? '★' : '☆'}
{isPending && <span className="animate-spin">...</span>}
</button>
);
}useTransition으로 감싸면 Action이 완료될 때까지 UI가 블로킹되지 않고, isPending 상태로 로딩 피드백을 제공할 수 있습니다.
'use client';
import { useEffect } from 'react';
import { trackPageView } from '@/actions/analytics';
function PageTracker({ pageId }: { pageId: string }) {
useEffect(() => {
trackPageView(pageId);
}, [pageId]);
return null;
}Server Action에서 에러를 throw하는 대신 결과 객체를 반환하는 패턴이 권장됩니다.
'use server';
type ActionResult =
| { success: true; data: Article }
| { success: false; error: string; field?: string };
export async function createArticle(
prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// 유효성 검사
if (!title || title.length < 3) {
return {
success: false,
error: '제목은 3자 이상이어야 합니다.',
field: 'title',
};
}
if (!content || content.length < 10) {
return {
success: false,
error: '내용은 10자 이상이어야 합니다.',
field: 'content',
};
}
try {
const article = await db.articles.create({
data: { title, content },
});
revalidatePath('/articles');
return { success: true, data: article };
} catch (error) {
return {
success: false,
error: '게시물 생성에 실패했습니다. 잠시 후 다시 시도해주세요.',
};
}
}'use client';
import { useActionState } from 'react';
import { createArticle } from '@/actions/article';
function CreateArticleForm() {
const [state, formAction, isPending] = useActionState(
createArticle,
{ success: false, error: '' }
);
return (
<form action={formAction}>
<div>
<input name="title" placeholder="제목" />
{!state.success && state.field === 'title' && (
<p className="text-red-500 text-sm">{state.error}</p>
)}
</div>
<div>
<textarea name="content" placeholder="내용" />
{!state.success && state.field === 'content' && (
<p className="text-red-500 text-sm">{state.error}</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? '저장 중...' : '저장'}
</button>
{!state.success && !state.field && state.error && (
<p className="text-red-500">{state.error}</p>
)}
</form>
);
}예상치 못한 에러는 Error Boundary에서 잡을 수 있습니다.
// app/articles/new/error.tsx
'use client';
function ArticleCreateError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>문제가 발생했습니다</h2>
<p>게시물을 생성하는 중 오류가 발생했습니다.</p>
<button onClick={reset}>다시 시도</button>
</div>
);
}
export default ArticleCreateError;Server Action의 응답을 기다리지 않고 UI를 먼저 업데이트하면 사용자 경험이 크게 개선됩니다.
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleLike } from '@/actions/like';
interface LikeState {
count: number;
isLiked: boolean;
}
function LikeSection({ articleId, initialState }: {
articleId: string;
initialState: LikeState;
}) {
const [isPending, startTransition] = useTransition();
const [optimisticState, setOptimisticState] = useOptimistic(
initialState,
(current: LikeState) => ({
count: current.isLiked ? current.count - 1 : current.count + 1,
isLiked: !current.isLiked,
})
);
function handleToggle() {
startTransition(async () => {
// 즉시 UI 업데이트
setOptimisticState(optimisticState);
// 서버에 요청 (실패하면 자동 롤백)
await toggleLike(articleId);
});
}
return (
<button onClick={handleToggle}>
{optimisticState.isLiked ? '★' : '☆'}
{optimisticState.count}
</button>
);
}useOptimistic은 Action이 완료(성공 또는 실패)되면 자동으로 실제 상태(initialState)로 돌아갑니다. 서버 응답이 성공이면 initialState가 업데이트된 새 값으로 바뀌므로 UI가 유지되고, 실패하면 원래 값으로 롤백됩니다.
Server Actions로 파일 업로드도 자연스럽게 처리할 수 있습니다.
'use server';
export async function uploadImage(formData: FormData) {
const file = formData.get('image') as File;
if (!file || file.size === 0) {
return { error: '파일을 선택해주세요.' };
}
// 파일 크기 제한 (5MB)
if (file.size > 5 * 1024 * 1024) {
return { error: '파일 크기는 5MB 이하여야 합니다.' };
}
// 허용된 MIME 타입 검사
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return { error: 'JPEG, PNG, WebP 형식만 허용됩니다.' };
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// S3 업로드 또는 로컬 저장
const key = `uploads/${Date.now()}-${file.name}`;
await uploadToStorage(key, buffer, file.type);
return { success: true, url: `/api/images/${key}` };
}'use client';
import { useActionState } from 'react';
import { uploadImage } from '@/actions/upload';
function ImageUploader() {
const [state, formAction, isPending] = useActionState(
uploadImage,
{ error: null, url: null }
);
return (
<form action={formAction}>
<input
type="file"
name="image"
accept="image/jpeg,image/png,image/webp"
/>
<button type="submit" disabled={isPending}>
{isPending ? '업로드 중...' : '업로드'}
</button>
{state.error && <p className="text-red-500">{state.error}</p>}
{state.url && (
<img src={state.url} alt="업로드된 이미지" className="mt-4 max-w-xs" />
)}
</form>
);
}Server Actions는 공개된 HTTP 엔드포인트로 노출되므로 보안에 주의해야 합니다.
'use server';
import { auth } from '@/lib/auth';
// 인증이 필요한 모든 Action에서 호출
async function requireAuth() {
const session = await auth();
if (!session?.user) {
throw new Error('인증이 필요합니다.');
}
return session.user;
}
export async function createArticle(formData: FormData) {
const user = await requireAuth();
// 권한 검사
if (!user.roles.includes('author')) {
throw new Error('게시물 작성 권한이 없습니다.');
}
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.articles.create({
data: { title, content, authorId: user.id },
});
revalidatePath('/articles');
}
export async function deleteArticle(id: string) {
const user = await requireAuth();
// 소유자 확인
const article = await db.articles.findUnique({ where: { id } });
if (article?.authorId !== user.id && !user.roles.includes('admin')) {
throw new Error('삭제 권한이 없습니다.');
}
await db.articles.delete({ where: { id } });
revalidatePath('/articles');
}클라이언트 유효성 검사는 UX를 위한 것이고, 서버 유효성 검사는 보안을 위한 것입니다. 항상 Server Action에서 입력을 검증해야 합니다.
'use server';
import { z } from 'zod';
const ArticleSchema = z.object({
title: z
.string()
.min(3, '제목은 3자 이상이어야 합니다.')
.max(200, '제목은 200자 이하여야 합니다.')
.trim(),
content: z
.string()
.min(10, '내용은 10자 이상이어야 합니다.')
.max(50000, '내용은 50,000자 이하여야 합니다.'),
tags: z
.string()
.transform(s => s.split(',').map(t => t.trim()).filter(Boolean))
.pipe(z.array(z.string()).max(10, '태그는 10개 이하여야 합니다.')),
});
export async function createArticle(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const raw = {
title: formData.get('title'),
content: formData.get('content'),
tags: formData.get('tags'),
};
const result = ArticleSchema.safeParse(raw);
if (!result.success) {
const errors = result.error.flatten().fieldErrors;
return {
success: false,
errors,
values: raw as Record<string, string>,
};
}
try {
await db.articles.create({ data: result.data });
revalidatePath('/articles');
return { success: true };
} catch {
return {
success: false,
errors: { _form: ['게시물 생성에 실패했습니다.'] },
values: raw as Record<string, string>,
};
}
}Server Actions에도 Rate Limiting을 적용해야 합니다.
'use server';
import { headers } from 'next/headers';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 m'), // 1분에 10회
});
export async function submitForm(formData: FormData) {
const headersList = await headers();
const ip = headersList.get('x-forwarded-for') ?? '127.0.0.1';
const { success } = await ratelimit.limit(ip);
if (!success) {
return { error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' };
}
// ... 실제 로직
}지금까지 다룬 내용을 종합하여 완전한 CRUD 패턴을 구성합니다.
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const TodoSchema = z.object({
title: z.string().min(1).max(200).trim(),
});
type TodoState = {
errors?: Record<string, string[]>;
message?: string;
};
export async function createTodo(
prevState: TodoState,
formData: FormData
): Promise<TodoState> {
const result = TodoSchema.safeParse({
title: formData.get('title'),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
await db.todos.create({ data: result.data });
revalidatePath('/todos');
return { message: '할 일이 추가되었습니다.' };
}
export async function toggleTodo(id: string) {
const todo = await db.todos.findUnique({ where: { id } });
if (!todo) return;
await db.todos.update({
where: { id },
data: { completed: !todo.completed },
});
revalidatePath('/todos');
}
export async function deleteTodo(id: string) {
await db.todos.delete({ where: { id } });
revalidatePath('/todos');
}
export async function updateTodo(id: string, formData: FormData) {
const result = TodoSchema.safeParse({
title: formData.get('title'),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
await db.todos.update({
where: { id },
data: result.data,
});
revalidatePath('/todos');
return { message: '수정되었습니다.' };
}// app/todos/page.tsx (Server Component)
import { TodoForm } from '@/components/TodoForm';
import { TodoItem } from '@/components/TodoItem';
async function TodosPage() {
const todos = await db.todos.findMany({
orderBy: { createdAt: 'desc' },
});
return (
<div>
<h1>할 일 목록</h1>
<TodoForm />
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
</div>
);
}// components/TodoItem.tsx
'use client';
import { useTransition, useOptimistic } from 'react';
import { toggleTodo, deleteTodo } from '@/actions/todo';
interface Todo {
id: string;
title: string;
completed: boolean;
}
function TodoItem({ todo }: { todo: Todo }) {
const [isPending, startTransition] = useTransition();
const [optimisticTodo, setOptimisticTodo] = useOptimistic(
todo,
(current: Todo) => ({ ...current, completed: !current.completed })
);
function handleToggle() {
startTransition(async () => {
setOptimisticTodo(todo);
await toggleTodo(todo.id);
});
}
function handleDelete() {
startTransition(async () => {
await deleteTodo(todo.id);
});
}
return (
<li className={isPending ? 'opacity-50' : ''}>
<label>
<input
type="checkbox"
checked={optimisticTodo.completed}
onChange={handleToggle}
/>
<span className={optimisticTodo.completed ? 'line-through' : ''}>
{todo.title}
</span>
</label>
<button onClick={handleDelete} disabled={isPending}>
삭제
</button>
</li>
);
}
export { TodoItem };'use server' 디렉티브로 표시된 비동기 함수로, 클라이언트에서 호출하면 서버에서 실행됩니다.<form action>에 직접 전달하여 Progressive Enhancement를 지원하는 폼을 만들 수 있습니다.useEffect 등에서 useTransition과 함께 호출할 수 있습니다.useOptimistic과 결합하여 즉각적인 UI 피드백을 제공할 수 있습니다.다음 장에서는 React 19의 새로운 use() API를 살펴보고, Promise와 Context를 소비하는 새로운 패턴을 다룹니다.
이 글이 도움이 되셨나요?
React 19의 use() API로 Promise와 Context를 조건부로 소비하는 방법, 서버-클라이언트 스트리밍 패턴, 기존 훅과의 차이를 다룹니다.
React Server Components의 동작 원리, 직렬화 프로토콜, 번들 전략, 합성 규칙을 심층적으로 분석합니다. 서버와 클라이언트의 경계를 이해합니다.
React 19의 새로운 훅 3종을 심층 분석합니다. 폼 상태 관리, 제출 상태 추적, 낙관적 UI 업데이트의 실전 패턴을 다룹니다.