`next-safe-action`으로 Next.js Server Actions에 인증과 레이트리밋을 붙이는 타입 안전 파이프라인
Next.js에서 Server Actions를 처음 써봤을 때 "오, 이거 편하다" 싶었는데, 실제 프로덕션에 올리려니 막막한 부분이 생겼습니다. 인증 확인 코드를 매 액션마다 복붙하고, 레이트리밋은 어디에 어떻게 붙여야 하는지 갈피를 못 잡고, 타입은 흐릿하게 any로 퉁치고 있는 자신을 발견하게 된 거죠. 아마 비슷한 경험이 있는 분들이 꽤 계실 겁니다.
한 가지 더 솔직하게 말씀드리면, 인증 없이 노출된 Server Actions는 사실상 공개 API 엔드포인트와 다름없습니다. Next.js의 편의성에 속아서 "어차피 클라이언트에서만 호출하니까 괜찮겠지"라고 생각하기 쉬운데, 악의적인 요청자는 curl 한 줄로 액션을 직접 호출할 수 있습니다. 레이트리밋 없이 DB를 직접 건드리는 액션이 무방비로 노출돼 있다면 DDoS는 물론 데이터 유출로도 이어질 수 있습니다.
next-safe-action은 그 문제를 정면으로 해결해 줍니다. 타입 안전한 미들웨어 파이프라인을 통해 인증, 레이트리밋, 입력 검증을 한 곳에서 선언적으로 묶어낼 수 있거든요. 이 글을 읽고 나면 인증과 레이트리밋이 붙은 Server Action을 30줄 이내로 정의할 수 있고, 새 액션을 추가할 때마다 반복 코드 없이 보안 파이프라인이 자동으로 적용되는 구조를 갖출 수 있습니다. 기본 클라이언트 설정부터 시작해서 인증 미들웨어와 Upstash 기반 레이트리밋을 단계적으로 쌓아 올리고, 실제 액션과 React 컴포넌트에 연결하는 전체 흐름을 살펴봅니다.
핵심 개념
사전 지식과 버전 정보
이 글은 다음 세 가지를 이미 사용하고 있는 상황을 전제합니다.
- Next.js App Router — Server Actions가 있는 환경
- TypeScript strict mode — 타입 안전성을 최대한 활용하기 위해
- 인증 라이브러리 — 예시에서는 Better Auth를 사용하지만, 어떤 라이브러리든 세션을 조회한 뒤
next({ ctx })에 담으면 동일한 패턴이 적용됩니다
현재 최신 버전은 v8.1.8 (2026년 5월 기준)입니다. v6→v7→v8 사이에 브레이킹 체인지가 있었으니, 기존 코드베이스에 도입한다면 공식 마이그레이션 가이드를 먼저 확인해보시는 것을 권장합니다.
Action Client — 파이프라인의 시작점
next-safe-action의 모든 것은 createSafeActionClient()에서 출발합니다. 이 클라이언트 인스턴스를 기반으로 미들웨어를 체이닝하고, 최종적으로 개별 액션을 정의합니다.
// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";
export const actionClient = createSafeActionClient({
handleServerError(e) {
if (e instanceof Error) return e.message;
return "An unexpected error occurred.";
},
});
handleServerError: Server Action 내부에서 발생한 에러를 클라이언트에 노출하기 전에 가공하는 훅입니다. 스택 트레이스나 민감한 내부 정보가 그대로 노출되는 것을 막아주는 역할을 합니다. 프로덕션에서는 에러 로깅 서비스(Sentry 등)를 여기에 연결해두는 것이 좋습니다.
실무에서는 이 기본 클라이언트를 뿌리로 삼아 트리 형태로 확장하는 패턴을 씁니다. 인증이 필요한 액션용 클라이언트, 거기다 레이트리밋까지 붙인 클라이언트를 계층적으로 만들어두면, 각 액션에서 상황에 맞는 클라이언트를 골라 쓸 수 있습니다.
미들웨어 체인 — .use()와 .useValidated()
두 가지 미들웨어 메서드가 있고, 실행 시점이 다릅니다. 저도 처음엔 이 둘의 차이를 무시하고 전부 .use()에 넣었다가, 검증 전 단계에서 parsedInput에 접근하려고 하니 타입 에러가 났었습니다.
| 메서드 | 실행 시점 | 주요 용도 |
|---|---|---|
.use() |
입력 유효성 검사 이전 | 인증 세션 확인, 레이트리밋, 로깅 |
.useValidated() |
입력 유효성 검사 이후 | 리소스 소유권 확인, 비즈니스 로직 가드 |
파이프라인의 실행 순서를 도식화하면 이렇습니다:
.use() 미들웨어들 → 입력 유효성 검사 (schema) → .useValidated() 미들웨어들 → .action().useValidated()는 검증이 끝난 parsedInput을 온전히 타입 추론된 상태로 받아볼 수 있다는 게 핵심입니다. next() 함수로 다음 미들웨어에 컨텍스트를 전달할 때, 컨텍스트를 그대로 유지하려면 return next({ ctx })를, 새 값을 추가하려면 return next({ ctx: { ...ctx, newProp } })로 확장하는 방식이 좋습니다. 기존 ctx를 통째로 덮어쓰면 이전 미들웨어에서 넣은 값이 사라지는 버그가 생길 수 있습니다.
Standard Schema v1 — validator 선택의 자유
Standard Schema v1은 TypeScript 생태계의 여러 validator 라이브러리(Zod, Valibot, ArkType 등)가 공통으로 구현하는 인터페이스 규약입니다.
next-safe-actionv8부터 이 규약을 지원해서, 특정 라이브러리에 종속되지 않고 팀에서 선호하는 validator를 그대로 사용할 수 있습니다.
예시 코드에서는 Zod를 사용하지만, Valibot이나 ArkType으로 교체해도 동일하게 동작합니다.
언제 쓰면 좋은가
다음 상황 중 하나라도 해당된다면 도입을 고려해볼 만합니다.
- 여러 Server Actions에 동일한 인증 확인 코드를 반복하고 있을 때
- 레이트리밋이 필요한데 어디에 어떻게 넣어야 할지 막막할 때
- 액션의 입력·출력 타입을 클라이언트에서 직접 추론하고 싶을 때
- 기존 Server Actions는 건드리지 않고 새 액션부터 점진적으로 개선하고 싶을 때
반대로, 인증도 없이 공개 접근을 허용하는 단순한 CRUD 하나라면 오버엔지니어링이 될 수 있습니다.
실전 적용
서버 사이드 파이프라인 구성
예시 1: 기본 클라이언트와 인증 미들웨어
Better Auth를 기준으로 설명드립니다. 신규 프로젝트라면 Better Auth가 현재 가장 자연스러운 선택이고, next-safe-action 공식 문서에도 통합 가이드가 별도로 제공됩니다. 다만 NextAuth나 Clerk를 쓰더라도 방식은 동일합니다. 세션을 조회한 뒤 next({ ctx: { user, session } })에 담아주면 됩니다.
// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";
import { headers } from "next/headers";
import { auth } from "@/lib/auth"; // Better Auth 인스턴스
// 1단계: 기본 클라이언트
export const actionClient = createSafeActionClient({
handleServerError(e) {
if (e instanceof Error) return e.message;
return "An unexpected error occurred.";
},
});
// 2단계: 인증 클라이언트 (세션 없으면 즉시 차단)
export const authActionClient = actionClient.use(async ({ next }) => {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) throw new Error("Unauthorized");
return next({
ctx: {
user: session.user,
session: session.session,
},
});
});| 코드 포인트 | 설명 |
|---|---|
auth.api.getSession() |
Better Auth의 세션 조회 API. 플러그인 타입 포함 완전한 타입 추론 |
headers() |
Next.js의 headers() 함수로 요청 헤더 전달. await 필요 (Next.js 15+) |
throw new Error("Unauthorized") |
미들웨어에서 던진 에러는 handleServerError를 거쳐 클라이언트에 전달 |
next({ ctx: ... }) |
이후 미들웨어와 .action()에서 ctx.user, ctx.session으로 접근 가능 |
예시 2: 레이트리밋 미들웨어 추가 (Upstash)
처음엔 레이트리밋을 actionClient에 바로 붙여봤는데, 인증 전에 제한이 걸리면 사용자 ID를 못 쓰더라고요. 그래서 인증 미들웨어 다음에 체이닝하는 패턴이 맞습니다. 이미 ctx.user.id가 확보된 상태라, 익명 IP 대신 인증된 사용자 ID를 식별자로 쓸 수 있습니다.
// lib/safe-action.ts (이어서)
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
// Redis.fromEnv()는 UPSTASH_REDIS_REST_URL과 UPSTASH_REDIS_REST_TOKEN 환경 변수를 읽습니다
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "60s"), // 60초 슬라이딩 윈도우, 최대 5회
});
export const rateLimitedAuthClient = authActionClient.use(
async ({ next, ctx }) => {
const { success } = await ratelimit.limit(ctx.user.id);
if (!success) throw new Error("Too many requests. Please try again later.");
return next({ ctx }); // ctx를 그대로 전파
}
);슬라이딩 윈도우(Sliding Window): 고정된 구간이 아니라 현재 시각 기준으로 과거 N초를 계산하는 방식. Fixed Window 대비 경계 시점의 버스트 트래픽을 더 효과적으로 제어합니다.
Upstash를 처음 설정할 때 .env.local에 UPSTASH_REDIS_REST_URL과 UPSTASH_REDIS_REST_TOKEN 두 값을 넣어야 합니다. Upstash 대시보드에서 Redis 인스턴스를 만들면 두 값을 바로 확인할 수 있습니다. 환경 변수 오타로 첫 연결에서 막히는 경우가 꽤 많으니 콘솔을 꼭 열어두시면 좋습니다.
| 코드 포인트 | 설명 |
|---|---|
Redis.fromEnv() |
UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN 환경 변수 자동 로드 |
Ratelimit.slidingWindow(5, "60s") |
60초 슬라이딩 윈도우 기준 최대 5회 허용 |
ratelimit.limit(ctx.user.id) |
사용자 ID 기반 제한 — IP 기반보다 스푸핑에 안전 |
return next({ ctx }) |
ctx를 확장 없이 그대로 다음 단계로 전달 |
예시 3: 액션 정의 — 소유권 확인과 프로필 업데이트
앞서 만든 rateLimitedAuthClient에 스키마와 액션 본문을 붙입니다. .useValidated()는 스키마 검증이 끝난 이후에 실행되므로 parsedInput에 완전한 타입으로 접근할 수 있습니다. "소유권 확인을 여기서 하겠다고 설명했으면서 코드는 비어 있더라"는 경험을 드리지 않기 위해, 실제 소유권 체크 예시를 넣어봤습니다.
// actions/update-profile.ts
"use server";
import { z } from "zod";
import { rateLimitedAuthClient } from "@/lib/safe-action";
import { db } from "@/lib/db"; // Prisma 클라이언트
export const updateProfileAction = rateLimitedAuthClient
.schema(
z.object({
userId: z.string(),
name: z.string().min(1).max(50),
bio: z.string().max(200).optional(),
})
)
.useValidated(async ({ next, parsedInput, ctx }) => {
// 검증된 parsedInput에 접근 가능한 시점 — 소유권 확인
if (parsedInput.userId !== ctx.user.id) {
throw new Error("Forbidden: 다른 사용자의 프로필은 수정할 수 없습니다.");
}
return next({ ctx });
})
.action(async ({ parsedInput, ctx }) => {
// ctx.user, ctx.session 완전한 타입 추론
await db.user.update({
where: { id: ctx.user.id },
data: {
name: parsedInput.name,
bio: parsedInput.bio, // optional()이므로 undefined가 될 수 있음 — Prisma는 undefined 필드를 업데이트 대상에서 제외
},
});
return { success: true };
});| 코드 포인트 | 설명 |
|---|---|
.useValidated()의 소유권 확인 |
parsedInput.userId !== ctx.user.id로 타인의 리소스 수정 차단 |
return next({ ctx }) |
ctx를 그대로 전파. 값을 추가하려면 { ctx: { ...ctx, extra } } 형태로 확장 |
parsedInput.bio |
optional()이므로 undefined 가능. Prisma는 undefined 필드를 업데이트 대상에서 제외 |
클라이언트 연동
예시 4: React 컴포넌트에서 useAction 훅으로 호출
useAction 훅의 onError 콜백에는 두 가지 에러 유형이 함께 옵니다. validationErrors는 Zod 스키마 검증 실패, serverError는 미들웨어나 액션에서 던진 에러입니다. 타입 안전 라이브러리를 쓰는 만큼, 두 가지를 구분해서 처리하면 사용자에게 훨씬 명확한 피드백을 줄 수 있습니다.
// components/profile-form.tsx
"use client";
import { useAction } from "next-safe-action/hooks";
import { updateProfileAction } from "@/actions/update-profile";
import { toast } from "sonner";
export function ProfileForm({ userId }: { userId: string }) {
const { execute, isExecuting } = useAction(updateProfileAction, {
onSuccess: () => toast.success("프로필이 업데이트됐습니다!"),
onError: ({ error }) => {
if (error.validationErrors) {
// Zod 스키마 검증 실패 — 필드별 에러 메시지 처리 가능
toast.error("입력값을 다시 확인해주세요.");
} else {
// 미들웨어나 액션에서 던진 서버 에러
toast.error(error.serverError ?? "오류가 발생했습니다.");
}
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
execute({
userId,
name: data.get("name") as string,
bio: (data.get("bio") as string | undefined) || undefined, // optional 필드 처리
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" type="text" />
<textarea name="bio" />
<button type="submit" disabled={isExecuting}>
{isExecuting ? "저장 중..." : "저장"}
</button>
</form>
);
}Redis 없이 통합 보안 적용하기
예시 5: Arcjet으로 레이트리밋·봇 감지·WAF 한 번에
Redis 인프라를 별도로 운영하기 부담스럽다면 Arcjet도 좋은 선택입니다. 레이트리밋과 봇 감지, WAF를 한 번에 처리할 수 있어서 설정이 상당히 간결합니다.
// lib/safe-action.ts (Arcjet 버전)
import arcjet, { shield, detectBot, fixedWindow } from "@arcjet/next";
import { headers } from "next/headers";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
shield({ mode: "LIVE" }), // WAF — SQL 인젝션, XSS 등 차단
detectBot({ mode: "LIVE", allow: [] }), // 봇 트래픽 차단
fixedWindow({ mode: "LIVE", window: "60s", max: 5 }), // 레이트리밋
],
});
export const secureActionClient = actionClient.use(async ({ next }) => {
const decision = await aj.protect(await headers());
if (decision.isDenied()) throw new Error("Request blocked.");
return next({});
});주의:
aj.protect(await headers())는 간략화된 예시입니다. Arcjet Next.js SDK는 버전에 따라NextRequest객체를 요구하거나 별도 헬퍼를 사용하는 방식이 다를 수 있습니다. 실제 적용 전에 공식 Arcjet Next.js 예제를 확인해보시는 것을 권장합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 완전한 타입 추론 | 스키마 → 미들웨어 ctx → 액션 본문까지 수동 타입 선언 없이 TypeScript가 따라옴 |
| 관심사 분리 | 인증, 레이트리밋, 로깅을 미들웨어로 분리해 각 액션의 보일러플레이트 제거 |
| validator 유연성 | Standard Schema v1 덕분에 Zod·Valibot·ArkType 중 원하는 것 선택 가능 |
| React 훅 내장 | useAction, useOptimisticAction으로 로딩 상태와 낙관적 업데이트를 간결하게 처리 |
| 점진적 도입 | 기존 액션을 건드리지 않고 새 액션부터 선택적으로 적용 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Next.js 미들웨어와 혼동 | middleware.ts(Edge Runtime)와 .use() 미들웨어는 완전히 별개 |
인증 검사는 반드시 Server Actions 내부에서 수행 |
| 잦은 API 변경 | v6→v7→v8 마이너 메이저 간 브레이킹 체인지 존재 | 버전 고정 후 공식 마이그레이션 가이드 참조 |
| IP 추출 복잡성 | Server Actions는 Request 객체를 직접 노출하지 않음 |
headers()로 헤더 추출. Vercel 환경은 x-real-ip, Cloudflare는 CF-Connecting-IP 헤더 활용 권장 |
| 미들웨어 순서 제약 | .useValidated() 이후에 .use() 추가 불가 |
사전 검증 미들웨어를 모두 .use()로 먼저 등록 |
Edge Runtime vs Node.js Runtime: Next.js의
middleware.ts는 Edge Runtime에서 실행돼 응답 속도는 빠르지만 Node.js API 접근이 제한됩니다. 반면 Server Actions는 Node.js Runtime에서 실행되므로 DB 접근 등이 자유롭습니다. 두 레이어의 역할을 섞지 않는 것이 좋습니다.
실무에서 가장 흔한 실수
-
Next.js
middleware.ts에 인증 로직을 전부 올리는 것: Edge Runtime의 세션 검증은 진짜 보안 경계가 아닙니다. Server Actions 내부에서 반드시 재검증이 필요하고,next-safe-action의 미들웨어가 바로 그 역할을 합니다. -
레이트리밋을 인증 전에 IP 기반으로만 거는 것: 프록시나 CDN 환경에서는
x-forwarded-for헤더가 스푸핑될 수 있습니다. 인증 미들웨어 다음에 레이트리밋을 배치해 사용자 ID 기반으로 제한하면 훨씬 안정적입니다. -
.useValidated()없이.action()내부에서 소유권 확인을 하는 것: 가능은 하지만 관심사가 섞입니다. 소유권·권한 확인처럼 검증된 입력이 필요한 로직은.useValidated()로 분리해두면 액션 본문이 훨씬 깔끔해집니다.
마치며
이 구조를 프로젝트에 얹고 나면 달라지는 것이 있습니다. 새 Server Action을 추가할 때 인증, 레이트리밋, 소유권 확인을 매번 새로 짜지 않아도 됩니다. rateLimitedAuthClient를 골라 스키마와 액션 본문만 붙이면 보안 파이프라인이 자동으로 따라옵니다. 코드 리뷰할 때 "인증 넣었어요?"라는 질문도 자연스럽게 사라집니다.
지금 바로 시작해볼 수 있는 3단계:
pnpm add next-safe-action zod로 의존성을 설치하고,lib/safe-action.ts에 기본actionClient를 만들어볼 수 있습니다.handleServerError하나만 달아도 기존 액션보다 훨씬 안전한 시작점이 됩니다.- 프로젝트에서 가장 중요한 Server Action 하나를 골라
authActionClient로 마이그레이션해볼 수 있습니다. ctx를 따라 타입이 자동으로 흘러오는 경험을 직접 느껴보시면 "왜 이걸 이제 알았지"가 될 겁니다. pnpm add @upstash/ratelimit @upstash/redis를 추가하고, Upstash 대시보드에서 Redis 인스턴스를 만들어UPSTASH_REDIS_REST_URL과UPSTASH_REDIS_REST_TOKEN을.env.local에 설정해볼 수 있습니다. 프로토타입이나 소규모 서비스라면 무료 티어로 시작할 수 있습니다. 환경 변수 오타로 첫 연결에서 막히는 경우가 많으니 콘솔을 꼭 열어두시면 좋습니다.
참고 자료
- next-safe-action 공식 문서
- Middleware | next-safe-action 공식 문서
- Better Auth | next-safe-action 공식 통합 가이드
- GitHub - TheEdoRan/next-safe-action
- Migration from v6 to v7 | next-safe-action
- Next.js Server Actions Security: 5 Vulnerabilities You Must Fix — MakerKit
- Next.js server action security — Arcjet Blog
- GitHub - arcjet/example-nextjs-server-action
- Rate-limiting Server Actions in Next.js | Next.js Weekly
- Rate Limiting Next.js API Routes using Upstash Redis | Upstash Blog
- GitHub - upstash/ratelimit-js