문서
App Router 사용하기
서버 액션 및 데이터 수정

서버 액션 및 데이터 수정

서버 액션은 서버에서 실행되는 비동기 함수입니다. Next.js 애플리케이션에서 폼 제출과 데이터 수정을 처리하기 위해 서버 및 클라이언트 컴포넌트에서 호출할 수 있습니다.

🎥 시청하기: 서버 액션을 사용한 변이에 대해 자세히 알아보기 → YouTube (10분).

규칙

서버 액션은 React "use server" 지시문으로 정의할 수 있습니다. async 함수의 맨 위에 지시문을 배치하여 해당 함수를 서버 액션으로 표시하거나, 별도의 파일 맨 위에 배치하여 해당 파일의 모든 내보내기를 서버 액션으로 표시할 수 있습니다.

서버 컴포넌트

서버 컴포넌트는 인라인 함수 수준 또는 모듈 수준 "use server" 지시문을 사용할 수 있습니다. 서버 액션을 인라인으로 사용하려면 함수 본문 맨 위에 "use server"를 추가하세요:

app/page.tsx
export default function Page() {
  // 서버 액션
  async function create() {
    "use server";
    // 데이터 수정
  }
 
  return "...";
}
app/page.jsx
export default function Page() {
  // 서버 액션
  async function create() {
    "use server";
    // 데이터 수정
  }
 
  return "...";
}

클라이언트 컴포넌트

클라이언트 컴포넌트에서 서버 액션을 호출하려면 새 파일을 만들고 맨 위에 "use server" 지시문을 추가하세요. 파일 내의 모든 함수는 클라이언트 및 서버 컴포넌트 모두에서 재사용할 수 있는 서버 액션으로 표시됩니다:

app/actions.ts
"use server";
 
export async function create() {}
app/actions.js
"use server";
 
export async function create() {}
app/ui/button.tsx
"use client";
 
import { create } from "@/app/actions";
 
export function Button() {
  return <Button onClick={create} />;
}
app/ui/button.js
"use client";
 
import { create } from "@/app/actions";
 
export function Button() {
  return <Button onClick={create} />;
}

액션을 props로 전달하기

서버 액션을 클라이언트 컴포넌트에 prop으로 전달할 수도 있습니다:

<ClientComponent updateItemAction={updateItem} />
app/client-component.tsx
"use client";
 
export default function ClientComponent({
  updateItemAction,
}: {
  updateItemAction: (formData: FormData) => void;
}) {
  return <form action={updateItemAction}>{/* ... */}</form>;
}
app/client-component.jsx
"use client";
 
export default function ClientComponent({ updateItemAction }) {
  return <form action={updateItemAction}>{/* ... */}</form>;
}

일반적으로 Next.js TypeScript 플러그인은 updateItemActionclient-component.tsx에서 플래그를 지정합니다. 이는 일반적으로 클라이언트-서버 경계를 넘어 직렬화될 수 없는 함수이기 때문입니다. 그러나 action이라는 이름이나 Action으로 끝나는 props는 서버 액션을 받는 것으로 가정됩니다. 이는 TypeScript 플러그인이 실제로 서버 액션을 받는지 아니면 일반 함수를 받는지 알 수 없기 때문에 단지 추측일 뿐입니다. 런타임 타입 검사는 여전히 실수로 클라이언트 컴포넌트에 함수를 전달하지 않도록 보장합니다.

동작

  • 서버 액션은 <form> 요소action 속성을 사용하여 호출할 수 있습니다:
    • 서버 컴포넌트는 기본적으로 점진적 향상을 지원하므로, JavaScript가 아직 로드되지 않았거나 비활성화된 경우에도 폼이 제출됩니다.
    • 클라이언트 컴포넌트에서 서버 액션을 호출하는 폼은 JavaScript가 아직 로드되지 않은 경우 제출을 큐에 넣어 클라이언트 하이드레이션을 우선시합니다.
    • 하이드레이션 후에는 브라우저가 폼 제출 시 새로고침하지 않습니다.
  • 서버 액션은 <form>에만 국한되지 않으며 이벤트 핸들러, useEffect, 서드파티 라이브러리 및 기타 폼 요소(예: <button>)에서 호출할 수 있습니다.
  • 서버 액션은 Next.js의 캐싱 및 재검증 아키텍처와 통합됩니다. 액션이 호출되면 Next.js는 단일 서버 왕복에서 업데이트된 UI와 새 데이터를 모두 반환할 수 있습니다.
  • 내부적으로 액션은 POST 메서드를 사용하며, 이 HTTP 메서드만 액션을 호출할 수 있습니다.
  • 서버 액션의 인수와 반환 값은 React에 의해 직렬화 가능해야 합니다. 직렬화 가능한 인수와 값의 목록은 React 문서를 참조하세요.
  • 서버 액션은 함수입니다. 즉, 애플리케이션의 어디에서나 재사용할 수 있습니다.
  • 서버 액션은 사용되는 페이지나 레이아웃에서 런타임을 상속받습니다.
  • 서버 액션은 maxDuration과 같은 필드를 포함하여 사용되는 페이지나 레이아웃에서 라우트 세그먼트 구성을 상속받습니다.

예시

React는 HTML <form> 요소를 확장하여 action prop으로 서버 액션을 호출할 수 있도록 합니다.

폼에서 호출될 때 액션은 자동으로 FormData 객체를 받습니다. 필드를 관리하기 위해 React useState를 사용할 필요가 없으며, 대신 네이티브 FormData 메서드를 사용하여 데이터를 추출할 수 있습니다:

app/invoices/page.tsx
export default function Page() {
  async function createInvoice(formData: FormData) {
    "use server";
 
    const rawFormData = {
      customerId: formData.get("customerId"),
      amount: formData.get("amount"),
      status: formData.get("status"),
    };
 
    // 데이터 수정
    // 캐시 재검증
  }
 
  return <form action={createInvoice}>...</form>;
}
app/invoices/page.jsx
export default function Page() {
  async function createInvoice(formData) {
    "use server";
 
    const rawFormData = {
      customerId: formData.get("customerId"),
      amount: formData.get("amount"),
      status: formData.get("status"),
    };
 
    // 데이터 수정
    // 캐시 재검증
  }
 
  return <form action={createInvoice}>...</form>;
}

알아두면 좋은 점:

추가 인수 전달하기

JavaScript bind 메서드를 사용하여 서버 액션에 추가 인수를 전달할 수 있습니다.

app/client-component.tsx
"use client";
 
import { updateUser } from "./actions";
 
export function UserProfile({ userId }: { userId: string }) {
  const updateUserWithId = updateUser.bind(null, userId);
 
  return (
    <form action={updateUserWithId}>
      <input type="text" name="name" />
      <button type="submit">사용자 이름 업데이트</button>
    </form>
  );
}
app/client-component.js
"use client";
 
import { updateUser } from "./actions";
 
export function UserProfile({ userId }) {
  const updateUserWithId = updateUser.bind(null, userId);
 
  return (
    <form action={updateUserWithId}>
      <input type="text" name="name" />
      <button type="submit">사용자 이름 업데이트</button>
    </form>
  );
}

서버 액션은 폼 데이터 외에 userId 인수를 받게 됩니다:

app/actions.js
"use server";
 
export async function updateUser(userId, formData) {}

알아두면 좋은 점:

  • 대안으로 폼에서 숨겨진 입력 필드로 인수를 전달할 수 있습니다(예: <input type="hidden" name="userId" value={userId} />). 하지만 이 값은 렌더링된 HTML의 일부가 되며 인코딩되지 않습니다.
  • .bind는 서버 및 클라이언트 컴포넌트 모두에서 작동합니다. 또한 단계적 기능 향상을 지원합니다.

중첩된 폼 요소

<form> 내부에 중첩된 요소에서도 서버 액션을 호출할 수 있습니다. 예를 들어 <button>, <input type="submit">, <input type="image">와 같은 요소들은 formAction prop이나 이벤트 핸들러를 받습니다.

이는 폼 내에서 여러 서버 액션을 호출하고 싶을 때 유용합니다. 예를 들어, 게시물 발행 외에도 게시물 초안을 저장하기 위한 특정 <button> 요소를 만들 수 있습니다. 자세한 내용은 React <form> 문서를 참조하세요.

프로그래밍 방식의 폼 제출

requestSubmit() 메서드를 사용하여 프로그래밍 방식으로 폼 제출을 트리거할 수 있습니다. 예를 들어, 사용자가 + Enter 키보드 단축키를 사용하여 폼을 제출할 때, onKeyDown 이벤트를 수신할 수 있습니다:

app/entry.tsx
"use client";
 
export function Entry() {
  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (
      (e.ctrlKey || e.metaKey) &&
      (e.key === "Enter" || e.key === "NumpadEnter")
    ) {
      e.preventDefault();
      e.currentTarget.form?.requestSubmit();
    }
  };
 
  return (
    <div>
      <textarea name="entry" rows={20} required onKeyDown={handleKeyDown} />
    </div>
  );
}
app/entry.jsx
"use client";
 
export function Entry() {
  const handleKeyDown = (e) => {
    if (
      (e.ctrlKey || e.metaKey) &&
      (e.key === "Enter" || e.key === "NumpadEnter")
    ) {
      e.preventDefault();
      e.currentTarget.form?.requestSubmit();
    }
  };
 
  return (
    <div>
      <textarea name="entry" rows={20} required onKeyDown={handleKeyDown} />
    </div>
  );
}

이는 가장 가까운 <form> 조상의 제출을 트리거하여 서버 액션을 호출합니다.

서버 측 폼 유효성 검사

기본적인 클라이언트 측 폼 유효성 검사를 위해 requiredtype="email"과 같은 HTML 속성을 사용할 수 있습니다.

더 고급 서버 측 유효성 검사를 위해 zod와 같은 라이브러리를 사용하여 데이터를 변경하기 전에 폼 필드를 검증할 수 있습니다:

app/actions.ts
"use server";
 
import { z } from "zod";
 
const schema = z.object({
  email: z.string({
    invalid_type_error: "Invalid Email",
  }),
});
 
export default async function createUser(formData: FormData) {
  const validatedFields = schema.safeParse({
    email: formData.get("email"),
  });
 
  // 폼 데이터가 유효하지 않으면 일찍 반환
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }
 
  // 데이터 변경
}
app/actions.js
"use server";
 
import { z } from "zod";
 
const schema = z.object({
  email: z.string({
    invalid_type_error: "Invalid Email",
  }),
});
 
export default async function createsUser(formData) {
  const validatedFields = schema.safeParse({
    email: formData.get("email"),
  });
 
  // 폼 데이터가 유효하지 않으면 일찍 반환
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }
 
  // 데이터 변경
}

필드가 서버에서 검증되면 액션에서 직렬화 가능한 객체를 반환하고 React useActionState 훅을 사용하여 사용자에게 메시지를 표시할 수 있습니다.

  • useActionState에 액션을 전달하면 액션의 함수 서명이 변경되어 첫 번째 인수로 새 prevState 또는 initialState 매개변수를 받습니다.
  • useActionState는 React 훅이므로 클라이언트 컴포넌트에서 사용해야 합니다.

알아두면 좋은 점: 이 예제들은 React 19 RC에서 사용 가능한 React의 useActionState 훅을 사용합니다. 이전 버전의 React를 사용 중이라면 대신 useFormState를 사용하세요. 자세한 내용은 React 문서를 참조하세요.

app/actions.ts
"use server";
 
import { redirect } from "next/navigation";
 
export async function createUser(prevState: any, formData: FormData) {
  const res = await fetch("https://...");
  const json = await res.json();
 
  if (!res.ok) {
    return { message: "Please enter a valid email" };
  }
 
  redirect("/dashboard");
}
app/actions.js
"use server";
 
import { redirect } from "next/navigation";
 
export async function createUser(prevState, formData) {
  const res = await fetch("https://...");
  const json = await res.json();
 
  if (!res.ok) {
    return { message: "Please enter a valid email" };
  }
 
  redirect("/dashboard");
}

그런 다음 useActionState 훅에 액션을 전달하고 반환된 state를 사용하여 오류 메시지를 표시할 수 있습니다.

app/ui/signup.tsx
"use client";
 
import { useActionState } from "react";
import { createUser } from "@/app/actions";
 
const initialState = {
  message: "",
};
 
export function Signup() {
  const [state, formAction] = useActionState(createUser, initialState);
 
  return (
    <form action={formAction}>
      <label htmlFor="email">이메일</label>
      <input type="text" id="email" name="email" required />
      {/* ... */}
      <p aria-live="polite">{state?.message}</p>
      <button>가입하기</button>
    </form>
  );
}
app/ui/signup.js
"use client";
 
import { useActionState } from "react";
import { createUser } from "@/app/actions";
 
const initialState = {
  message: "",
};
 
export function Signup() {
  const [state, formAction] = useActionState(createUser, initialState);
 
  return (
    <form action={formAction}>
      <label htmlFor="email">이메일</label>
      <input type="text" id="email" name="email" required />
      {/* ... */}
      <p aria-live="polite">{state?.message}</p>
      <button>가입하기</button>
    </form>
  );
}

알아두면 좋은 점:

  • 데이터를 변경하기 전에 사용자가 해당 작업을 수행할 권한이 있는지 항상 확인해야 합니다. 인증 및 권한 부여를 참조하세요.

대기 상태

useActionState 훅은 액션이 실행되는 동안 로딩 표시기를 표시하는 데 사용할 수 있는 pending 상태를 노출합니다.

app/submit-button.tsx
"use client";
 
import { useActionState } from "react";
import { createUser } from "@/app/actions";
 
const initialState = {
  message: "",
};
 
export function Signup() {
  const [state, formAction, pending] = useActionState(createUser, initialState);
 
  return (
    <form action={formAction}>
      <label htmlFor="email">Email</label>
      <input type="text" id="email" name="email" required />
      {/* ... */}
      <p aria-live="polite" className="sr-only">
        {state?.message}
      </p>
      <button aria-disabled={pending} type="submit">
        {pending ? "Submitting..." : "Sign up"}
      </button>
    </form>
  );
}
app/submit-button.jsx
"use client";
 
import { useActionState } from "react";
import { createUser } from "@/app/actions";
 
const initialState = {
  message: "",
};
 
export function Signup() {
  const [state, formAction, pending] = useActionState(createUser, initialState);
 
  return (
    <form action={formAction}>
      <label htmlFor="email">Email</label>
      <input type="text" id="email" name="email" required />
      {/* ... */}
      <p aria-live="polite" className="sr-only">
        {state?.message}
      </p>
      <button aria-disabled={pending} type="submit">
        {pending ? "Submitting..." : "Sign up"}
      </button>
    </form>
  );
}

알아두면 좋은 점: 또는 특정 폼에 대한 대기 상태를 표시하기 위해 useFormStatus 훅을 사용할 수도 있습니다.

낙관적 업데이트

React useOptimistic 훅을 사용하여 서버 액션이 실행을 완료할 때까지 기다리지 않고 응답을 기다리는 대신 UI를 낙관적으로 업데이트할 수 있습니다:

app/page.tsx
"use client";
 
import { useOptimistic } from "react";
import { send } from "./actions";
 
type Message = {
  message: string;
};
 
export function Thread({ messages }: { messages: Message[] }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic
    Message[],
    string
  >(messages, (state, newMessage) => [...state, { message: newMessage }]);
 
  const formAction = async (formData) => {
    const message = formData.get("message") as string;
    addOptimisticMessage(message);
    await send(message);
  };
 
  return (
    <div>
      {optimisticMessages.map((m, i) => (
        <div key={i}>{m.message}</div>
      ))}
      <form action={formAction}>
        <input type="text" name="message" />
        <button type="submit">전송</button>
      </form>
    </div>
  );
}
app/page.jsx
"use client";
 
import { useOptimistic } from "react";
import { send } from "./actions";
 
export function Thread({ messages }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newMessage) => [...state, { message: newMessage }]
  );
 
  const formAction = async (formData) => {
    const message = formData.get("message");
    addOptimisticMessage(message);
    await send(message);
  };
 
  return (
    <div>
      {optimisticMessages.map((m) => (
        <div>{m.message}</div>
      ))}
      <form action={formAction}>
        <input type="text" name="message" />
        <button type="submit">전송</button>
      </form>
    </div>
  );
}

이벤트 핸들러

서버 액션을 <form> 요소 내에서 사용하는 것이 일반적이지만, onClick과 같은 이벤트 핸들러에서도 호출할 수 있습니다. 예를 들어, 좋아요 수를 증가시키는 경우:

app/like-button.tsx
"use client";
 
import { incrementLike } from "./actions";
import { useState } from "react";
 
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
 
  return (
    <>
      <p>총 좋아요: {likes}</p>
      <button
        onClick={async () => {
          const updatedLikes = await incrementLike();
          setLikes(updatedLikes);
        }}
      >
        좋아요
      </button>
    </>
  );
}
app/like-button.js
"use client";
 
import { incrementLike } from "./actions";
import { useState } from "react";
 
export default function LikeButton({ initialLikes }) {
  const [likes, setLikes] = useState(initialLikes);
 
  return (
    <>
      <p>총 좋아요: {likes}</p>
      <button
        onClick={async () => {
          const updatedLikes = await incrementLike();
          setLikes(updatedLikes);
        }}
      >
        좋아요
      </button>
    </>
  );
}

폼 요소에 이벤트 핸들러를 추가할 수도 있습니다. 예를 들어, onChange에서 폼 필드를 저장하는 경우:

app/ui/edit-post.tsx
"use client";
 
import { publishPost, saveDraft } from "./actions";
 
export default function EditPost() {
  return (
    <form action={publishPost}>
      <textarea
        name="content"
        onChange={async (e) => {
          await saveDraft(e.target.value);
        }}
      />
      <button type="submit">발행</button>
    </form>
  );
}

이와 같이 여러 이벤트가 빠르게 발생할 수 있는 경우에는 불필요한 서버 액션 호출을 방지하기 위해 디바운싱을 사용하는 것을 권장합니다.

useEffect

React useEffect 훅을 사용하여 컴포넌트가 마운트되거나 의존성이 변경될 때 서버 액션을 호출할 수 있습니다. 이는 전역 이벤트에 의존하거나 자동으로 트리거해야 하는 변이에 유용합니다. 예를 들어, 앱 단축키를 위한 onKeyDown, 무한 스크롤을 위한 교차 관찰자 훅, 또는 컴포넌트가 마운트될 때 조회 수를 업데이트하는 경우 등에 유용합니다:

app/view-count.tsx
"use client";
 
import { incrementViews } from "./actions";
import { useState, useEffect } from "react";
 
export default function ViewCount({ initialViews }: { initialViews: number }) {
  const [views, setViews] = useState(initialViews);
 
  useEffect(() => {
    const updateViews = async () => {
      const updatedViews = await incrementViews();
      setViews(updatedViews);
    };
 
    updateViews();
  }, []);
 
  return <p>총 조회수: {views}</p>;
}
app/view-count.js
"use client";
 
import { incrementViews } from "./actions";
import { useState, useEffect } from "react";
 
export default function ViewCount({ initialViews }: { initialViews: number }) {
  const [views, setViews] = useState(initialViews);
 
  useEffect(() => {
    const updateViews = async () => {
      const updatedViews = await incrementViews();
      setViews(updatedViews);
    };
 
    updateViews();
  }, []);
 
  return <p>총 조회수: {views}</p>;
}

useEffect동작과 주의사항을 고려하는 것을 잊지 마세요.

오류 처리

오류가 발생하면 가장 가까운 error.js 또는 <Suspense> 경계에 의해 클라이언트에서 캐치됩니다. UI에서 처리할 오류를 반환하기 위해 try/catch를 사용하는 것을 권장합니다.

예를 들어, 서버 액션이 새 항목 생성 시 오류를 처리하고 메시지를 반환할 수 있습니다:

app/actions.ts
"use server";
 
export async function createTodo(prevState: any, formData: FormData) {
  try {
    // 데이터 변경
  } catch (e) {
    throw new Error("할 일 생성 실패");
  }
}
app/actions.js
"use server";
 
export async function createTodo(prevState, formData) {
  try {
    // 데이터 변경
  } catch (e) {
    throw new Error("할 일 생성 실패");
  }
}

알아두면 좋은 점:

데이터 재검증

서버 액션 내에서 revalidatePath API를 사용하여 Next.js 캐시를 재검증할 수 있습니다:

app/actions.ts
"use server";
 
import { revalidatePath } from "next/cache";
 
export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }
 
  revalidatePath("/posts");
}
app/actions.js
"use server";
 
import { revalidatePath } from "next/cache";
 
export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }
 
  revalidatePath("/posts");
}

또는 revalidateTag를 사용하여 캐시 태그로 특정 데이터 가져오기를 무효화할 수 있습니다:

app/actions.ts
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }
 
  revalidateTag("posts");
}
app/actions.js
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function createPost() {
  try {
    // ...
  } catch (error) {
    // ...
  }
 
  revalidateTag("posts");
}

리디렉션

서버 액션 완료 후 사용자를 다른 경로로 리디렉션하려면 redirect API를 사용할 수 있습니다. redirecttry/catch 블록 외부에서 호출해야 합니다:

app/actions.ts
"use server";
 
import { redirect } from "next/navigation";
import { revalidateTag } from "next/cache";
 
export async function createPost(id: string) {
  try {
    // ...
  } catch (error) {
    // ...
  }
 
  revalidateTag("posts"); // 캐시된 게시물 업데이트
  redirect(`/post/${id}`); // 새 게시물 페이지로 이동
}
app/actions.js
"use server";
 
import { redirect } from "next/navigation";
import { revalidateTag } from "next/cache";
 
export async function createPost(id) {
  try {
    // ...
  } catch (error) {
    // ...
  }
 
  revalidateTag("posts"); // 캐시된 게시물 업데이트
  redirect(`/post/${id}`); // 새 게시물 페이지로 이동
}

쿠키

서버 액션 내에서 cookies API를 사용하여 쿠키를 get, set, delete할 수 있습니다:

app/actions.ts
"use server";
 
import { cookies } from "next/headers";
 
export async function exampleAction() {
  // 쿠키 가져오기
  const value = cookies().get("name")?.value;
 
  // 쿠키 설정
  cookies().set("name", "Delba");
 
  // 쿠키 삭제
  cookies().delete("name");
}
app/actions.js
"use server";
 
import { cookies } from "next/headers";
 
export async function exampleAction() {
  // 쿠키 가져오기
  const value = cookies().get("name")?.value;
 
  // 쿠키 설정
  cookies().set("name", "Delba");
 
  // 쿠키 삭제
  cookies().delete("name");
}

서버 액션에서 쿠키를 삭제하는 추가 예제를 참조하세요.

보안

인증 및 권한 부여

서버 액션을 공개 API 엔드포인트처럼 취급하고 사용자가 해당 작업을 수행할 권한이 있는지 확인해야 합니다. 예를 들어:

app/actions.ts
"use server";
 
import { auth } from "./lib";
 
export function addItem() {
  const { user } = auth();
  if (!user) {
    throw new Error("이 작업을 수행하려면 로그인해야 합니다");
  }
 
  // ...
}

클로저 및 암호화

컴포넌트 내에서 서버 액션을 정의하면 액션이 외부 함수의 범위에 접근할 수 있는 클로저가 생성됩니다. 예를 들어, publish 액션은 publishVersion 변수에 접근할 수 있습니다:

app/page.tsx
export default async function Page() {
  const publishVersion = await getLatestVersion();
 
  async function publish() {
    "use server";
    if (publishVersion !== await getLatestVersion()) {
      throw new Error('게시 버튼을 누른 이후 버전이 변경되었습니다');
    }
    ...
  }
 
  return (
    <form>
      <button formAction={publish}>게시</button>
    </form>
  );
}
app/page.js
export default async function Page() {
  const publishVersion = await getLatestVersion();
 
  async function publish() {
    "use server";
    if (publishVersion !== await getLatestVersion()) {
      throw new Error('게시 버튼을 누른 이후 버전이 변경되었습니다');
    }
    ...
  }
 
  return (
    <form>
      <button formAction={publish}>게시</button>
    </form>
  );
}

클로저는 렌더링 시점의 데이터 스냅샷(예: publishVersion)을 캡처하여 나중에 액션이 호출될 때 사용할 수 있도록 하는 데 유용합니다.

그러나 이를 위해서는 캡처된 변수가 클라이언트로 전송되고 액션이 호출될 때 다시 서버로 전송되어야 합니다. 민감한 데이터가 클라이언트에 노출되는 것을 방지하기 위해 Next.js는 자동으로 클로저 변수를 암호화합니다. Next.js 애플리케이션이 빌드될 때마다 각 액션에 대해 새로운 개인 키가 생성됩니다. 이는 액션이 특정 빌드에 대해서만 호출될 수 있음을 의미합니다.

알아두면 좋은 점: 민감한 값이 클라이언트에 노출되는 것을 방지하기 위해 암호화에만 의존하는 것을 권장하지 않습니다. 대신 React taint APIs를 사용하여 특정 데이터가 클라이언트로 전송되는 것을 사전에 방지해야 합니다.

암호화 키 덮어쓰기 (고급)

Next.js 애플리케이션을 여러 서버에 자체 호스팅할 때, 각 서버 인스턴스가 서로 다른 암호화 키를 가질 수 있어 잠재적인 불일치를 유발할 수 있습니다.

이를 완화하기 위해 process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 환경 변수를 사용하여 암호화 키를 덮어쓸 수 있습니다. 이 변수를 지정하면 빌드 간에 암호화 키가 지속되고 모든 서버 인스턴스가 동일한 키를 사용하도록 보장합니다.

이는 여러 배포에서 일관된 암호화 동작이 애플리케이션에 중요한 고급 사용 사례입니다. 키 로테이션 및 서명과 같은 표준 보안 관행을 고려해야 합니다.

알아두면 좋은 점: Vercel에 배포된 Next.js 애플리케이션은 이를 자동으로 처리합니다.

허용된 출처 (고급)

서버 액션은 <form> 요소에서 호출될 수 있기 때문에 CSRF 공격에 노출될 수 있습니다.

내부적으로 서버 액션은 POST 메서드를 사용하며, 이 HTTP 메서드만 액션을 호출할 수 있습니다. 이는 SameSite 쿠키가 기본값인 최신 브라우저에서 대부분의 CSRF 취약점을 방지합니다.

추가적인 보호로, Next.js의 서버 액션은 Origin 헤더Host 헤더(또는 X-Forwarded-Host)와 비교합니다. 이들이 일치하지 않으면 요청이 중단됩니다. 즉, 서버 액션은 호스팅하는 페이지와 동일한 호스트에서만 호출될 수 있습니다.

리버스 프록시나 다층 백엔드 아키텍처를 사용하는 대규모 애플리케이션(서버 API가 프로덕션 도메인과 다른 경우)의 경우, serverActions.allowedOrigins 옵션을 사용하여 안전한 출처 목록을 지정하는 것이 좋습니다. 이 옵션은 문자열 배열을 받습니다.

next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
  experimental: {
    serverActions: {
      allowedOrigins: ["my-proxy.com", "*.my-proxy.com"],
    },
  },
};

보안 및 서버 액션에 대해 자세히 알아보세요.

추가 리소스

서버 액션에 대한 자세한 정보는 다음 React 문서를 확인하세요: