이 글은 누구를 위한 것인가
- Next.js의 API Routes 대신 Server Actions로 폼을 처리하려는 팀
revalidatePath와revalidateTag의 차이를 이해하고 싶은 개발자- 서버 액션에서 파일 업로드와 에러 처리를 구현하려는 팀
들어가며
Next.js 15의 Server Actions는 별도 API 라우트 없이 서버 로직을 컴포넌트 가까이 작성한다. 폼 제출, 데이터베이스 뮤테이션, 캐시 갱신을 하나의 함수로 처리하고, 점진적 향상(Progressive Enhancement)을 기본으로 지원한다.
이 글은 bluefoxdev.kr의 Next.js Server Actions 가이드 를 참고하여 작성했습니다.
1. Server Actions 핵심 개념
[Server Actions 동작 방식]
1. 클라이언트 → 서버 함수 호출
- 폼 제출 시: action 속성으로 자동
- 버튼 클릭: onClick에서 직접 호출
- JS 없이: 네이티브 폼 제출로 폴백
2. 서버에서 실행
- 직접 DB 접근 (ORM 사용 가능)
- 환경변수 접근 (클라이언트 노출 없음)
- 쿠키, 헤더 조작
3. 캐시 갱신
- revalidatePath('/path'): 특정 경로 캐시 무효화
- revalidateTag('tag'): 특정 태그 캐시 무효화
- redirect(): 완료 후 리다이렉트
[보안 주의사항]
Server Actions는 POST 엔드포인트
CSRF: Next.js가 자동으로 Origin 헤더 검증
인증: 반드시 서버 액션 내부에서 세션 확인
입력 검증: Zod로 FormData 검증 필수
권한 검사: 리소스 소유자 확인
2. Server Actions 완전 구현
// app/actions/product.ts
'use server'; // 이 파일의 모든 함수가 Server Action
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
const createProductSchema = z.object({
name: z.string().min(2).max(100),
price: z.coerce.number().positive(),
category: z.string(),
description: z.string().optional(),
});
export async function createProduct(formData: FormData) {
// 1. 인증 확인
const session = await auth();
if (!session?.user?.isAdmin) {
throw new Error('권한이 없습니다');
}
// 2. 입력 검증
const validatedFields = createProductSchema.safeParse({
name: formData.get('name'),
price: formData.get('price'),
category: formData.get('category'),
description: formData.get('description'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
// 3. 데이터베이스 작업
const product = await db.product.create({
data: validatedFields.data,
});
// 4. 캐시 갱신
revalidatePath('/products'); // 목록 페이지 갱신
revalidateTag('products'); // 'products' 태그 캐시 갱신
// 5. 리다이렉트 (폼 재제출 방지)
redirect(`/products/${product.id}`);
}
export async function updateProduct(id: string, formData: FormData) {
const session = await auth();
if (!session?.user?.isAdmin) {
return { error: '권한이 없습니다' };
}
const data = createProductSchema.partial().parse({
name: formData.get('name'),
price: formData.get('price'),
});
await db.product.update({ where: { id }, data });
revalidatePath(`/products/${id}`);
revalidatePath('/products');
return { success: true };
}
export async function deleteProduct(id: string) {
const session = await auth();
if (!session?.user?.isAdmin) {
throw new Error('권한이 없습니다');
}
await db.product.delete({ where: { id } });
revalidatePath('/products');
redirect('/products');
}
// 파일 업로드
export async function uploadProductImage(formData: FormData) {
const session = await auth();
if (!session) throw new Error('로그인 필요');
const file = formData.get('image') as File;
if (!file || file.size === 0) {
return { error: '파일을 선택해주세요' };
}
if (file.size > 5 * 1024 * 1024) {
return { error: '파일 크기는 5MB 이하여야 합니다' };
}
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
return { error: 'JPEG, PNG, WebP 형식만 지원합니다' };
}
// S3 업로드
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadToS3(buffer, file.name, file.type);
return { url };
}
async function uploadToS3(buffer: Buffer, name: string, type: string): Promise<string> {
return `https://cdn.example.com/${name}`;
}
// app/products/new/page.tsx - Server Action 사용
import { createProduct } from '@/app/actions/product';
// 서버 컴포넌트에서 직접 action 전달 (JS 없이도 동작)
export default function NewProductPage() {
return (
<form action={createProduct}>
<input name="name" placeholder="상품명" required />
<input name="price" type="number" placeholder="가격" required />
<select name="category">
<option value="electronics">전자제품</option>
<option value="clothing">의류</option>
</select>
<textarea name="description" placeholder="설명" />
<button type="submit">상품 등록</button>
</form>
);
}
// 클라이언트 컴포넌트에서 향상된 UX
'use client';
import { useActionState, useTransition } from 'react';
import { createProduct } from '@/app/actions/product';
type FormState = {
errors?: Record<string, string[]>;
error?: string;
} | null;
function ProductForm() {
const [state, action, isPending] = useActionState<FormState, FormData>(
createProduct as any,
null
);
return (
<form action={action}>
<div>
<input name="name" placeholder="상품명" />
{state?.errors?.name && (
<p className="text-red-500">{state.errors.name[0]}</p>
)}
</div>
<div>
<input name="price" type="number" placeholder="가격" />
{state?.errors?.price && (
<p className="text-red-500">{state.errors.price[0]}</p>
)}
</div>
{state?.error && (
<p className="text-red-500">{state.error}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? '등록 중...' : '상품 등록'}
</button>
</form>
);
}
// 버튼 클릭으로 Server Action 직접 호출
function ProductActions({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (!confirm('정말 삭제하시겠습니까?')) return;
startTransition(async () => {
await deleteProduct(productId);
});
};
return (
<button onClick={handleDelete} disabled={isPending}>
{isPending ? '삭제 중...' : '삭제'}
</button>
);
}
import { deleteProduct } from '@/app/actions/product';
// revalidatePath vs revalidateTag 전략
// revalidatePath - 특정 URL 캐시 무효화
revalidatePath('/products'); // /products 페이지
revalidatePath('/products/[id]', 'page'); // 모든 상품 상세 페이지
// revalidateTag - 데이터 태그로 무효화
// fetch에서 태그 설정:
// fetch('/api/products', { next: { tags: ['products'] } })
revalidateTag('products'); // 'products' 태그가 붙은 모든 캐시 무효화
// 사용 전략:
// 특정 페이지가 변경됐을 때: revalidatePath
// 특정 데이터가 변경됐을 때 여러 페이지 갱신: revalidateTag
마무리
Server Actions는 API Routes의 boilerplate를 제거한다. CRUD 작업마다 /api/products, /api/products/[id] 같은 라우트를 만들 필요 없이, 컴포넌트 옆에 actions.ts를 두고 직접 호출한다. 보안 핵심: 인증과 권한 확인은 반드시 Server Action 내부에서 해야 한다. 클라이언트에서 전달하는 모든 값은 조작될 수 있다고 가정하고 서버에서 다시 검증해야 한다.