Dev Thinking
21완료

서버 액션으로 C/U/D – 폼·검증·캐시 무효화

2025-09-27
8분 읽기

공식문서 기반의 Next.js 입문기

들어가며

4-1편에서는 검색어와 페이지 번호를 URL에 고정해 공유 가능한 상태를 만들었습니다. 이제 검색된 데이터를 실제로 변경하는 단계로 넘어가겠습니다.

리액트(CSR)에서는 사용자가 폼에 입력한 데이터를 브라우저에서 먼저 확인하고 서버에 저장 요청을 보냅니다. 하지만 페이지를 새로고침하거나 다른 사람에게 링크를 공유하면 방금 저장한 데이터가 사라집니다. 데이터가 변경되었을 때 화면을 즉시 업데이트하는 것도 까다롭습니다.

Next.js에서는 서버 액션(Server Actions)으로 이 문제를 해결합니다. 폼 데이터를 서버에서 직접 처리하고, 검증·저장·캐시 무효화까지 한 번에 처리하는 전략입니다.

서버 액션 기반 C/U/D 원칙

서버 액션은 Next.js App Router에서 서버 측 코드를 직접 실행할 수 있게 하는 기능입니다. "use server" 지시어로 선언한 함수를 클라이언트에서 호출하면, Next.js가 자동으로 서버 환경에서 실행하고 결과를 반환합니다.

특히 폼 데이터 처리에 특화되어 있어, HTML <form action={서버액션함수}>으로 제출하면 Next.js가 FormData 객체를 서버 액션에 전달합니다. 서버 액션은 이 데이터를 받아 검증·저장·캐시 무효화·리다이렉트까지 모든 처리를 한 번에 담당합니다.

왜 이렇게 할까요? 클라이언트와 서버 사이의 데이터 흐름을 자연스럽게 만들기 위해서입니다. 사용자 입력부터 UI 업데이트까지 한 번에 처리하면 복잡한 상태 관리를 피할 수 있습니다. 각자의 역할은 이렇게 나눕니다:

  • 클라이언트 컴포넌트: 사용자 인터페이스(UI)와 즉시 피드백 담당 (예: 폼 표시, 로딩 상태)
  • 서버 액션: 데이터 처리 로직 담당 (예: 입력 검증, DB 저장, 캐시 정리, 페이지 이동)

이 전략은 revalidatePathrevalidateTag 같은 캐시 제어와 결합하면 데이터 변경 후 즉시 UI를 업데이트하는 정교한 사용자 경험을 만들 수 있습니다.

참고: Next.js는 데이터를 캐시하여 빠른 응답을 제공합니다. 데이터 변경 후 캐시는 수동으로 업데이트해야 하는데, revalidatePath('/invoices')는 특정 페이지 캐시를 무효화하고, revalidateTag('user-profile')는 태그로 묶인 데이터만 선택적으로 제거합니다. 태그는 서버 컴포넌트의 fetch 옵션으로 tags: ['user-profile']처럼 미리 지정하며, 클라이언트 측 fetch에서는 사용할 수 없는 Next.js 전용 옵션입니다.

다음은 이런 서버 액션 개념을 실제 인보이스 폼 처리에 적용한 예시입니다. 폼 데이터를 서버에서 직접 받아 검증하고 저장하는 과정을 보여줍니다.

서버 액션 사용 방법

1단계: 서버 액션 함수 정의

"use server";
 
export async function myAction(formData: FormData) {
  const data = formData.get("fieldName") as string;
  await processData(data);
  revalidatePath("/some-path");
  redirect("/success");
}

2단계: 폼에서 서버 액션 사용

export function MyForm() {
  return (
    <form action={myAction}>
      <input name="fieldName" type="text" />
      <button type="submit">제출</button>
    </form>
  );
}

3단계: 로딩 상태와 에러 처리

"use client";
 
import { useActionState } from "react";
 
export function MyForm() {
  const [state, formAction, isPending] = useActionState(myAction, null);
 
  return (
    <form action={formAction}>
      <input name="fieldName" type="text" disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? "처리 중..." : "제출"}
      </button>
      {state?.error && <p className="error">{state.error}</p>}
    </form>
  );
}

기능 구현 및 비교

인보이스 생성/수정 시나리오로 CSR과 Next.js를 비교해보겠습니다.

리액트 + React Query + Zod 구성 – 클라이언트에서 데이터 관리

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
 
const invoiceSchema = z.object({
  customerName: z.string().min(1),
  amount: z.number().min(1),
  status: z.enum(["pending", "paid", "overdue"]),
});
 
export function useInvoices() {
  const queryClient = useQueryClient();
 
  const { data: invoices = [] } = useQuery({
    queryKey: ["invoices"],
    queryFn: () => fetch("/api/invoices").then((res) => res.json()),
  });
 
  const createInvoice = useMutation({
    mutationFn: async (formData) => {
      const validatedData = invoiceSchema.parse(formData);
      const res = await fetch("/api/invoices", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(validatedData),
      });
      if (!res.ok) throw new Error("생성 실패");
      return res.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["invoices"] });
    },
  });
 
  return { invoices, createInvoice };
}

React Query로 캐시와 로딩 상태를 자동 관리하고, Zod로 데이터 검증을 합니다.

Next.js 구성 – 서버 액션으로 통합 처리

// app/invoices/actions.ts
"use server";
 
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
 
const invoiceSchema = z.object({
  customerName: z.string().min(1),
  amount: z.number().min(1),
  status: z.enum(["pending", "paid", "overdue"]),
});
 
export async function createInvoice(formData: FormData) {
  const rawData = {
    customerName: formData.get("customerName") as string,
    amount: parseFloat(formData.get("amount") as string),
    status: formData.get("status") as string,
  };
 
  const validatedData = invoiceSchema.parse(rawData);
  await db.invoice.create({ data: validatedData });
 
  revalidatePath("/invoices");
  redirect("/invoices");
}
// app/invoices/components/InvoiceForm.tsx
"use client";
 
import { useActionState } from "react";
 
export function InvoiceForm({ action, initialData }) {
  const [state, formAction, isPending] = useActionState(action, null);
 
  return (
    <form action={formAction}>
      <input name="customerName" defaultValue={initialData?.customerName} disabled={isPending} />
      <input name="amount" type="number" defaultValue={initialData?.amount} disabled={isPending} />
      <select name="status" defaultValue={initialData?.status} disabled={isPending}>
        <option value="pending">대기중</option>
        <option value="paid">결제완료</option>
        <option value="overdue">연체</option>
      </select>
      <button type="submit" disabled={isPending}>
        {isPending ? "저장 중..." : "저장"}
      </button>
    </form>
  );
}

이렇게 하면 폼 제출 시점에 FormData가 서버 액션으로 전달되어 서버에서 Zod 검증 → DB 저장 → revalidatePath로 캐시 무효화 → redirect로 페이지 이동까지 한 번에 처리됩니다. 클라이언트는 UI 렌더링과 로딩 상태만 담당합니다.

리액트 vs Next.js 비교표

구분리액트 + React Query + Zod (CSR)Next.js (서버/클라이언트 + 서버 액션)
실행 환경 기본값폼 제출 → Zod 검증 → API 호출 → 캐시 업데이트폼 제출 → 서버 액션 → 검증·저장·캐시 무효화
데이터 접근 모델클라이언트에서 API 호출 + React Query 캐시서버에서 직접 DB 접근 + 자동 캐시 무효화
번들 관점검증 로직이 클라이언트 번들에 포함검증 로직이 서버에서 실행, 클라이언트는 UI만 번들링
컴포넌트 분리 의미React Query 훅 + UI 컴포넌트 결합서버 액션(데이터 변경) + 클라이언트 컴포넌트(UI) 분리
설계의 제약공유성/SEO 제한적, 수동 캐시 관리 필요URL 기반 공유 가능, 자동 캐시 무효화 강제

참고: React Query + Zod 조합도 훌륭하지만, 서버 액션은 Next.js의 "제로 번들 사이즈 데이터 변경" 전략으로 클라이언트 번들에 비즈니스 로직이 포함되지 않아 더 가볍고 안전합니다.

서버 액션 설계와 실전 활용

서버 액션 설계 패턴

기본 패턴: 폼 제출 → 서버 검증 → DB 저장 → 캐시 무효화 → 리다이렉트

Create/Update/Delete 패턴:

  • Create: 새 데이터를 검증 후 DB에 삽입하고 성공 시 목록 페이지로 이동
  • Update: 기존 데이터를 찾아 수정하고 변경사항을 캐시에서 제거
  • Delete: 데이터를 삭제하고 관련 캐시를 무효화하여 UI 업데이트

Optimistic Updates: 사용자가 액션을 수행하면 즉시 UI를 업데이트하고, 서버 응답을 기다리지 않습니다. useOptimistic 훅으로 구현하며, 서버 액션이 완료되면 실제 데이터를 동기화합니다. 느린 네트워크에서도 좋은 UX를 유지할 수 있습니다.

// 예시: 좋아요 토글에서 Optimistic Updates 사용
function LikeButton({ initialLikes }: { initialLikes: number }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (state, amount: number) => state + amount
  );
 
  const toggleLike = async () => {
    addOptimisticLike(1); // 즉시 UI 업데이트
    await toggleLikeAction(); // 서버 액션 호출
  };
 
  return <button onClick={toggleLike}>좋아요 {optimisticLikes}</button>;
}

상태 종류별 선택 가이드

상태 종류공유 필요성지속성추천 패턴
폼 입력 값낮음낮음클라이언트 상태
검증 결과중간낮음서버 액션 + 클라이언트
변경 상태높음높음서버 액션
캐시 무효화높음높음revalidatePath

서버 액션 설계 체크리스트

  • 서버 액션에서 Zod로 검증 로직 구현
  • useActionState로 로딩/에러 상태 관리
  • revalidatePath로 관련 캐시만 선택적 무효화
  • redirect로 PRG(Post-Redirect-Get) 패턴 적용 (폼 제출 후 새로고침 시 중복 제출 방지)
  • useOptimistic으로 UX 개선 (선택적)

서버 액션 적용의 트레이드오프

장점

  • 보안 강화: 서버에서 직접 검증을 실행하여 클라이언트 측 조작이나 우회가 불가능합니다. 민감한 비즈니스 로직이 클라이언트 번들에 포함되지 않아 안전합니다.
  • 개발 효율: 폼 제출부터 데이터 검증, DB 저장, 캐시 무효화까지 모든 과정을 하나의 함수에서 처리할 수 있습니다. 코드 중복을 줄이고 유지보수가 쉽습니다.
  • 자동 캐시 관리: revalidatePathrevalidateTag로 데이터 변경 후 즉시 캐시를 업데이트하여 UI와 서버 상태를 자동으로 동기화합니다.

단점

  • 범위 제한: 복잡한 비즈니스 로직이나 외부 API 연동에는 적합하지 않습니다. 이런 경우에는 Route Handlers를 별도로 사용해야 합니다.
  • 에러 처리: 서버 액션 실패 시 사용자에게 적절한 피드백을 제공하기 위해 useActionState로 체계적인 에러 처리를 구현해야 합니다.
  • 캐시 범위: revalidatePath의 범위를 잘못 설정하면 불필요한 캐시 무효화로 성능이 저하될 수 있으므로 신중하게 설정해야 합니다.

균형 맞추기 팁

폼 기반 CRUD에는 서버 액션, 복잡한 로직이나 외부 연동에는 Route Handlers를 사용하세요. revalidatePath는 변경된 데이터와 직접 관련된 경로만 무효화하세요.

예상 질문

Q. 서버 액션과 Route Handlers의 차이는? 서버 액션은 "use server"로 선언한 서버 측 함수로, 클라이언트에서 직접 호출하여 폼 데이터를 서버에서 검증·저장·캐시 무효화까지 처리하는 기능입니다. 앱 내부의 폼 기반 CRUD에 특화되어 있습니다.

Route Handlers는 app/api/에 정의하는 API 엔드포인트로, HTTP 요청을 받아 외부 시스템 연동이나 복잡한 비즈니스 로직을 처리합니다.

간단히 말해, 폼 제출에는 서버 액션, 외부 API 통신에는 Route Handlers를 사용하세요.

Q. revalidatePath와 revalidateTag 중 어느 걸 써야 하나요? 일반 CRUD에서는 revalidatePath로 충분합니다. revalidateTag는 데이터 종류별 태그로 세밀한 캐시 제어가 필요할 때 사용합니다.

Q. 폼 검증은 클라이언트에서만 해도 되지 않나요? 클라이언트 검증만으로는 보안에 취약합니다. 서버 액션에서 Zod로 검증을 기본으로 하고, 클라이언트는 UX 개선용으로 사용하세요.

Q. 서버 액션이 느릴 때는 어떻게 하나요? useTransition으로 로딩 표시, useOptimistic으로 즉시 UI 업데이트를 적용하세요.

Q. 그냥 use client를 페이지에 붙이면 안 되나요? 가능하지만 서버 액션의 이점을 포기하게 됩니다. 검증 로직이 클라이언트 번들에 포함되고 캐시 관리가 수동으로 바뀝니다.

Q. Next.js 예제 코드에 있는 useActionState 훅은 리액트에선 사용할 수 없는지? useActionState는 React 19의 새로운 훅으로, 서버 액션과 함께 사용할 때 특히 유용합니다. 일반 React 앱에서는 서버 액션이 없기 때문에 useActionState도 큰 의미가 없으며, React 19 이전 버전에서는 사용할 수 없습니다. Next.js에서는 서버 액션과 함께 사용되어 폼 상태 관리를 쉽게 할 수 있습니다.

Q. useOptimistic 훅은 Next.js 전용인가요? 아니요, useOptimistic은 React 19의 새로운 훅으로 React에서 제공하는 기능입니다. Next.js가 아니라 일반 React 앱에서도 사용할 수 있지만, 서버 액션과 함께 사용할 때 특히 유용합니다. Optimistic Updates 패턴을 쉽게 구현할 수 있게 해줍니다.

요약

4-1편의 URL 상태 관리 경험을 바탕으로, 검색된 데이터를 변경하는 CRUD 패턴을 서버 액션으로 구현했습니다.

CSR에서는 모든 로직이 클라이언트에 집중되지만, Next.js에서는 서버 액션으로 폼 데이터를 서버에서 직접 처리합니다. 검증·저장·캐시 무효화가 한 번에 이루어져 보안과 성능이 향상됩니다.

서버 액션 설계 시 기본 패턴을 따르고, useActionState로 상태 관리를 구현하세요. 폼 기반 CRUD에는 서버 액션, 복잡한 로직에는 Route Handlers를 선택하는 게 좋습니다.

  • 보안 강화: 서버 검증으로 클라이언트 우회 불가능
  • 개발 효율: 폼·검증·저장·캐시 무효화 통합
  • UX 개선: revalidatePath로 즉시 동기화

참조