본문으로 건너뛰기
Advertisement

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는 데이터를 캐싱합니다. 서버 액션으로 데이터를 변경했을 때, 캐시를 무효화하지 않으면 오래된 데이터가 표시됩니다. revalidatePathrevalidateTag로 캐시를 갱신합니다.

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;
});
Advertisement