Next.js 15 서버 액션(Server Actions)은 클라이언트에서 직접 호출할 수 있는 비동기 서버 함수로, 별도의 API 라우트를 만들지 않고도 폼 제출과 데이터 변경(mutation)을 처리할 수 있게 해주는 React 19 기반 기능입니다. 파일 또는 함수 상단에 'use server' 지시문을 추가하면 Next.js가 자동으로 POST 엔드포인트를 생성하고, CSRF 보호, 직렬화, 에러 전파, 그리고 자바스크립트가 꺼진 환경에서의 점진적 향상(progressive enhancement)까지 처리해 줍니다. 이 가이드에서는 폼 처리, useActionState 훅, Zod 검증, 캐시 무효화, 그리고 프로덕션에서 반드시 알아야 할 보안 패턴을 실제 코드와 함께 다룹니다.
서버 액션은 'use server' 지시문으로 선언하는 비동기 서버 함수로, 데이터 변경(mutation)에 최적화되어 있으며 데이터 조회에는 서버 컴포넌트나 라우트 핸들러를 사용해야 합니다.
React 19에서 useFormState가 useActionState로 대체되었고, useFormStatus와 결합하면 로딩 상태와 에러 상태를 명확하게 관리할 수 있습니다.
Next.js는 동일 출처(Same-Origin) 검사와 암호화된 액션 ID로 기본적인 CSRF 보호를 제공하지만, 모든 액션은 여전히 공개 HTTP 엔드포인트이므로 인증·인가·입력 검증을 직접 수행해야 합니다.
리버스 프록시나 멀티 도메인 환경에서는 next.config.js의 serverActions.allowedOrigins를 반드시 설정해야 요청이 차단되지 않습니다.
변경 후 캐시 갱신은 단일 경로일 때 revalidatePath, 여러 페이지에 공통으로 쓰이는 데이터일 때 revalidateTag를 사용하는 것이 정석입니다.
예외를 던지는 대신 구조화된 에러 객체를 반환하고, useActionState로 UI에 표시하면 사용자 경험과 디버깅이 모두 개선됩니다.
서버 액션이란 무엇인가
서버 액션은 Next.js App Router에서 서버에서만 실행되도록 보장되는 비동기 함수입니다. React 19와 함께 안정화되어 Next.js 15부터 기본적으로 활성화되며, 'use server' 지시문을 함수 상단(또는 파일 최상단)에 두면 컴파일러가 해당 함수를 클라이언트에서 직접 호출할 수 있는 RPC 엔드포인트로 변환합니다. 클라이언트 번들에는 함수 본문이 포함되지 않고, 대신 암호화된 액션 ID 참조만 남습니다.
가장 큰 강점은 위치(co-location)입니다. 폼이 정의된 컴포넌트 옆에 변경 로직을 두면 데이터 흐름이 한눈에 들어오고, 별도의 API 핸들러, 클라이언트 패치 훅, fetch wrapper 같은 보일러플레이트가 사라집니다. 또한 <form action={...}>에 서버 액션을 직접 바인딩하면 자바스크립트가 비활성화된 브라우저에서도 폼이 동작하므로 자연스러운 점진적 향상이 가능합니다. 모바일 환경이나 접근성을 고려해야 하는 서비스에서 특히 가치가 큽니다.
저는 최근 한 SaaS 프로젝트에서 30개 이상의 mutation API 라우트를 서버 액션으로 옮긴 적이 있는데, 코드량은 약 40% 줄었고, 클라이언트 번들에서 mutation 관련 코드가 거의 사라졌습니다. 이는 LCP(Largest Contentful Paint) 측면에서도 의미 있는 개선이었습니다.
서버 액션과 API 라우트의 차이는 무엇인가
두 기능 모두 서버에서 코드를 실행한다는 점은 같지만, 설계 의도와 사용 패턴이 다릅니다. 서버 액션은 자체 앱의 데이터 변경(mutation)을 위한 RPC로 설계되었고, 라우트 핸들러(Route Handler, app/api/.../route.ts)는 공개 HTTP API를 만들기 위한 도구입니다.
항목
서버 액션
라우트 핸들러(Route Handler)
주 용도
폼 제출, 자체 UI의 데이터 변경
외부 노출 API, 웹훅, 서드파티 호출
HTTP 메서드
POST 전용
GET, POST, PUT, DELETE 등 자유
CSRF 보호
내장 (Origin/Host 검사)
수동 구현 필요
호출 방식
함수 import 후 직접 호출
fetch / HTTP 클라이언트
응답 캐싱
지원하지 않음 (mutation 전용)
fetch 옵션으로 제어 가능
점진적 향상
JS 없이도 동작
JS 필수
타입 안전성
함수 시그니처 그대로
경계에서 직렬화·역직렬화 필요
실무에서는 다음 기준으로 결정합니다. 내가 만든 UI가 내가 만든 백엔드 로직을 호출한다면 서버 액션이 정답입니다. 반면 모바일 앱이 같은 엔드포인트를 호출하거나, Stripe 같은 외부 서비스가 웹훅을 보내거나, 다른 도메인의 클라이언트가 접근해야 한다면 라우트 핸들러를 사용해야 합니다. 두 가지를 혼합해서 사용하는 것도 정상적인 패턴입니다.
첫 서버 액션 만들기: 기본 폼 처리
가장 단순한 형태부터 보겠습니다. 액션 함수를 별도 파일로 분리하는 것이 권장되는 패턴인데, 이렇게 하면 동일한 액션을 여러 컴포넌트에서 재사용할 수 있고, 클라이언트 컴포넌트에서도 안전하게 import할 수 있습니다.
// app/actions/posts.ts
'use server';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
export async function createPost(formData: FormData) {
const title = formData.get('title')?.toString().trim();
const content = formData.get('content')?.toString().trim();
if (!title || !content) {
throw new Error('제목과 본문은 필수입니다.');
}
const [post] = await db
.insert(posts)
.values({ title, content, createdAt: new Date() })
.returning({ id: posts.id });
revalidatePath('/posts');
redirect(`/posts/${post.id}`);
}
이제 이 액션을 서버 컴포넌트의 <form>에 직접 연결합니다. 자바스크립트 번들에는 액션 ID만 들어가고 실제 함수는 클라이언트로 전송되지 않습니다.
React 19에서 useFormState는 useActionState로 이름이 바뀌고 시그니처가 정리되었습니다. 이 훅은 액션의 이전 상태와 최신 반환값을 추적해 폼 검증 에러나 성공 메시지를 UI에 표시할 때 필수적입니다. 핵심은 액션이 예외를 던지는 대신 직렬화 가능한 상태 객체를 반환하도록 작성하는 것입니다.
서버 액션은 외부에서 호출 가능한 HTTP 엔드포인트라는 사실을 잊어서는 안 됩니다. 클라이언트 측 HTML 검증(required, pattern)은 UX 개선용일 뿐, 누구나 fetch로 임의의 페이로드를 보낼 수 있습니다. 따라서 모든 서버 액션은 진입점에서 입력을 검증해야 합니다. Zod는 가장 보편적이고 타입스크립트 추론이 강력한 검증 라이브러리입니다.
safeParse를 쓰는 이유는 예외 대신 결과 객체를 받기 위해서입니다. 액션이 예외를 던지면 React 19는 가까운 error.tsx 경계로 전파시키는데, 폼 필드 단위 에러 표시에는 적합하지 않습니다. 검증 실패는 "예상된 결과"로 다루는 것이 옳습니다. 비밀번호처럼 민감한 정보는 반환 상태에 절대 포함시키지 마세요 — 사용자가 다시 입력하도록 안내해야 합니다.
revalidatePath vs revalidateTag: 캐시 무효화 전략
Next.js 15에서 데이터 변경 후 화면이 즉시 반영되지 않는 가장 흔한 원인은 캐시 무효화를 누락했기 때문입니다. 두 가지 함수를 상황에 맞게 골라 써야 합니다.
revalidatePath(path)는 특정 라우트의 데이터와 풀 라우트 캐시를 무효화합니다. 변경된 데이터가 어떤 페이지에 영향을 주는지 명확할 때 가장 직관적입니다. 동적 세그먼트도 지원하므로 revalidatePath('/posts/[id]', 'page')처럼 호출할 수 있습니다.
revalidateTag(tag)는 fetch 호출 시 부여한 태그를 기준으로 무효화합니다. 한 데이터가 여러 페이지에 흩어져 있을 때(예를 들어 사용자 프로필이 헤더, 사이드바, 설정 페이지에 모두 표시될 때) 한 번의 호출로 모두 갱신할 수 있습니다.
// fetch에 태그 부여
const user = await fetch(`/api/users/${id}`, {
next: { tags: [`user:${id}`] },
}).then(r => r.json());
// 액션 안에서 무효화
'use server';
import { revalidateTag } from 'next/cache';
export async function updateProfile(formData: FormData) {
const id = formData.get('id') as string;
await db.update(users).set({ /* ... */ }).where(eq(users.id, id));
revalidateTag(`user:${id}`);
}
실시간성이 더 중요한 부분에서는 cookies()나 headers()처럼 캐시를 자동으로 무효화하는 동적 API를 사용하거나, { cache: 'no-store' }로 fetch를 설정하는 방법도 있습니다. 다만 이는 빌드 시 정적 최적화를 포기하는 것이므로 LCP에 영향을 줄 수 있습니다.
서버 액션 보안: CSRF, allowedOrigins, 인증
"서버 액션은 안전한가?"라는 질문에 짧게 답하면 "기본 보호는 있지만 인증·인가는 직접 해야 한다"입니다. Next.js가 무엇을 자동으로 처리하고 무엇을 개발자가 책임져야 하는지 정확히 구분해야 합니다.
Next.js가 자동으로 처리하는 보호
POST 전용: 서버 액션은 POST 메서드로만 호출되므로 대부분의 단순한 CSRF 공격이 차단됩니다.
좋은 에러 처리는 두 종류의 실패를 구분합니다. 예상된 실패(검증 에러, 중복 이메일, 권한 부족)는 반환값으로 표현하고, 예상치 못한 실패(DB 연결 끊김, 외부 API 타임아웃)는 예외로 던집니다. 전자는 useActionState가 받아 UI에 표시하고, 후자는 가까운 error.tsx로 전파됩니다.
낙관적 업데이트(optimistic UI)가 필요한 경우 React 19의 useOptimistic을 함께 사용하면 서버 응답을 기다리지 않고도 즉각 반응하는 UI를 만들 수 있습니다. 사용자가 "좋아요" 버튼을 누르는 순간 카운트가 올라가고, 액션이 실패하면 자동으로 롤백되는 식입니다. 자세한 API는 React 19의 useOptimistic 문서에 잘 정리되어 있습니다.
흔한 안티 패턴과 피해야 할 함정
실무에서 자주 보는 실수들을 정리합니다.
서버 액션으로 데이터를 조회하기: 액션은 POST 전용이라 캐시되지 않습니다. 조회는 서버 컴포넌트에서 직접 await하거나 라우트 핸들러를 쓰세요.
여러 액션을 한 파일에 섞고 일부만 'use server' 추가: 지시문은 파일 최상단에 두는 것이 가독성과 빌드 안전성 모두에 좋습니다.
액션에서 cookies() 설정 후 같은 액션에서 redirect: 동작은 하지만 redirect는 내부적으로 예외를 던지므로 try/catch로 감싸면 안 됩니다. 검증·DB 작업이 끝난 가장 마지막에 호출하세요.
큰 파일 업로드를 액션으로 처리: 기본 본문 크기 제한은 1MB입니다. 이미지·동영상은 presigned URL이나 별도 업로드 핸들러를 사용하세요.
에러를 console.log만 하고 사용자에게 알리지 않기: 액션이 조용히 실패하면 사용자는 "버튼이 안 눌린다"고 느낍니다. 항상 상태로 표현하거나 토스트를 띄우세요.
액션 안에서 fetch('/api/...') 호출: 자기 자신의 라우트 핸들러를 호출하는 것은 불필요한 HTTP 왕복입니다. 동일한 로직을 함수로 분리해 직접 호출하세요.
자주 묻는 질문
서버 액션은 클라이언트 컴포넌트에서도 호출할 수 있나요?
네, 가능합니다. 'use server' 파일에서 export한 함수를 클라이언트 컴포넌트에서 import해 호출하면 Next.js 컴파일러가 자동으로 POST 요청으로 변환합니다. 함수 본문 자체는 클라이언트 번들에 포함되지 않고 암호화된 액션 ID만 남습니다.
서버 액션과 tRPC, React Query 중 무엇을 써야 하나요?
자체 Next.js 앱 내부의 mutation이면 서버 액션이 가장 적은 보일러플레이트로 충분합니다. 모바일 앱이나 외부 클라이언트와 같은 백엔드를 공유해야 하면 tRPC 또는 라우트 핸들러를 사용하고, 복잡한 조회와 캐싱·낙관적 업데이트가 많다면 React Query를 라우트 핸들러와 함께 쓰는 조합도 좋습니다.
서버 액션에서 파일 업로드는 어떻게 처리하나요?
작은 파일(1MB 이하)은 FormData에서 File 객체로 직접 추출할 수 있습니다. 더 큰 파일은 serverActions.bodySizeLimit을 늘리거나, S3·R2 같은 객체 스토리지의 presigned URL을 받아 브라우저가 직접 업로드하도록 만드는 방법이 표준입니다.
왜 revalidatePath를 호출했는데도 UI가 갱신되지 않나요?
가장 흔한 원인은 (1) 캐시를 무효화한 경로와 화면이 실제로 표시되는 경로가 다른 경우, (2) fetch에 { cache: 'force-cache' }를 명시했지만 태그를 부여하지 않은 경우, (3) 라우터 캐시(클라이언트) 때문에 router.refresh()가 추가로 필요한 경우입니다. 동적 세그먼트는 두 번째 인자 'page' 또는 'layout'을 명시해야 정확히 매칭됩니다.
서버 액션 호출이 "Invalid Server Action" 에러로 거부됩니다.
대부분 Origin 검사 실패이거나, 셀프 호스팅 환경에서 빌드별 암호화 키가 동기화되지 않은 경우입니다. 리버스 프록시 뒤라면 serverActions.allowedOrigins를 설정하고, 멀티 인스턴스라면 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 환경 변수를 모든 노드에 동일하게 배포하세요.