2 min read
Next.js Server Actions 검증 설계 플레이북
Server Actions 입력 검증, 권한 검사, 에러 맵핑을 분리해 운영 안정성을 높이는 실무 가이드

도입
Server Actions를 도입하면 API Route를 줄일 수 있어 개발 속도는 빨라진다. 문제는 운영 단계에서 검증 규칙, 권한 규칙, 에러 응답 규칙이 뒤섞이기 쉽다는 점이다. 특히 액션 수가 늘어나면 try/catch가 비즈니스 로직을 덮어버리고, 장애 분석 시점에는 "어디서 실패했는지"를 추적하기 어려워진다.
이 글은 액션 하나를 기준으로 입력 검증, 권한 체크, 유즈케이스 실행, 에러 매핑을 분리하는 실전 패턴을 정리한다. 목표는 "개발자가 액션을 추가해도 품질이 무너지지 않는 구조"다.

문제 정의
현업에서 자주 만나는 실패 패턴은 다음과 같다.
- 폼 입력 검증이 클라이언트와 서버에서 다르게 구현되어 데이터 정합성이 깨진다.
- 권한 체크가 유즈케이스 내부에 흩어져 정책 변경 시 누락이 발생한다.
- DB 에러나 외부 API 에러가 그대로 노출되어 사용자 메시지가 일관되지 않다.
- 실패 로그에 request context가 없어 재현이 어렵다.
핵심은 액션 함수를 "작은 오케스트레이터"로 보고 각 책임을 어댑터처럼 분리하는 것이다.
핵심 개념
| 레이어 | 책임 | 실패 시 대응 |
|---|---|---|
| Input Validator | 타입/스키마 검증 | 400 + 필드 단위 에러 |
| Policy Guard | 인증/권한 확인 | 401/403 + 정책 코드 |
| UseCase | 도메인 규칙 실행 | 도메인 예외로 래핑 |
| Error Mapper | 사용자 응답/로그 포맷 | 표준 에러 모델 반환 |
운영 기준은 단순하다. 액션은 직접 DB를 만지지 않고 UseCase만 호출한다. 액션은 ActionResult<T> 같은 공통 타입으로만 응답한다.
코드 예시 1: 액션 엔트리 포인트 분리
"use server";
import { z } from "zod";
import { getSession } from "@/lib/auth/session";
import { createPostUseCase } from "@/app/_application/post-usecase";
import { mapActionError, type ActionResult } from "@/app/_application/action-error";
const schema = z.object({
title: z.string().min(8).max(120),
body: z.string().min(50),
tags: z.array(z.string()).max(8),
});
type CreatePostOutput = { postId: string };
export async function createPostAction(
formData: FormData,
): Promise<ActionResult<CreatePostOutput>> {
try {
const parsed = schema.parse({
title: formData.get("title"),
body: formData.get("body"),
tags: formData.getAll("tags"),
});
const session = await getSession();
if (!session) return { ok: false, code: "UNAUTHENTICATED", message: "로그인이 필요합니다." };
if (!session.roles.includes("editor")) {
return { ok: false, code: "FORBIDDEN", message: "작성 권한이 없습니다." };
}
const output = await createPostUseCase.execute({
actorId: session.userId,
...parsed,
});
return { ok: true, data: { postId: output.postId } };
} catch (error) {
return mapActionError(error, { feature: "createPostAction" });
}
}
코드 예시 2: 표준 에러 매퍼
import { ZodError } from "zod";
type ErrorCode =
| "VALIDATION_ERROR"
| "UNAUTHENTICATED"
| "FORBIDDEN"
| "CONFLICT"
| "INTERNAL_ERROR";
export type ActionResult<T> =
| { ok: true; data: T }
| { ok: false; code: ErrorCode; message: string; details?: unknown };
export function mapActionError(error: unknown, ctx: { feature: string }): ActionResult<never> {
if (error instanceof ZodError) {
return {
ok: false,
code: "VALIDATION_ERROR",
message: "입력값을 다시 확인해주세요.",
details: error.flatten(),
};
}
console.error("action.error", { feature: ctx.feature, error });
return {
ok: false,
code: "INTERNAL_ERROR",
message: "잠시 후 다시 시도해주세요.",
};
}
아키텍처 흐름
Mermaid diagram rendering...
트레이드오프
- 액션마다 스키마/가드/매퍼를 나누면 초기 코드량은 늘어난다.
- 반대로 장애 분석 시간은 크게 줄어든다. 에러 코드와 로그 스키마가 통일되기 때문이다.
- 팀이 커질수록 "공통 규칙 강제"의 가치가 커진다. 신규 개발자의 편차를 줄일 수 있다.
정리
Server Actions의 성패는 문법이 아니라 경계 설계에서 갈린다. 액션을 얇게 유지하고, 검증과 권한, 유즈케이스, 에러 매핑을 분리하면 액션 수가 늘어도 운영 난이도가 선형적으로 유지된다. 다음 단계로는 ActionResult를 기반으로 클라이언트 에러 UI를 공통 컴포넌트화하면 된다.
이미지 출처
- Cover: source link
- License: GPLv3 / Author: Waldenn
- Note: Wikimedia Commons 무료 라이선스 이미지를 다운로드 후 1600px 기준 JPG로 최적화했습니다.