Server Actions — 서버에서 데이터를 변경하는 새로운 방법

폼을 제출해서 데이터를 저장하려면, 보통 API 엔드포인트를 만들고 fetch로 POST 요청을 보냅니다. 그런데 Next.js에서는 함수 하나만 정의하면 서버에서 바로 실행된다는데, 이게 어떻게 가능한 걸까요?

Server Actions는 서버에서 실행되는 비동기 함수 를 클라이언트에서 직접 호출할 수 있게 해주는 기능입니다. API 라우트를 따로 만들 필요 없이, 함수 정의와 폼 연결만으로 서버 로직을 실행할 수 있습니다.


개념 정의

Server Action 은 'use server' 지시어로 선언된 비동기 함수로, 클라이언트에서 호출하면 HTTP POST 요청으로 변환되어 서버에서 실행 됩니다. 함수 본문은 절대 클라이언트에 노출되지 않으며, 서버의 DB, 파일 시스템, 환경 변수에 안전하게 접근할 수 있습니다.


기본 사용법

방법 1: 파일 상단에 'use server' 선언

TSX
// app/actions.ts
'use server';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  await db.post.create({ data: { title, content } });
}

파일 상단에 'use server'를 선언하면, 해당 파일의 모든 export 함수가 Server Action 이 됩니다.

방법 2: 함수 본문 안에 'use server' 선언

TSX
// app/page.tsx (Server Component)
export default function Page() {
  async function handleSubmit(formData: FormData) {
    'use server';
    const title = formData.get('title') as string;
    await db.post.create({ data: { title } });
  }

  return (
    <form action={handleSubmit}>
      <input name="title" />
      <button type="submit">등록</button>
    </form>
  );
}

Server Component 안에서 인라인으로 정의할 수도 있습니다.


동작 원리

  1. 빌드 시 Next.js가 'use server' 함수를 발견하면 고유 엔드포인트를 자동 생성 합니다.
  2. 클라이언트에서 이 함수를 호출하면 실제로는 POST 요청 이 해당 엔드포인트로 전송됩니다.
  3. 서버에서 함수가 실행되고, 결과가 클라이언트로 반환됩니다.
  4. 폼의 action에 연결된 경우, JavaScript가 비활성화되어도 HTML 폼 제출로 동작 합니다.

개발자가 보기에는 "함수를 호출"하는 것이지만, 내부적으로는 HTTP 요청-응답이 오갑니다.


폼과 함께 쓰기

HTML form의 action 속성

TSX
import { createPost } from './actions';

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="제목" />
      <textarea name="content" placeholder="내용" />
      <button type="submit">작성</button>
    </form>
  );
}

React 19부터 <form action>에 함수를 전달할 수 있습니다. Server Action을 연결하면, 폼 제출 시 해당 함수가 서버에서 실행됩니다.

revalidatePath로 캐시 갱신

데이터를 변경한 후에는 관련 페이지의 캐시를 무효화해야 합니다.

TSX
'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  await db.post.create({ data: { title } });

  revalidatePath('/blog'); // /blog 페이지의 캐시를 무효화
}

revalidatePath를 호출하면 해당 경로가 다음 요청 시 새로 렌더링 됩니다. 특정 태그로 캐시를 관리하려면 revalidateTag를 사용할 수도 있습니다.


Client Component에서 Server Action 호출

TSX
'use client';

import { createPost } from './actions';
import { useTransition } from 'react';

export default function PostForm() {
  const [isPending, startTransition] = useTransition();

  function handleSubmit(formData: FormData) {
    startTransition(async () => {
      await createPost(formData);
    });
  }

  return (
    <form action={handleSubmit}>
      <input name="title" />
      <button disabled={isPending}>
        {isPending ? '저장 중...' : '저장'}
      </button>
    </form>
  );
}

useTransition을 사용하면 Server Action 실행 중 로딩 상태를 관리 할 수 있습니다. 폼이 아닌 버튼 클릭 등에서도 Server Action을 호출할 수 있습니다.


Route Handler와의 차이

기준Server ActionRoute Handler
정의 방식'use server' 함수app/api/*/route.ts
호출 방식함수 호출 (내부적으로 POST)fetch로 명시적 HTTP 요청
사용 위치폼 제출, 데이터 변경외부 API, 웹훅, 서드파티 연동
HTTP 메서드POST만GET, POST, PUT, DELETE 등
클라이언트 노출함수 본문 미노출URL 노출 (외부 접근 가능)
JS 비활성화폼은 동작함fetch 필요 → JS 필수

**선택 기준 **: 폼 제출이나 데이터 변경 → Server Action. 외부에서 호출해야 하는 API → Route Handler.


주의할 점

Server Action의 인자는 직렬화 가능해야 한다

Server Action은 네트워크를 통해 호출되므로, 인자와 반환값 모두 ** 직렬화 가능한 값 **이어야 합니다. 함수, 클래스 인스턴스, Symbol 등은 전달할 수 없습니다.

TSX
'use server';

// 반환값도 직렬화 가능해야 함
export async function getUser(id: string) {
  const user = await db.user.findUnique({ where: { id } });
  return { name: user.name, email: user.email }; // 일반 객체 → 가능
  // return user; ← Prisma 모델 인스턴스라면 주의 필요
}

민감한 데이터 검증은 서버에서 해야 한다

Server Action의 인자는 클라이언트에서 전송되는 값입니다. ** 클라이언트는 언제든 조작할 수 있으므로 **, 서버에서 반드시 유효성 검증을 해야 합니다.

TSX
'use server';

export async function updateProfile(formData: FormData) {
  const name = formData.get('name') as string;

  // 서버에서 검증 필수
  if (!name || name.length > 100) {
    throw new Error('유효하지 않은 이름입니다');
  }

  await db.user.update({ where: { id: getCurrentUserId() }, data: { name } });
}

redirect는 try/catch 밖에서 호출

redirect()는 내부적으로 예외를 던져서 동작합니다. try/catch 안에서 호출하면 catch에 잡혀서 리다이렉트가 동작하지 않습니다.

TSX
'use server';

import { redirect } from 'next/navigation';

export async function createPost(formData: FormData) {
  let postId: string;

  try {
    const post = await db.post.create({ ... });
    postId = post.id;
  } catch (error) {
    return { error: '생성 실패' };
  }

  redirect(`/blog/${postId}`); // try 밖에서 호출
}

정리

핵심내용
Server Action'use server'로 선언, 서버에서 실행되는 비동기 함수
동작 방식클라이언트 호출 → 내부적으로 POST 요청 → 서버 실행
캐시 갱신revalidatePath / revalidateTag
vs Route Handler폼/변경은 Server Action, 외부 API는 Route Handler

기억할 한 줄: "API 라우트 없이도 서버 로직을 실행할 수 있다. 단, 검증은 반드시 서버에서."

댓글 로딩 중...