13.1 Server Actions — 서버 액션
Server Actions란?
Server Actions는 Next.js 15에서 제공하는 기능으로, 클라이언트에서 직접 서버 측 함수를 호출할 수 있게 해줍니다. 별도의 API 엔드포인트를 만들지 않아도 폼 제출, 데이터 변경 등의 서버 작업을 수행할 수 있습니다.
기존 방식에서는 데이터를 변경하려면 반드시 API 라우트를 작성하고, 클라이언트에서 fetch로 해당 API를 호출해야 했습니다. Server Actions를 사용하면 이 과정을 훨씬 간단하게 처리할 수 있습니다.
핵심 개념 정리
| 개념 | 설명 |
|---|---|
"use server" | 서버 액션임을 선언하는 디렉티브 |
| 폼 처리 | <form action={serverAction}> 형태로 폼과 연결 |
| 낙관적 업데이트 | UI를 먼저 업데이트하고 서버 응답을 나중에 반영 |
revalidatePath | 특정 경로의 캐시를 무효화하여 최신 데이터 반영 |
revalidateTag | 태그 기반으로 캐시를 무효화 |
"use server" 디렉티브
파일 단위 선언
파일 최상단에 "use server"를 선언하면 해당 파일의 모든 함수가 서버 액션이 됩니다.
// app/actions/todo.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function createTodo(formData: FormData) {
const title = formData.get("title") as string;
if (!title || title.trim() === "") {
throw new Error("할 일 제목은 필수입니다.");
}
await db.todo.create({
data: { title: title.trim(), completed: false },
});
revalidatePath("/todos");
}
export async function deleteTodo(id: number) {
await db.todo.delete({ where: { id } });
revalidatePath("/todos");
}
export async function toggleTodo(id: number, completed: boolean) {
await db.todo.update({
where: { id },
data: { completed: !completed },
});
revalidatePath("/todos");
}
함수 단위 선언
Server Component 내부에서 인라인으로 서버 액션을 선언할 수도 있습니다.
// app/todos/page.tsx
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export default async function TodosPage() {
const todos = await db.todo.findMany({ orderBy: { createdAt: "desc" } });
async function addTodo(formData: FormData) {
"use server";
const title = formData.get("title") as string;
await db.todo.create({ data: { title, completed: false } });
revalidatePath("/todos");
}
return (
<div>
<h1>할 일 목록</h1>
<form action={addTodo}>
<input name="title" placeholder="새 할 일 입력" required />
<button type="submit">추가</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
}
폼 처리 (Form Handling)
기본 폼 처리
Server Actions의 가장 강력한 활용 사례는 HTML 폼 처리입니다. <form>의 action 속성에 서버 액션 함수를 전달하면, JavaScript 없이도 폼이 동작합니다 (Progressive Enhancement).
// app/contact/page.tsx
import { redirect } from "next/navigation";
async function submitContact(formData: FormData) {
"use server";
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) {
throw new Error("모든 필드를 입력해주세요.");
}
// 이메일 형식 검사
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error("올바른 이메일 형식이 아닙니다.");
}
// DB 저장 또는 이메일 발송 처리
await saveContactToDb({ name, email, message });
// 성공 시 리다이렉트
redirect("/contact/success");
}
export default function ContactPage() {
return (
<form action={submitContact} className="space-y-4">
<div>
<label htmlFor="name">이름</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label htmlFor="email">이메일</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="message">메시지</label>
<textarea id="message" name="message" required />
</div>
<button type="submit">전송</button>
</form>
);
}
useActionState로 폼 상태 관리
useActionState (Next.js 15, React 19)를 사용하면 서버 액션의 결과를 클라이언트에서 쉽게 처리할 수 있습니다.
// app/actions/auth.ts
"use server";
export type ActionState = {
success: boolean;
message: string;
errors?: Record<string, string>;
};
export async function loginAction(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const errors: Record<string, string> = {};
if (!email) errors.email = "이메일을 입력해주세요.";
if (!password) errors.password = "비밀번호를 입력해주세요.";
if (password && password.length < 8)
errors.password = "비밀번호는 8자 이상이어야 합니다.";
if (Object.keys(errors).length > 0) {
return { success: false, message: "입력 오류가 있습니다.", errors };
}
try {
// 실제 인증 로직
const user = await authenticateUser(email, password);
if (!user) {
return { success: false, message: "이메일 또는 비밀번호가 틀렸습니다." };
}
return { success: true, message: "로그인 성공!" };
} catch (error) {
return { success: false, message: "서버 오류가 발생했습니다." };
}
}
// app/login/page.tsx
"use client";
import { useActionState } from "react";
import { loginAction, ActionState } from "@/app/actions/auth";
const initialState: ActionState = { success: false, message: "" };
export default function LoginPage() {
const [state, formAction, isPending] = useActionState(
loginAction,
initialState
);
return (
<form action={formAction} className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-4">로그인</h1>
{state.message && (
<div
className={`p-3 rounded mb-4 ${
state.success
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{state.message}
</div>
)}
<div className="mb-4">
<label htmlFor="email" className="block mb-1">
이메일
</label>
<input
id="email"
name="email"
type="email"
className="w-full border rounded px-3 py-2"
/>
{state.errors?.email && (
<p className="text-red-500 text-sm mt-1">{state.errors.email}</p>
)}
</div>
<div className="mb-6">
<label htmlFor="password" className="block mb-1">
비밀번호
</label>
<input
id="password"
name="password"
type="password"
className="w-full border rounded px-3 py-2"
/>
{state.errors?.password && (
<p className="text-red-500 text-sm mt-1">{state.errors.password}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 text-white py-2 rounded disabled:opacity-50"
>
{isPending ? "로그인 중..." : "로그인"}
</button>
</form>
);
}
낙관적 업데이트 (Optimistic Updates)
낙관적 업데이트란 서버 응답을 기다리지 않고, 성공할 것이라고 가정하여 UI를 먼저 업데이트하는 기법입니다. 사용자 경험을 크게 향상시킵니다.
React 19의 useOptimistic 훅을 활용합니다.
// app/todos/TodoList.tsx
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleTodo, deleteTodo } from "@/app/actions/todo";
type Todo = {
id: number;
title: string;
completed: boolean;
};
type OptimisticAction =
| { type: "toggle"; id: number }
| { type: "delete"; id: number };
export default function TodoList({ todos }: { todos: Todo[] }) {
const [isPending, startTransition] = useTransition();
const [optimisticTodos, updateOptimistic] = useOptimistic(
todos,
(state: Todo[], action: OptimisticAction) => {
if (action.type === "toggle") {
return state.map((todo) =>
todo.id === action.id
? { ...todo, completed: !todo.completed }
: todo
);
}
if (action.type === "delete") {
return state.filter((todo) => todo.id !== action.id);
}
return state;
}
);
const handleToggle = (todo: Todo) => {
startTransition(async () => {
updateOptimistic({ type: "toggle", id: todo.id });
await toggleTodo(todo.id, todo.completed);
});
};
const handleDelete = (id: number) => {
startTransition(async () => {
updateOptimistic({ type: "delete", id });
await deleteTodo(id);
});
};
return (
<ul className="space-y-2">
{optimisticTodos.map((todo) => (
<li
key={todo.id}
className="flex items-center gap-3 p-3 border rounded"
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo)}
className="w-4 h-4"
/>
<span
className={`flex-1 ${todo.completed ? "line-through text-gray-400" : ""}`}
>
{todo.title}
</span>
<button
onClick={() => handleDelete(todo.id)}
className="text-red-500 hover:text-red-700 text-sm"
>
삭제
</button>
</li>
))}
{isPending && (
<li className="text-gray-400 text-sm text-center py-2">
업데이트 중...
</li>
)}
</ul>
);
}
재검증 (revalidatePath, revalidateTag)
Next.js는 데이터를 캐싱합니다. 서버 액션으로 데이터를 변경했을 때, 캐시를 무효화하지 않으면 오래된 데이터가 표시됩니다. revalidatePath와 revalidateTag로 캐시를 갱신합니다.
revalidatePath — 경로 기반 재검증
// app/actions/post.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const slug = title.toLowerCase().replace(/\s+/g, "-");
const post = await db.post.create({
data: { title, content, slug, publishedAt: new Date() },
});
// 목록 페이지와 새 게시물 페이지 재검증
revalidatePath("/posts");
revalidatePath(`/posts/${post.slug}`);
return post;
}
export async function updatePost(
id: number,
formData: FormData
) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const post = await db.post.update({
where: { id },
data: { title, content, updatedAt: new Date() },
});
// layout 타입으로 해당 경로의 모든 하위 경로 재검증
revalidatePath("/posts", "layout");
}
export async function deletePost(id: number, slug: string) {
await db.post.delete({ where: { id } });
revalidatePath("/posts");
revalidatePath(`/posts/${slug}`);
}
revalidateTag — 태그 기반 재검증
데이터 페칭 시 태그를 붙이고, 관련 데이터가 변경될 때 태그로 캐시를 무효화합니다.
// app/lib/data.ts
import { unstable_cache } from "next/cache";
// 태그를 붙여 캐싱
export const getPosts = unstable_cache(
async () => {
const response = await fetch("https://api.example.com/posts");
return response.json();
},
["posts-list"],
{
tags: ["posts"],
revalidate: 3600, // 1시간마다 자동 재검증
}
);
export const getPostBySlug = unstable_cache(
async (slug: string) => {
const response = await fetch(`https://api.example.com/posts/${slug}`);
return response.json();
},
["post-detail"],
{
tags: ["posts", `post-${slug}`],
}
);
// app/actions/post.ts
"use server";
import { revalidateTag } from "next/cache";
export async function publishPost(id: number) {
await db.post.update({ where: { id }, data: { published: true } });
// "posts" 태그가 붙은 모든 캐시 무효화
revalidateTag("posts");
}
실전 예제 — 댓글 시스템 구현
완전한 CRUD 댓글 시스템을 Server Actions로 구현합니다.
// app/actions/comment.ts
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export type CommentActionState = {
success: boolean;
message: string;
commentId?: number;
};
// 댓글 작성
export async function createComment(
postSlug: string,
prevState: CommentActionState,
formData: FormData
): Promise<CommentActionState> {
const session = await auth();
if (!session?.user) {
return { success: false, message: "로그인이 필요합니다." };
}
const content = formData.get("content") as string;
if (!content || content.trim().length < 2) {
return { success: false, message: "댓글은 2자 이상 입력해주세요." };
}
if (content.length > 500) {
return { success: false, message: "댓글은 500자 이하로 입력해주세요." };
}
const post = await db.post.findUnique({ where: { slug: postSlug } });
if (!post) {
return { success: false, message: "게시물을 찾을 수 없습니다." };
}
const comment = await db.comment.create({
data: {
content: content.trim(),
postId: post.id,
userId: session.user.id,
},
});
revalidatePath(`/posts/${postSlug}`);
return { success: true, message: "댓글이 등록되었습니다.", commentId: comment.id };
}
// 댓글 삭제
export async function deleteComment(
commentId: number,
postSlug: string
): Promise<CommentActionState> {
const session = await auth();
if (!session?.user) {
return { success: false, message: "로그인이 필요합니다." };
}
const comment = await db.comment.findUnique({ where: { id: commentId } });
if (!comment) {
return { success: false, message: "댓글을 찾을 수 없습니다." };
}
// 본인 댓글 또는 관리자만 삭제 가능
if (comment.userId !== session.user.id && session.user.role !== "admin") {
return { success: false, message: "삭제 권한이 없습니다." };
}
await db.comment.delete({ where: { id: commentId } });
revalidatePath(`/posts/${postSlug}`);
return { success: true, message: "댓글이 삭제되었습니다." };
}
// app/posts/[slug]/CommentSection.tsx
"use client";
import { useActionState, useOptimistic, useTransition } from "react";
import {
createComment,
deleteComment,
CommentActionState,
} from "@/app/actions/comment";
import { useSession } from "next-auth/react";
type Comment = {
id: number;
content: string;
createdAt: Date;
user: { id: string; name: string; image: string };
};
const initialState: CommentActionState = { success: false, message: "" };
export default function CommentSection({
postSlug,
initialComments,
}: {
postSlug: string;
initialComments: Comment[];
}) {
const { data: session } = useSession();
const [isPending, startTransition] = useTransition();
const [optimisticComments, removeOptimistic] = useOptimistic(
initialComments,
(state: Comment[], deletedId: number) =>
state.filter((c) => c.id !== deletedId)
);
const createCommentWithSlug = createComment.bind(null, postSlug);
const [state, formAction, isSubmitting] = useActionState(
createCommentWithSlug,
initialState
);
const handleDelete = (commentId: number) => {
startTransition(async () => {
removeOptimistic(commentId);
await deleteComment(commentId, postSlug);
});
};
return (
<section className="mt-8">
<h2 className="text-xl font-bold mb-4">
댓글 {optimisticComments.length}개
</h2>
{/* 댓글 목록 */}
<div className="space-y-4 mb-6">
{optimisticComments.map((comment) => (
<div key={comment.id} className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<img
src={comment.user.image}
alt={comment.user.name}
className="w-8 h-8 rounded-full"
/>
<span className="font-medium">{comment.user.name}</span>
<span className="text-sm text-gray-500">
{new Date(comment.createdAt).toLocaleDateString("ko-KR")}
</span>
</div>
{session?.user?.id === comment.user.id && (
<button
onClick={() => handleDelete(comment.id)}
disabled={isPending}
className="text-red-500 text-sm hover:underline disabled:opacity-50"
>
삭제
</button>
)}
</div>
<p className="text-gray-700">{comment.content}</p>
</div>
))}
</div>
{/* 댓글 작성 폼 */}
{session ? (
<form action={formAction} className="space-y-3">
{state.message && (
<p
className={`text-sm ${state.success ? "text-green-600" : "text-red-600"}`}
>
{state.message}
</p>
)}
<textarea
name="content"
placeholder="댓글을 입력하세요... (최대 500자)"
maxLength={500}
required
className="w-full border rounded-lg p-3 resize-none h-24"
/>
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-600 text-white px-4 py-2 rounded-lg disabled:opacity-50"
>
{isSubmitting ? "등록 중..." : "댓글 등록"}
</button>
</form>
) : (
<p className="text-gray-500">
댓글을 작성하려면{" "}
<a href="/login" className="text-blue-600 underline">
로그인
</a>
이 필요합니다.
</p>
)}
</section>
);
}
고수 팁
1. Server Action에서 쿠키 및 헤더 접근
"use server";
import { cookies, headers } from "next/headers";
export async function getTheme() {
const cookieStore = await cookies();
return cookieStore.get("theme")?.value ?? "light";
}
export async function setTheme(theme: "light" | "dark") {
const cookieStore = await cookies();
cookieStore.set("theme", theme, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365, // 1년
});
}
export async function getRequestInfo() {
const headerStore = await headers();
const userAgent = headerStore.get("user-agent");
const ip = headerStore.get("x-forwarded-for");
return { userAgent, ip };
}
2. 여러 Server Action을 하나의 폼에서 처리
// app/profile/page.tsx
"use client";
import { updateProfile, deleteAccount } from "@/app/actions/profile";
export default function ProfilePage() {
return (
<div>
{/* 프로필 업데이트 폼 */}
<form action={updateProfile}>
<input name="name" placeholder="이름" />
<button type="submit">프로필 수정</button>
</form>
{/* formAction으로 동적으로 액션 선택 */}
<form>
<button formAction={updateProfile}>저장</button>
<button formAction={deleteAccount} type="submit">
계정 삭제
</button>
</form>
</div>
);
}
3. Server Action 결과를 redirect와 함께 사용
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
export async function createAndRedirect(formData: FormData) {
const item = await db.item.create({
data: { name: formData.get("name") as string },
});
revalidatePath("/items");
// redirect는 throw 방식으로 동작하므로 try/catch 외부에서 호출해야 함
redirect(`/items/${item.id}`);
}
4. 에러 경계 처리
// app/todos/error.tsx
"use client";
export default function ErrorBoundary({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="p-6 bg-red-50 rounded-lg">
<h2 className="text-xl font-bold text-red-700 mb-2">오류 발생</h2>
<p className="text-red-600 mb-4">{error.message}</p>
<button
onClick={reset}
className="bg-red-600 text-white px-4 py-2 rounded"
>
다시 시도
</button>
</div>
);
}
5. Server Action 타입 안전성 강화 (zod)
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
const TodoSchema = z.object({
title: z.string().min(1, "제목은 필수입니다.").max(100, "제목은 100자 이하"),
priority: z.enum(["low", "medium", "high"]).default("medium"),
dueDate: z.string().optional(),
});
export async function createTodoValidated(
prevState: { success: boolean; message: string; errors?: Record<string, string[]> },
formData: FormData
) {
const raw = {
title: formData.get("title"),
priority: formData.get("priority"),
dueDate: formData.get("dueDate"),
};
const result = TodoSchema.safeParse(raw);
if (!result.success) {
return {
success: false,
message: "입력값을 확인해주세요.",
errors: result.error.flatten().fieldErrors,
};
}
await db.todo.create({ data: result.data });
revalidatePath("/todos");
return { success: true, message: "할 일이 추가되었습니다." };
}
6. Server Action 미들웨어 패턴 — 인증 래퍼
반복적인 인증 체크를 래퍼 함수로 추상화합니다.
// lib/action-guard.ts
import { auth } from "@/lib/auth";
type ServerAction<T extends unknown[], R> = (...args: T) => Promise<R>;
export function withAuth<T extends unknown[], R>(
action: (userId: string, ...args: T) => Promise<R>
): ServerAction<T, R> {
return async (...args: T): Promise<R> => {
const session = await auth();
if (!session?.user?.id) {
throw new Error("인증이 필요합니다.");
}
return action(session.user.id, ...args);
};
}
// 사용 예시
// app/actions/secure.ts
"use server";
import { withAuth } from "@/lib/action-guard";
import { revalidatePath } from "next/cache";
export const createSecurePost = withAuth(async (userId, formData: FormData) => {
const title = formData.get("title") as string;
const post = await db.post.create({ data: { title, authorId: userId } });
revalidatePath("/posts");
return post;
});