"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"가 있었다. 이게 그 예외다.
함수가 건너가는 게 아니다
어떻게 브라우저가 서버 함수를 부를까. 함수를 문자로 바꿔 보내는 게 아니다.
- 빌드createComment 에 고유 ID 를 붙인다
- 브라우저에 가는 것함수가 아니라 ID 문자열
- 제출그 ID 로 POST 요청을 보낸다
- 서버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이면 살아난다.
결과와 대기 상태를 보여준다
저장이 실패했을 때 메시지를 띄우려면 액션의 반환값이 필요하다. 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 } }); // 위험하다
}
관리자 화면에서만 이 버튼을 그렸더라도, 아무나 아무 글이나 지울 수 있다.
- 01
누구인지 확인한다
세션을 읽는다. 로그인하지 않았으면 여기서 끝낸다.
const session = await auth(); if (!session) return { error: "로그인이 필요하다" }; - 02
그럴 권한이 있는지 확인한다
로그인했다는 것과 이 글을 지워도 된다는 것은 다르다.
const post = await db.post.findUnique({ where: { id: postId } }); if (post.authorId !== session.userId) return { error: "권한이 없다" }; - 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로 옮긴다.redirect는try밖에서. - 액션은 공개된 엔드포인트다. 인증·인가·검증을 액션 안에서 매번 확인한다. 버튼을 숨기는 건 보안이 아니다.
Part 3이 끝났다. 서버와 브라우저가 어디서 갈라지는지, 그 경계를 어떻게 넘나드는지 배웠다.
다음 강의부터 Part 4다. revalidatePath가 "캐시를 버린다"고 했는데, 우리는 캐시를 켠 적이 없다. Next.js 16에서 캐싱은 켜야 동작한다. 그 전에 더 근본적인 질문이 있다. 이 페이지는 빌드할 때 만들어졌는가, 요청이 올 때 만들어졌는가. 5강에서 generateStaticParams로 스쳐 지나간 그 갈림길이다.