학습 자료

"use server" — 서버 함수를 버튼에 건다


Article

10강까지 데이터를 읽기만 했다. 이제 쓴다. 댓글을 저장해야 한다.

지금까지 배운 것만으로 하면 이렇게 된다. 먼저 엔드포인트를 만들고,

// app/api/comments/route.js
export async function POST(request) {
  const { text } = await request.json();
  await db.comment.create({ data: { text } });
  return Response.json({ ok: true });
}

브라우저에서 부른다.

"use client";

export default function CommentForm() {
  const [text, setText] = useState("");
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState(null);

  async function handleSubmit(e) {
    e.preventDefault();
    setIsPending(true);
    try {
      const res = await fetch("/api/comments", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text }),
      });
      if (!res.ok) throw new Error("실패");
    } catch (err) {
      setError(err.message);
    } finally {
      setIsPending(false);
    }
  }
  …
}

동작한다. 그리고 파일 두 개, 상태 세 개, 주소 문자열 하나가 생겼다. /api/comments라는 이름은 어디에도 강제되지 않는다. 오타를 내면 런타임에야 안다.

3강에서 서버 컴포넌트가 await db.query()를 바로 쓰던 걸 떠올려보자. 읽을 때는 이 모든 게 필요 없었다. 쓸 때만 왜 이래야 하나.

함수에 한 줄을 붙인다

// app/actions.js
"use server";

import { db } from "@/lib/db";

export async function createComment(formData) {
  const text = formData.get("text");
  await db.comment.create({ data: { text } });
}
// app/CommentForm.jsx — 클라이언트 컴포넌트가 아니어도 된다
import { createComment } from "./actions";

export default function CommentForm() {
  return (
    <form action={createComment}>
      <textarea name="text" />
      <button type="submit">등록</button>
    </form>
  );
}

끝이다. useState도, fetch도, 주소 문자열도 없다. "use client"조차 없다.

8강에서 함수는 props로 넘길 수 없다고 했다. 그 에러 메시지 끝에 "use server"가 있었다. 이게 그 예외다.

함수가 건너가는 게 아니다

어떻게 브라우저가 서버 함수를 부를까. 함수를 문자로 바꿔 보내는 게 아니다.

빌드할 때와 실행할 때
  1. 빌드createComment 에 고유 ID 를 붙인다
  2. 브라우저에 가는 것함수가 아니라 ID 문자열
  3. 제출그 ID 로 POST 요청을 보낸다
  4. 서버ID 로 함수를 찾아 실행한다

Next.js가 우리 대신 엔드포인트를 만든 것이다. 우리가 주소를 짓지 않았을 뿐이다. 그래서 오타가 날 자리가 없고, 함수 이름을 바꾸면 양쪽이 같이 바뀐다.

선언하는 두 자리

파일 맨 위에 쓰면 그 파일의 모든 export가 액션이 된다.

"use server";

export async function createComment(formData) { … }
export async function deleteComment(id) { … }

서버 컴포넌트 안에서는 함수 안쪽에 쓸 수도 있다.

export default async function Post({ params }) {
  const { id } = await params;

  async function addComment(formData) {
    "use server";
    await db.comment.create({ data: { postId: id, text: formData.get("text") } });
  }

  return <form action={addComment}></form>;
}

바깥의 id를 그대로 쓴다. 자바스크립트 편 14강의 클로저다. 다만 이 클로저가 보안 문제를 만든다. 뒤에서 다시 본다.

어디에 쓰나무엇이 액션이 되나언제
파일 맨 위그 파일의 모든 export여러 곳에서 재사용
함수 안 첫 줄그 함수 하나서버 컴포넌트 안에서만
클라이언트 컴포넌트 안불가능import 해서 쓴다

클라이언트 컴포넌트 파일 안에는 "use server" 함수를 쓸 수 없다. 그 파일은 브라우저로 가기 때문이다. 별도 파일에서 import 한다. 9강의 경계 규칙이 여기서도 그대로다.

자바스크립트가 꺼져 있어도 동작한다

<form action={createComment}>를 눈여겨본다. React 편 19강에서 배운 Actions 문법이다.

onSubmit이 아니라 action이다. 그래서 자바스크립트가 로드되기 전에도 폼이 제출된다. 브라우저의 기본 폼 동작이 살아 있기 때문이다.

하이드레이션 전에 제출했다면
onSubmit
아무 일도 없다이제 동작
action
브라우저가 그냥 제출한다이제 새로고침 없이
HTML 도착하이드레이션 완료
3강의 "글자는 보이는데 아직 안 눌리는 구간"이 여기서 문제가 된다

느린 네트워크에서 성급하게 제출 버튼을 누른 사용자가 있다. onSubmit이면 그 클릭은 사라진다. action이면 살아난다.

결과와 대기 상태를 보여준다

저장이 실패했을 때 메시지를 띄우려면 액션의 반환값이 필요하다. useActionState를 쓴다.

"use server";

export async function createComment(prevState, formData) {
  const text = formData.get("text");

  if (!text?.trim()) return { error: "내용을 입력한다" };

  await db.comment.create({ data: { text } });
  return { message: "등록됐다" };
}

첫 인자가 이전 상태다. 폼에서 넘긴 formData는 두 번째로 밀린다.

"use client";

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

export default function CommentForm() {
  const [state, formAction, isPending] = useActionState(createComment, {});

  return (
    <form action={formAction}>
      <textarea name="text" />
      <button disabled={isPending}>{isPending ? "저장 중…" : "등록"}</button>
      {state.error && <p role="alert">{state.error}</p>}
      {state.message && <p>{state.message}</p>}
    </form>
  );
}

React 편 19강에서 배운 그대로다. 달라진 것은 createComment다른 컴퓨터에서 실행된다는 것뿐이다.

isPending 대신 useFormStatus를 쓰는 방법도 있다. 버튼을 별도 컴포넌트로 뺄 때 유용하다.

"use client";

import { useFormStatus } from "react-dom";

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

반드시 <form> 안쪽 자식이어야 한다. 폼 바깥에서 부르면 항상 false다.

저장한 뒤에 화면을 고친다

댓글을 저장했는데 목록이 그대로다. 서버 컴포넌트가 다시 실행되지 않았기 때문이다.

"use server";

import { revalidatePath } from "next/cache";

export async function createComment(formData) {
  await db.comment.create({ data: { text: formData.get("text") } });

  revalidatePath("/posts");
}

revalidatePath그 주소의 캐시를 버려라는 신호다. 다음에 그릴 때 서버 컴포넌트가 다시 돈다.

6강에서 router.refresh()로 하던 일이다. 이제 서버에서 처리하니 클라이언트 컴포넌트가 필요 없다.

무엇을 얼마나 버릴지 정교하게 다루는 방법은 14강에서 본다. Next.js 16에서 이 부분이 크게 바뀌었다.

저장한 뒤에 다른 페이지로 보낸다

"use server";

import { redirect } from "next/navigation";

export async function createPost(formData) {
  const post = await db.post.create({ data: { … } });

  redirect(`/posts/${post.id}`);
}

6강에서 redirect()예외를 던진다고 했다. try 블록 안에서 부르면 catch가 삼킨다. 액션 안에서도 똑같다.

이 함수는 공개된 주소다

여기가 가장 중요하다. Server Action은 누구나 호출할 수 있는 HTTP 엔드포인트다.

버튼을 화면에 안 그렸다고 안전한 게 아니다. 브라우저에 실려간 ID를 알아내면 직접 POST를 보낼 수 있다.

"use server";

export async function deletePost(postId) {
  await db.post.delete({ where: { id: postId } });   // 위험하다
}

관리자 화면에서만 이 버튼을 그렸더라도, 아무나 아무 글이나 지울 수 있다.

액션 안에서 매번 확인한다
  1. 01

    누구인지 확인한다

    세션을 읽는다. 로그인하지 않았으면 여기서 끝낸다.

    const session = await auth();
    if (!session) return { error: "로그인이 필요하다" };
  2. 02

    그럴 권한이 있는지 확인한다

    로그인했다는 것과 이 글을 지워도 된다는 것은 다르다.

    const post = await db.post.findUnique({ where: { id: postId } });
    if (post.authorId !== session.userId) return { error: "권한이 없다" };
  3. 03

    입력값을 검증한다

    formData 는 사용자가 보낸 것이다. 타입도 길이도 믿을 수 없다.

무엇이 우리를 지켜주고 무엇이 지켜주지 않는가

Next.js 가 해준다

자동

  • 액션 ID 는 추측하기 어렵다
  • 빌드마다 ID 가 바뀐다
  • 쓰지 않는 액션은 제거된다
  • 클로저로 잡은 값은 암호화된다
  • Origin 헤더를 검사한다

우리가 해야 한다

빼먹으면 뚫린다

  • 인증 — 누구인가
  • 인가 — 그럴 자격이 있나
  • 검증 — 입력값이 정상인가
  • 버튼을 숨기는 건 보안이 아니다

폼이 아닌 곳에서 부른다

버튼 하나로 액션을 실행할 수도 있다.

"use client";

import { deletePost } from "./actions";

export default function DeleteButton({ postId }) {
  return <button onClick={() => deletePost(postId)}>삭제</button>;
}

동작하지만 자바스크립트가 필요하다. 폼의 이점을 잃는다. 인자를 미리 묶어 넘기는 편이 낫다.

<form action={deletePost.bind(null, postId)}>
  <button>삭제</button>
</form>

bind로 첫 인자를 고정했다. 이 값도 암호화되어 오가고, 액션은 deletePost(postId, formData)로 받는다.

정리하면

  • "use server"를 붙이면 그 함수를 브라우저에서 직접 부를 수 있다. API Route를 만들 필요가 없다.
  • 함수가 건너가는 게 아니라 ID가 건너간다. Next.js가 엔드포인트를 대신 만든다.
  • "use server"엔드포인트 선언이고, "use client"경계 선언이다. 반대말이 아니다.
  • 클라이언트 컴포넌트 파일 안에는 액션을 정의할 수 없다. import 한다.
  • <form action={…}>은 하이드레이션 전에도 동작한다. onSubmit은 그러지 못한다.
  • useActionState로 결과와 대기 상태를 받는다. 에러는 던지지 말고 객체로 돌려준다.
  • 저장 후에는 revalidatePath로 화면을 고치고, redirect로 옮긴다. redirecttry 밖에서.
  • 액션은 공개된 엔드포인트다. 인증·인가·검증을 액션 안에서 매번 확인한다. 버튼을 숨기는 건 보안이 아니다.

Part 3이 끝났다. 서버와 브라우저가 어디서 갈라지는지, 그 경계를 어떻게 넘나드는지 배웠다.

다음 강의부터 Part 4다. revalidatePath가 "캐시를 버린다"고 했는데, 우리는 캐시를 켠 적이 없다. Next.js 16에서 캐싱은 켜야 동작한다. 그 전에 더 근본적인 질문이 있다. 이 페이지는 빌드할 때 만들어졌는가, 요청이 올 때 만들어졌는가. 5강에서 generateStaticParams로 스쳐 지나간 그 갈림길이다.

"use server" — 서버 함수를 버튼에 건다 — 디코드랩(DCODELAB)