next-safe-action으로 타입 안전한 Server Action 만들기 — 미들웨어 인증과 Vitest 테스트까지
이 글의 전제: Next.js App Router에서 Server Action을 사용해본 경험이 있는 분을 대상으로 씁니다.
Next.js App Router의 Server Action이 처음 나왔을 때, 저도 꽤 설렜습니다. "드디어 API 라우트 없이도 서버 로직을 바로 호출할 수 있구나." 그런데 막상 프로덕션 코드를 쓰다 보면, 각 액션마다 이런 패턴이 반복되기 시작합니다.
// 기존 방식 — 액션마다 이 보일러플레이트가 반복됩니다
export async function updateProfile(data: unknown) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized"); // 매번 직접 체크
const parsed = profileSchema.safeParse(data);
if (!parsed.success) return { error: parsed.error.flatten() }; // 에러 구조도 제각각
await db.user.update({ where: { id: session.user.id as string }, data: parsed.data });
}보일러플레이트가 쌓이다 보면 "이게 맞나?" 싶어지죠. next-safe-action은 이 반복을 없애줍니다. 인증은 미들웨어로 한 번만 정의해 재사용하고, 스키마 정의부터 클라이언트 훅까지 타입 추론이 자동으로 흐릅니다.
// next-safe-action 방식 — 인증과 검증이 자동으로 처리됩니다
export const updateProfile = authActionClient
.schema(profileSchema)
.action(async ({ parsedInput, ctx }) => {
// ctx.user, parsedInput 모두 타입 단언 없이 완전히 추론됩니다
await db.user.update({ where: { id: ctx.user.id }, data: parsedInput });
return { success: true };
});"Server Action은 테스트하기 어렵다"는 인식이 있는데, 이 글에서 Vitest를 활용한 미들웨어 격리 테스트 패턴으로 그 인식을 함께 깨봅니다. 핵심 개념부터 인증 미들웨어 계층 설계, Vitest 테스트까지 실무에서 바로 쓸 수 있는 흐름으로 살펴봅니다.
핵심 개념
Action Client — 미들웨어가 쌓이는 기반
next-safe-action의 모든 것은 createSafeActionClient()에서 시작합니다. .use()로 미들웨어를 추가할 때마다 원본 클라이언트를 변경하지 않고 새 인스턴스를 반환합니다. 덕분에 baseClient → authClient → adminClient처럼 클라이언트를 계층적으로 확장하면서 공통 로직을 DRY하게 유지할 수 있습니다.
// lib/safe-action.ts
import { createSafeActionClient, ActionError } from "next-safe-action";
import { auth } from "@/lib/auth";
export const actionClient = createSafeActionClient({
handleReturnedServerError(err) {
// ActionError는 메시지를 클라이언트에 그대로 노출하고,
// 일반 Error는 이 핸들러를 통해 마스킹됩니다
if (err instanceof ActionError) return err.message;
return "서버 오류가 발생했습니다.";
},
}).use(async ({ next }) => {
console.log("[action] start");
const result = await next();
console.log("[action] end");
return result;
});
// actionClient를 확장 — 세션 여부 확인 및 ctx.user 주입
export const authActionClient = actionClient.use(async ({ next }) => {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return next({ ctx: { user: session.user } });
});
// authActionClient를 확장 — 관리자 전용
export const adminActionClient = authActionClient.use(async ({ ctx, next }) => {
if (ctx.user.role !== "admin") throw new ActionError("관리자 권한이 필요합니다.");
return next({ ctx });
});createSafeActionClient()의 handleReturnedServerError 옵션이 여기서 중요한 보안 포인트입니다. ActionError를 던지면 그 메시지가 클라이언트의 result.serverError에 그대로 전달되고, 일반 Error를 던지면 이 핸들러가 개입해 메시지를 마스킹합니다. 사용자에게 보여줘도 괜찮은 에러("관리자 권한이 필요합니다.")는 ActionError로, 내부 시스템 에러는 일반 Error로 구분하면 보안 측면에서 훨씬 명확한 설계가 됩니다.
Middleware Pipeline — ctx로 정보를 흘려보내는 방식
미들웨어는 코드 상에서 .use()를 쓴 순서가 곧 실행 순서입니다. next()에 넣은 ctx는 다음 미들웨어로 그대로 전달되니, 인증 미들웨어에서 넣은 user가 액션 핸들러에서 타입 추론과 함께 그대로 살아 있습니다. 그리고 미들웨어에서 에러를 던지면 이후 미들웨어와 액션 핸들러는 실행되지 않습니다 — 당연하게 들리지만 직접 확인해두면 미들웨어 설계에 훨씬 자신감이 붙습니다.
// actions/update-profile.ts
"use server";
import { z } from "zod";
import { authActionClient } from "@/lib/safe-action";
import { db } from "@/lib/db";
export const updateProfile = authActionClient
.schema(z.object({ name: z.string().min(1) }))
.action(async ({ parsedInput, ctx }) => {
// ctx.user는 별도 타입 선언 없이 완전히 추론됩니다
await db.user.update({
where: { id: ctx.user.id },
data: { name: parsedInput.name },
});
return { success: true, name: parsedInput.name };
});핵심 인사이트: 미들웨어에서
next()의 반환값을 반드시return해야 체인이 유지됩니다. 처음에 이걸 빠뜨려서 액션 결과가undefined로 내려오는 걸 보고 한참 디버깅한 적이 있습니다.return await next()를 습관처럼 작성하는 것을 권장합니다.
Standard Schema v1: 2025년 Zod·Valibot·ArkType 창시자들이 공동 설계한 스펙으로, next-safe-action이 완전 지원합니다. Zod에서 Valibot으로 갈아타더라도 액션 코드를 건드릴 필요가 없습니다.
구조화된 에러 응답
next-safe-action이 반환하는 결과 객체는 세 가지 에러 유형을 명확히 분리합니다.
| 필드 | 발생 조건 |
|---|---|
validationErrors |
Zod 스키마 검증 실패 (필드별 에러 포함) |
serverError |
미들웨어 또는 액션 핸들러에서 던진 예외 (ActionError는 메시지 노출, 일반 Error는 마스킹) |
fetchError |
네트워크 레벨 실패 |
클라이언트에서 result.validationErrors?.name?._errors[0] 같은 식으로 필드별 에러 메시지를 바로 꺼내 쓸 수 있는 게 실무에서 꽤 편합니다.
실전 적용
예시 1: RBAC — 액션 수준 역할 검사
서비스 전체에 걸친 인증은 authActionClient가 처리하더라도, 특정 액션에만 추가 권한이 필요한 경우가 있습니다. 이럴 때 액션 정의 시점에 미들웨어를 인라인으로 추가할 수 있습니다. 아래 deleteUser는 바로 이어지는 Vitest 예시에서도 그대로 사용되니 흐름을 따라가 보시면 좋습니다.
// actions/delete-user.ts
"use server";
import { z } from "zod";
import { authActionClient } from "@/lib/safe-action";
import { ActionError } from "next-safe-action";
import { db } from "@/lib/db";
export const deleteUser = authActionClient
.use(async ({ ctx, next }) => {
if (ctx.user.role !== "admin") {
// ActionError를 사용하면 이 메시지가 클라이언트에 그대로 전달됩니다
throw new ActionError("관리자 권한이 필요합니다.");
}
return next({ ctx });
})
.schema(z.object({ userId: z.string().cuid() }))
.action(async ({ parsedInput }) => {
await db.user.delete({ where: { id: parsedInput.userId } });
return { deleted: parsedInput.userId };
});| 구성 요소 | 역할 |
|---|---|
authActionClient |
세션 존재 여부 확인, ctx.user 주입 |
인라인 .use() |
ctx.user.role 기반 RBAC 검사 |
ActionError |
클라이언트에 노출할 에러 메시지 (일반 Error는 마스킹됨) |
.schema() |
입력 유효성 검사 + 타입 추론 |
.action() |
실제 비즈니스 로직 |
예시 2: deleteUser 액션을 Vitest로 테스트하기
솔직히 이 부분이 처음엔 가장 막막했습니다. "use server" 지시어가 붙은 파일을 Vitest에서 그냥 임포트하면 에러가 납니다. 핵심 전략은 인증 함수와 DB 호출을 vi.mock()으로 교체하고 액션 함수를 직접 호출하는 것입니다.
먼저 vitest.config.ts에 경로 별칭을 설정해야 @/lib/auth 같은 임포트가 테스트 환경에서 정상 동작합니다. 이 설정이 없으면 이후 예시 코드 전체가 동작하지 않으니 꼭 확인해보시기 바랍니다.
// vitest.config.ts
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
environment: "node",
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./"),
},
},
});이제 예시 1의 deleteUser 액션을 테스트합니다.
// actions/delete-user.test.ts
import { vi, describe, it, expect, beforeEach } from "vitest";
import { deleteUser } from "./delete-user";
vi.mock("@/lib/auth", () => ({
auth: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
db: { user: { delete: vi.fn() } },
}));
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
describe("deleteUser", () => {
beforeEach(() => vi.clearAllMocks());
it("관리자는 사용자를 삭제할 수 있다", async () => {
vi.mocked(auth).mockResolvedValue({
user: { id: "admin-1", role: "admin" },
} as any);
// Auth.js의 Session 타입은 완전히 채워진 객체를 반환하지 않아
// 테스트 픽스처에서는 as any가 불가피합니다
vi.mocked(db.user.delete).mockResolvedValue({
id: "user-1",
name: "홍길동",
email: "hong@example.com",
});
const result = await deleteUser({ userId: "cld9oixx0000008l5etfg5f3i" });
expect(result?.data?.deleted).toBe("cld9oixx0000008l5etfg5f3i");
expect(db.user.delete).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "cld9oixx0000008l5etfg5f3i" } })
);
});
it("일반 사용자가 호출하면 serverError가 반환된다", async () => {
vi.mocked(auth).mockResolvedValue({
user: { id: "user-1", role: "user" },
} as any);
const result = await deleteUser({ userId: "cld9oixx0000008l5etfg5f3i" });
// ActionError("관리자 권한이 필요합니다.")가 serverError로 래핑됩니다
expect(result?.serverError).toBeDefined();
expect(db.user.delete).not.toHaveBeenCalled();
});
it("비로그인 상태에서는 serverError가 반환된다", async () => {
vi.mocked(auth).mockResolvedValue(null);
const result = await deleteUser({ userId: "cld9oixx0000008l5etfg5f3i" });
expect(result?.serverError).toBeDefined();
expect(db.user.delete).not.toHaveBeenCalled();
});
it("유효하지 않은 userId 형식은 validationErrors를 반환한다", async () => {
vi.mocked(auth).mockResolvedValue({
user: { id: "admin-1", role: "admin" },
} as any);
const result = await deleteUser({ userId: "not-a-cuid" });
expect(result?.validationErrors).toBeDefined();
expect(db.user.delete).not.toHaveBeenCalled();
});
});미들웨어 함수 자체는 순수 함수로 분리해두면 훨씬 더 깔끔하게 단독 테스트할 수 있습니다. 분리한 함수를 클라이언트에 연결할 때는 래핑해서 .use()에 넘기면 됩니다.
// lib/middlewares/auth-middleware.ts
export async function authMiddleware({
auth,
next,
ctx,
}: {
auth: () => Promise<{ user: { id: string; role: string } } | null>;
next: (args: { ctx: { user: { id: string; role: string } } }) => Promise<unknown>;
ctx: Record<string, unknown>;
}) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return next({ ctx: { ...ctx, user: session.user } });
}// lib/safe-action.ts — authActionClient를 순수 함수로 연결하는 방식
import { authMiddleware } from "@/lib/middlewares/auth-middleware";
import { auth } from "@/lib/auth";
export const authActionClient = actionClient.use(({ ctx, next }) =>
authMiddleware({ auth, ctx, next })
);이렇게 분리해두면 클라이언트 코드를 건드리지 않고 미들웨어 로직만 독립적으로 테스트할 수 있습니다.
// lib/middlewares/auth-middleware.test.ts
import { vi, describe, it, expect } from "vitest";
import { authMiddleware } from "./auth-middleware";
describe("authMiddleware", () => {
it("세션이 없으면 Unauthorized 에러를 던진다", async () => {
const mockAuth = vi.fn().mockResolvedValue(null);
const mockNext = vi.fn();
await expect(
authMiddleware({ auth: mockAuth, next: mockNext, ctx: {} })
).rejects.toThrow("Unauthorized");
expect(mockNext).not.toHaveBeenCalled();
});
it("세션이 있으면 ctx에 user를 주입하고 next를 호출한다", async () => {
const user = { id: "u1", role: "user" };
const mockAuth = vi.fn().mockResolvedValue({ user });
const mockNext = vi.fn().mockResolvedValue({ data: "ok" });
await authMiddleware({ auth: mockAuth, next: mockNext, ctx: {} });
expect(mockNext).toHaveBeenCalledWith({ ctx: { user } });
});
});테스트 전략 요약: 액션 통합 테스트는 의존성을 모킹 후 액션을 직접 호출하고, 미들웨어 단위 테스트는 순수 함수로 분리해 독립적으로 검증하는 방식을 조합하면 커버리지를 효율적으로 확보할 수 있습니다.
예시 3: 클라이언트 컴포넌트에서 훅으로 상태 관리
// components/profile-form.tsx
"use client";
import { useAction } from "next-safe-action/hooks";
import { updateProfile } from "@/actions/update-profile";
import { toast } from "sonner";
export function ProfileForm() {
const { execute, status, result } = useAction(updateProfile, {
onSuccess: () => toast.success("저장되었습니다"),
onError: ({ error }) =>
toast.error(error.serverError ?? "알 수 없는 오류가 발생했습니다"),
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
execute({ name: formData.get("name") as string });
}}
>
<input name="name" />
{result.validationErrors?.name && (
<p className="text-red-500">
{result.validationErrors.name._errors[0]}
</p>
)}
<button type="submit" disabled={status === "executing"}>
{status === "executing" ? "저장 중..." : "저장"}
</button>
</form>
);
}장단점 분석
장점
| 항목 | 내용 |
|---|---|
| End-to-end 타입 안전성 | 스키마 정의 → 서버 핸들러 → 클라이언트 훅까지 수동 타입 선언 없이 완전 추론 |
| 컴포저블 미들웨어 | baseClient → authClient → adminClient 계층으로 인증·인가 로직을 DRY하게 재사용 |
| 런타임 유효성 검사 | 클라이언트에서 넘어온 입력을 서버에서 반드시 재검증해 OWASP 권고사항 준수 |
| 검증 라이브러리 독립성 | Standard Schema v1 지원으로 Zod·Valibot·ArkType 자유롭게 교체 가능 |
| 구조화된 에러 핸들링 | validationErrors, serverError, fetchError를 분리해 클라이언트에서 세밀하게 처리 |
| 낙관적 UI 내장 | useOptimisticAction으로 서버 응답 전 UI 선반영, 실패 시 자동 롤백 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 학습 곡선 | 클라이언트 계층 설계, 미들웨어 ctx 전파 패턴을 익히는 데 초기 시간 필요 |
공식 Quick Start와 미들웨어 문서를 순서대로 따라가는 것을 권장합니다 |
| Vitest 직접 테스트 제약 | "use server" 파일은 Vitest 환경에서 그대로 실행 불가 |
의존성 vi.mock() 전략 + 미들웨어 순수 함수 분리로 대응 |
| 비동기 Server Component 테스트 한계 | Vitest는 async Server Component를 완전히 지원하지 않음 | Playwright E2E 테스트와 병행 |
| Next.js 버전 의존 | useStateAction 훅은 Next.js 15 이상 요구 |
레거시 프로젝트는 버전 확인 후 useAction으로 대체 |
| 추상화 오버헤드 | 인증이 불필요한 공개 액션도 클라이언트 구조를 거쳐야 함 | 공개 API용 publicActionClient를 별도로 두는 것을 권장합니다. 처음엔 레이어가 하나 더 생긴 것 같아 불편했는데, 클라이언트 쪽 에러 처리 코드가 일관되게 단순해지는 걸 보고 생각이 바뀌었습니다. |
용어 보충 — OWASP: Open Web Application Security Project의 약자로, 웹 애플리케이션 보안 취약점 Top 10을 정의하는 비영리 단체입니다. 서버에서 입력을 재검증하지 않는 것은 대표적인 OWASP 위반 사례입니다.
용어 보충 — RBAC: Role-Based Access Control. 사용자의 역할(
admin,user,moderator등)에 따라 접근 권한을 제어하는 방식입니다.
실무에서 가장 흔한 실수
-
미들웨어에서
return next()를 빠뜨리는 것 —next()를 호출만 하고 반환하지 않으면 액션 결과가undefined로 전달됩니다.return await next()를 습관처럼 작성하는 것을 권장합니다. -
vi.mock()을 임포트 구문 아래에 두는 것 — Vitest는vi.mock()을 파일 상단으로 호이스팅하지만, 모킹된 모듈을import한 뒤에vi.mock()을 선언하면 의도치 않은 순서 문제가 생길 수 있습니다.vi.mock()블록은 임포트 전에 위치시키는 것이 명확합니다. -
ctx를 전파하지 않고 덮어쓰는 것 —return next({ ctx: { user } })처럼 작성하면 이전 미들웨어에서 쌓은ctx값이 사라집니다.return next({ ctx: { ...ctx, user } })로 스프레드해 기존 컨텍스트를 유지하는 것이 안전합니다.
마치며
next-safe-action은 Server Action의 타입 안전성과 미들웨어 기반 인증·인가를 구조적으로 결합해, 반복되는 보일러플레이트를 없애고 테스트 가능한 코드를 만들 수 있도록 돕습니다. 이 패턴을 도입한 이후 액션 파일의 테스트 커버리지가 실질적으로 높아졌고, PR 리뷰에서 인증 로직 누락 지적이 눈에 띄게 줄었습니다.
지금 바로 시작해볼 수 있는 3단계:
pnpm add next-safe-action@latest zod후lib/safe-action.ts에actionClient와authActionClient를 정의합니다 — 기존 Auth.js나 Clerk 설정이 있다면 그대로 연결됩니다.- 가장 자주 쓰이는 Server Action 하나를
authActionClient.schema(...).action(...)으로 마이그레이션해봅니다 — 타입 추론이 자동으로 흘러오는 경험을 바로 느낄 수 있습니다. vitest.config.ts에 경로 별칭을 추가하고, 해당 액션의 인증 실패·유효성 실패 케이스 테스트를 작성합니다.