Next.js 15 Server Actions: 폼과 뮤테이션의 새로운 패러다임

프론트엔드

Next.js 15Server Actions폼 처리캐시App Router

이 글은 누구를 위한 것인가

  • Next.js의 API Routes 대신 Server Actions로 폼을 처리하려는 팀
  • revalidatePathrevalidateTag의 차이를 이해하고 싶은 개발자
  • 서버 액션에서 파일 업로드와 에러 처리를 구현하려는 팀

들어가며

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 내부에서 해야 한다. 클라이언트에서 전달하는 모든 값은 조작될 수 있다고 가정하고 서버에서 다시 검증해야 한다.