Ray Book
Next.js 깊이 보기

Server Actions, mutation의 재발명

API 라우트 + useEffect의 번거로움에서 'use server'의 단순함까지, Next.js가 폼과 데이터 변경을 다루는 방식을 어떻게 바꿨는지 정리합니다.

nextjsserver-actionsformsmutation

mutation의 오래된 패턴

"데이터를 읽는 것"은 렌더링의 이야기입니다. 그런데 웹 앱에는 반대쪽도 있습니다, 데이터를 변경하는 것 . 사용자가 폼을 제출하고, 버튼을 클릭하고, 설정을 바꿉니다. 이런 mutation은 읽기와는 전혀 다른 문제입니다.

전통적인 Next.js에서 mutation을 다루는 방법은 이랬습니다.

  1. pages/api/update-post.ts 같은 API 라우트를 만든다
  2. 클라이언트 컴포넌트에서 fetch로 그 API를 호출한다
  3. 결과에 따라 상태를 업데이트하고 UI를 새로고침한다
// pages/api/update-post.ts, API 라우트
export default async function handler(req, res) {
  if (req.method !== "POST") return res.status(405).end();

  const { id, title } = req.body;
  await db.posts.update(id, { title });
  res.status(200).json({ success: true });
}

// components/EditPostForm.tsx, 클라이언트 컴포넌트
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

export function EditPostForm({ post }) {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const router = useRouter();

  async function handleSubmit(e) {
    e.preventDefault();
    setLoading(true);
    setError(null);

    const formData = new FormData(e.currentTarget);
    const res = await fetch("/api/update-post", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        id: post.id,
        title: formData.get("title"),
      }),
    });

    if (!res.ok) {
      setError("업데이트 실패");
      setLoading(false);
      return;
    }

    router.refresh(); // 페이지 데이터 다시 가져오기
    setLoading(false);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" defaultValue={post.title} />
      <button disabled={loading}>저장</button>
      {error && <p>{error}</p>}
    </form>
  );
}

이 코드에는 여러 단계가 얽혀 있습니다.

  • API 라우트 파일을 별도로 만들어야 합니다
  • 클라이언트에서 fetch로 요청을 직접 조립해야 합니다
  • URL, HTTP 메서드, Content-Type, body 직렬화를 모두 개발자가 처리합니다
  • 로딩 상태, 에러 상태를 수동으로 관리합니다
  • 성공 후 데이터 갱신도 직접 트리거합니다
  • 서버와 클라이언트 사이에 타입이 공유되지 않아 오타나 스키마 불일치가 런타임까지 숨어있습니다

mutation 하나에 이만큼의 보일러플레이트가 붙는 것은 낭비입니다. Next.js는 이 문제를 근본적으로 해결하고 싶었습니다.

Server Actions의 등장

Server Actions 는 Next.js 13.4 (2023년 5월) 에서 알파로 도입되었고, Next.js 14 (2023년 10월) 에서 안정화되었습니다. 이것은 완전히 다른 패러다임입니다.

서버 함수를 클라이언트에서 직접 호출할 수 있게 하자.

// app/posts/[id]/edit/page.tsx (Server Component)
import { updatePost } from "./actions";

export default async function EditPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return (
    <form action={updatePost}>
      <input type="hidden" name="id" value={id} />
      <input name="title" />
      <button>저장</button>
    </form>
  );
}
// app/posts/[id]/edit/actions.ts
"use server";

import { revalidatePath } from "next/cache";

export async function updatePost(formData: FormData) {
  const id = formData.get("id") as string;
  const title = formData.get("title") as string;

  await db.posts.update(id, { title });
  revalidatePath(`/posts/${id}`);
}

위 코드에서 몇 가지 주목할 점이 있습니다.

"use server" 지시문. 파일 상단의 "use server"는 "이 파일의 함수들은 서버에서만 실행된다"는 뜻입니다. Next.js는 이 선언을 보고, 함수를 서버 코드로 유지하고 클라이언트에는 호출 프록시만 보냅니다.

<form action={updatePost}>. HTML 폼의 action 속성에 JavaScript 함수를 직접 전달합니다. 이것은 React 19와 Next.js가 함께 만든 새로운 기능입니다. 폼이 제출되면 updatePost(formData)가 호출됩니다.

API 라우트 없음. URL도, HTTP 메서드도, 직렬화도 개발자가 건드리지 않습니다. Next.js가 내부적으로 엔드포인트를 만들고, 폼 데이터를 자동으로 직렬화하고, 응답을 받아서 UI를 업데이트합니다.

progressive enhancement. JavaScript가 로드되기 전에도 이 폼은 동작합니다. JS가 없으면 브라우저의 기본 폼 제출 동작 (HTTP POST) 이 서버의 Server Action 엔드포인트로 데이터를 보내고, 서버가 응답 HTML을 반환합니다. JS가 있으면 같은 동작이 비동기로 부드럽게 일어납니다.

Server Action의 동작 원리

"어떻게 클라이언트 컴포넌트가 서버 함수를 직접 호출할 수 있는가?" 의 답은 단순합니다. 실제로는 호출하지 않기 때문입니다.

Next.js가 빌드할 때, "use server"가 붙은 함수는 서버 코드로 남고, 클라이언트 쪽에는 그 함수를 호출하는 대신 내부 API 엔드포인트로 요청을 보내는 프록시 가 생성됩니다. 그리고 클라이언트 코드에서 함수를 "호출"하면, 실제로는 그 프록시가 네트워크 요청을 보냅니다.

이것을 그림으로 표현하면:

[클라이언트 코드]
updatePost(formData)

[Next.js가 만든 프록시]
fetch('/_next/action/xyz', { body: formData })

[서버]
실제 updatePost 함수 실행

응답 반환

개발자 입장에서는 그저 함수를 호출하는 것처럼 보입니다. 하지만 뒤에서는 일반적인 HTTP 요청이 오가고 있습니다. 다만 그 모든 복잡도가 숨겨져 있을 뿐입니다.

이 추상화가 강력한 이유는 같은 코드가 Progressive Enhancement를 자동으로 지원 한다는 것입니다. JavaScript가 있으면 fetch 기반, 없으면 HTML 폼 기반. 개발자는 두 경우를 따로 처리하지 않습니다.

폼 없이 호출하기

Server Action은 폼의 action 속성에만 쓸 수 있는 것이 아닙니다. 일반 함수처럼 import해서 어디서나 호출할 수 있습니다.

// app/components/LikeButton.tsx
"use client";

import { likePost } from "./actions";

export function LikeButton({ postId }: { postId: string }) {
  async function handleClick() {
    await likePost(postId); // 서버 함수 직접 호출
  }

  return <button onClick={handleClick}>좋아요</button>;
}
// app/actions.ts
"use server";

export async function likePost(postId: string) {
  await db.likes.increment(postId);
}

Client Component 안에서 likePost를 호출하는 것처럼 보이지만, 실제로는 서버로 요청이 전송됩니다. 함수의 인자 (postId) 는 자동으로 직렬화되어 네트워크를 통과합니다.

인자로 전달할 수 있는 값은 Server Component → Client Component 에서의 props와 같은 규칙을 따릅니다, 직렬화 가능한 값만 전달할 수 있습니다. 함수나 클래스 인스턴스는 안 됩니다.

React 19의 Actions Hook과의 통합

Server Actions는 React 19의 Actions 패러다임과 자연스럽게 결합합니다. React 19는 폼과 mutation을 위한 세 가지 Hook을 제공합니다.

useActionState, 액션의 상태를 추적

// actions.ts
"use server";

export async function updatePost(
  prevState: { error: string | null },
  formData: FormData,
): Promise<{ error: string | null }> {
  try {
    await db.posts.update(formData.get("id") as string, {
      title: formData.get("title") as string,
    });
    return { error: null };
  } catch (e) {
    return { error: (e as Error).message };
  }
}
// EditForm.tsx
"use client";

import { useActionState } from "react";
import { updatePost } from "./actions";

export function EditForm({ post }) {
  const [state, formAction, isPending] = useActionState(updatePost, { error: null });

  return (
    <form action={formAction}>
      <input type="hidden" name="id" value={post.id} />
      <input name="title" defaultValue={post.title} />
      <button disabled={isPending}>저장</button>
      {state.error && <p>{state.error}</p>}
    </form>
  );
}

useActionState[state, formAction, isPending] 세 가지를 반환합니다.

  • state , 액션 함수가 마지막으로 반환한 값 (초기값은 두 번째 인자)
  • formAction , 폼의 action 속성에 전달할 함수
  • isPending , 액션 진행 중 여부

액션 함수의 첫 번째 인자는 이전 상태 (prevState) 이고, 두 번째 인자가 FormData 입니다. 이 reducer 패턴 덕분에 이전에 수동으로 관리하던 useState 여러 개가 하나의 Hook으로 통합됩니다.

useFormStatus, 자식에서 부모 폼 상태 읽기

"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "저장 중..." : "저장"}</button>;
}

useFormStatus는 부모 폼의 제출 상태를 자식 컴포넌트에서 직접 읽게 해줍니다. props drilling 없이 로딩 상태에 접근할 수 있습니다.

useOptimistic, 낙관적 업데이트

"use client";

import { useOptimistic } from "react";
import { addComment } from "./actions";

export function CommentList({ comments }) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (state, newComment) => [...state, { text: newComment, pending: true }],
  );

  async function action(formData: FormData) {
    const text = formData.get("text") as string;
    addOptimisticComment(text); // UI 먼저 업데이트
    await addComment(text); // 실제 서버 호출
  }

  return (
    <>
      {optimisticComments.map((c, i) => (
        <div key={i} className={c.pending ? "opacity-50" : ""}>
          {c.text}
        </div>
      ))}
      <form action={action}>
        <input name="text" />
        <button>전송</button>
      </form>
    </>
  );
}

useOptimistic은 서버 응답을 기다리지 않고 UI를 먼저 업데이트합니다. 실패하면 자동으로 되돌려집니다. 사용자 입장에서는 즉각적인 반응성을 느낍니다.

이 세 Hook이 Server Actions와 결합하면, 전통적인 "API 라우트 + useState + useEffect" 패턴이 얼마나 장황했는지가 드러납니다.

재검증과 연결

Server Action의 또 다른 중요한 특징은 캐시 재검증과 자연스럽게 연결 된다는 것입니다.

"use server";

import { revalidatePath, revalidateTag } from "next/cache";

export async function publishPost(postId: string) {
  await db.posts.update(postId, { published: true });

  // 특정 경로의 캐시 무효화
  revalidatePath("/posts");
  revalidatePath(`/posts/${postId}`);

  // 태그 기반 캐시 무효화
  revalidateTag("posts");
}

mutation이 성공하면, 관련된 캐시를 즉시 무효화할 수 있습니다. Next.js 16에서는 updateTag() 라는 새 API도 추가되었습니다.

"use server";

import { updateTag } from "next/cache";

export async function updateProfile(userId: string, profile: Profile) {
  await db.users.update(userId, profile);
  updateTag(`user-${userId}`); // 캐시 만료 + 즉시 새 데이터 읽기
}

updateTag()read-your-writes 시맨틱을 제공합니다. 사용자가 자기 프로필을 수정했을 때, 즉시 자기 변경사항을 볼 수 있습니다. revalidateTag()가 "다음 요청부터 새 데이터"라면, updateTag()는 "지금 이 요청부터 새 데이터"입니다.

Security 고려, 모든 action은 public이다

Server Actions를 쓸 때 꼭 기억해야 할 것이 있습니다. "use server"로 선언한 함수는 public 엔드포인트입니다.

Next.js는 이 함수들에 자동으로 URL을 할당하고, 누구나 그 URL로 POST 요청을 보낼 수 있습니다. 클라이언트 코드에서 호출할 수 있다는 것은, 악의적인 공격자도 호출할 수 있다는 뜻입니다.

따라서 Server Action 안에서는 반드시 권한 검증 을 해야 합니다.

"use server";

import { auth } from "./auth";

export async function deletePost(postId: string) {
  const session = await auth(); // 현재 사용자 확인
  if (!session) {
    throw new Error("로그인이 필요합니다");
  }

  const post = await db.posts.findById(postId);
  if (post.authorId !== session.user.id) {
    throw new Error("권한이 없습니다");
  }

  await db.posts.delete(postId);
}

폼에 <input type="hidden" name="userId"> 같은 것을 넣어서 서버로 전달받아도 안 됩니다. 공격자가 그 값을 조작할 수 있기 때문입니다. 항상 서버에서 세션/쿠키/토큰으로 사용자를 확인해야 합니다.

또한 입력값 검증도 필수입니다. Zod 같은 라이브러리로 formData를 검증하면 안전합니다.

"use server";

import { z } from "zod";

const updatePostSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1).max(200),
});

export async function updatePost(formData: FormData) {
  const parsed = updatePostSchema.safeParse({
    id: formData.get("id"),
    title: formData.get("title"),
  });

  if (!parsed.success) {
    return { error: "잘못된 입력입니다" };
  }

  await db.posts.update(parsed.data.id, { title: parsed.data.title });
}

무엇이 바뀌었나

Server Actions가 가져온 변화를 정리하면 이렇습니다.

API 라우트의 감소. 대부분의 mutation은 이제 별도의 API 라우트 파일 없이 처리할 수 있습니다. API 라우트는 외부 서비스나 웹훅 같은 public API를 만들 때만 필요합니다.

클라이언트/서버 경계의 간소화. "이 함수는 어디서 실행되는가"가 명확해졌습니다. "use server"가 있으면 서버, "use client"가 있으면 클라이언트. 경계는 파일 단위로 명시적입니다.

Progressive Enhancement의 부활. JavaScript 없이도 동작하는 웹을 다시 만들기 쉬워졌습니다. <form action={...}> 는 JS가 비활성화되어도 동작합니다.

폼 처리의 표준화. useActionState, useFormStatus, useOptimistic 이 React의 기본으로 포함되면서, 폼 처리 패턴이 프레임워크 간에 통일되기 시작했습니다.

mutation이라는 오래된 문제가, 전혀 새로운 방식으로 재발명되었습니다. 개발자는 더 적은 코드로 더 안정적이고 더 나은 UX를 만들 수 있게 되었습니다.

다음 단계

여기까지가 App Router와 그 위에서 동작하는 핵심 기능들, 라우팅, Server Components, 데이터 패칭, 렌더링 전략, Server Actions, 에 대한 이야기였습니다. 이것들은 모두 "React 컴포넌트를 어떻게 실행할 것인가"에 대한 것입니다.

마지막 글에서는 조금 다른 각도를 다룹니다. 빌드 시스템 . Webpack이 오랫동안 Next.js의 기반이었지만, 앱이 커지면서 한계가 드러났습니다. Turbopack이 그 자리를 대체하게 된 여정과, Next.js 16에서 기본 번들러가 된 지금의 모습, 그리고 16.2의 최신 개선사항들을 살펴봅니다.