Server Actions: API 없이 form 처리하기
1. 개요
예전에는 Next.js에서 form을 처리하려면 API route(app/api/.../route.ts)를 작성한 후, client에서 fetch로 데이터를 submit해야 했습니다. Server Actions는 이 중간 단계를 없애줍니다. 서버에서 실행할 함수를 작성하고 이를 <form>의 action 속성에 직접 연결하기만 하면 됩니다.
이 글을 읽고 나면 Server Action을 어떻게 선언하는지, 검증이 있는 form에서 사용하는 방법, useFormState와 useFormStatus로 pending/error 상태를 처리하는 방법, submit 후에 데이터를 revalidate하는 방법, 그리고 API route에서 Server Actions로 전환할 때 겪는 흔한 실수들을 피하는 방법을 알게 될 겁니다.
참고: Server Actions는 App Router의 기능입니다(Next.js 13.4+). Next.js 14부터 안정적입니다. 아직 Pages Router를 사용 중이라면 이 글은 적용되지 않습니다.
2. 첫 번째 Server Action
Server Action은 "use server" directive가 있는 async 함수입니다. 별도의 파일에 놓거나 component 내에 직접 놓을 수 있습니다(해당 component가 Server Component인 경우).
// app/actions/customer-actions.ts
"use server";
export async function createCustomer(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
console.log("고객 생성:", name, email);
// DB 저장 시뮬레이션
}
// app/customers/new/page.tsx
import { createCustomer } from "@/app/actions/customer-actions";
export default function NewCustomerPage() {
return (
<form action={createCustomer}>
<input name="name" placeholder="고객명" />
<input name="email" placeholder="이메일" />
<button type="submit">생성</button>
</form>
);
}
"use server"이 파일의 맨 앞에 있으면 Next.js에게 이 파일의 모든 export가 Server Action임을 알려줍니다. 서버에서만 실행되며 client로 전송되는 JS에 번들되지 않습니다.action={createCustomer}은 함수를 form에 직접 연결합니다. Next.js가 자동으로 form을FormData로 serialize하고 submit 시 서버에서 함수를 호출합니다.onSubmit,preventDefault,fetch가 필요 없습니다. JavaScript가 아직 로드되지 않았을 때도 form이 작동합니다(progressive enhancement).
Submit할 때의 결과: log는 browser console이 아닌 server terminal에 나타납니다. 함수가 서버에서 실행되기 때문입니다.
3. useFormState로 client에 결과 반환하기
실제로는 검증 오류나 성공 메시지를 표시해야 합니다. useFormState(react-dom에서)는 Server Action이 state를 client로 반환하도록 도와줍니다.
// app/actions/customer-actions.ts
"use server";
type ActionResult = { success: boolean; message: string };
export async function createCustomer(
_prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
if (!name || !email) {
return { success: false, message: "이름 또는 이메일이 누락되었습니다." };
}
if (!email.includes("@")) {
return { success: false, message: "유효하지 않은 이메일입니다." };
}
// DB 저장 시뮬레이션
return { success: true, message: `고객 ${name}을(를) 생성했습니다.` };
}
// app/customers/new/page.tsx
"use client";
import { useFormState } from "react-dom";
import { createCustomer } from "@/app/actions/customer-actions";
const initialState = { success: false, message: "" };
export default function NewCustomerForm() {
const [state, formAction] = useFormState(createCustomer, initialState);
return (
<form action={formAction}>
<input name="name" placeholder="고객명" />
<input name="email" placeholder="이메일" />
<button type="submit">생성</button>
{state.message && (
<p style={{ color: state.success ? "green" : "red" }}>
{state.message}
</p>
)}
</form>
);
}
useFormState(action, initialState)은 Server Action을 감싸고[state, formAction]을 반환합니다.state는 action이 가장 최근에 반환한 값이고,formAction은<form action={...}>에 실제로 연결되는 함수입니다.- Server Action은 첫 번째 매개변수로
prevState를 추가로 받습니다.useFormState와 함께 사용할 때 필수이지만 사용하지 않을 수도 있습니다. useFormState(React hook)를 사용하기 때문에 이 파일은 Client Component여야 하므로"use client"가 필요합니다.
# 이메일이 없는 상태로 submit
> 이름 또는 이메일이 누락되었습니다.
# 유효한 submit
> Nguyễn Văn A 고객을 생성했습니다.
4. useFormStatus로 pending 상태 표시하기
Submit할 때 버튼을 비활성화하고 로딩을 표시하고 싶습니다. useFormStatus는 가장 가까운 부모 form의 pending 상태를 읽습니다. 단, 별도의 자식 component 내에 있어야 합니다.
// app/customers/new/submit-button.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "생성 중..." : "생성"}
</button>
);
}
useFormStatus는 component가<form>내에서 렌더링될 때만 작동합니다.<SubmitButton />을 form을 포함하는 component와 같은 component에 배치하면, React가 DOM tree에서 가장 가까운 부모 form을 찾기 때문에 여전히 올바르게 작동합니다.pending은 Server Action이 서버에서 실행되는 동안true입니다. 이를 통해 버튼을 비활성화하여 double-submit을 방지할 수 있습니다.
참고: form을 포함하는 component에서 바로
useFormStatus를 호출하고 form의 상태를 알기를 기대하지 마세요. 실제로 DOM tree를 따라 작동하지만, 동일한 페이지에 여러 form이 있을 때 혼동을 피하기 위해 별도의 자식 component로 분리하는 것이 best practice입니다.
5. Zod로 데이터 검증하기
if/else로 직접 검증하는 것은 빠르게 복잡해집니다. Zod를 사용하여 schema를 명확하게 선언하세요.
<!-- 해당 사항 없음 — npm dependency입니다. 아래를 참고하세요. -->
npm install zod
// app/actions/customer-actions.ts
"use server";
import { z } from "zod";
const customerSchema = z.object({
name: z.string().min(2, "이름은 최소 2자 이상이어야 합니다."),
email: z.string().email("유효하지 않은 이메일입니다."),
});
type ActionResult = {
success: boolean;
message: string;
errors?: Record<string, string[]>;
};
export async function createCustomer(
_prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const parsed = customerSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
});
if (!parsed.success) {
return {
success: false,
message: "유효하지 않은 데이터입니다.",
errors: parsed.error.flatten().fieldErrors,
};
}
return { success: true, message: `고객 ${parsed.data.name}을(를) 생성했습니다.` };
}
safeParse는success및data/error를 가진 object를 반환하며 예외를 throw하지 않습니다. action을 충돌시키는 대신 client로 오류를 반환하기에 적합합니다.flatten().fieldErrors는{ name: ["이름은..."], email: [...] }형태의 object를 반환하여 각 input 바로 아래에 오류를 표시하기가 편합니다.- Schema는 한 번 정의하고 client 검증(필요한 경우)과 server 모두에 재사용할 수 있습니다. 로직 중복을 방지합니다.
6. Submit 후 데이터 revalidate하기
고객을 생성한 후 고객 목록 페이지(Next.js에 의해 cache됨)를 업데이트해야 합니다. revalidatePath 또는 revalidateTag를 사용합니다.
// app/actions/customer-actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createCustomer(formData: FormData) {
const name = formData.get("name") as string;
// ... DB에 저장
revalidatePath("/customers");
redirect("/customers");
}
revalidatePath("/customers")은 해당 페이지의 cache를 삭제하므로 다음 방문 시 Next.js가 새 데이터로 다시 렌더링합니다.redirect은 Next.js가 리다이렉션을 처리하기 위해 catch할 수 있는 특수 예외를 throw합니다.revalidatePath다음에 호출하세요.try/catch에 넣지 마세요. 리다이렉션이 작동하지 않기 때문입니다.- 두 함수 모두 Server Action 또는 Server Component에서만 호출할 수 있으며 client에서는 호출할 수 없습니다.
7. Client Component에서 Server Action 호출하기 (form을 거치지 않고)
Server Action을 반드시 <form>에 연결할 필요는 없습니다. onClick처럼 일반적인 async 함수로 직접 호출할 수 있습니다.
// app/customers/[id]/delete-button.tsx
"use client";
import { useState } from "react";
import { deleteCustomer } from "@/app/actions/customer-actions";
export function DeleteButton({ customerId }: { customerId: string }) {
const [isDeleting, setIsDeleting] = useState(false);
async function handleClick() {
setIsDeleting(true);
await deleteCustomer(customerId);
setIsDeleting(false);
}
return (
<button onClick={handleClick} disabled={isDeleting}>
{isDeleting ? "삭제 중..." : "삭제"}
</button>
);
}
// app/actions/customer-actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function deleteCustomer(id: string) {
console.log("고객 ID 삭제:", id);
revalidatePath("/customers");
}
- Server Action은 실제로 Next.js에서 숨겨진 POST endpoint로 빌드되고, client가 자동으로 생성된 network request로 호출합니다. fetch 코드는 보이지 않지만 실제로는 Network tab에서 볼 수 있습니다.
- 이는 delete button, like, toggle 상태 등에 편리합니다. 완전한
<form>이 필요하지 않은 경우입니다.
8. Server Action 테스트 작성하기
Server Action은 단순히 FormData를 받는 async 함수이므로 일반 unit function처럼 테스트할 수 있습니다. HTTP mock이 필요하지 않습니다.
npm install -D vitest
// app/actions/customer-actions.test.ts
import { describe, it, expect } from "vitest";
import { createCustomer } from "./customer-actions";
function buildFormData(data: Record<string, string>): FormData {
const fd = new FormData();
for (const [key, value] of Object.entries(data)) {
fd.append(key, value);
}
return fd;
}
describe("createCustomer", () => {
it("유효하지 않은 이메일일 때 오류를 반환합니다", async () => {
const formData = buildFormData({ name: "An", email: "khong-hop-le" });
const result = await createCustomer({ success: false, message: "" }, formData);
expect(result.success).toBe(false);
expect(result.errors?.email).toContain("유효하지 않은 이메일입니다.");
});
it("유효한 데이터로 성공합니다", async () => {
const formData = buildFormData({ name: "An", email: "an@example.com" });
const result = await createCustomer({ success: false, message: "" }, formData);
expect(result.success).toBe(true);
expect(result.message).toContain("An");
});
});
buildFormData는 browser가 보내는 것처럼FormData를 시뮬레이션합니다. component를 렌더링하거나 server를 mock할 필요가 없습니다.- Server Action은 순수 함수(입력 받고 출력 반환)이므로 test가 빠르게 실행되고 Next.js dev server를 시작할 필요가 없습니다.
참고: Server Action이
redirect또는revalidatePath를 호출하면 test가 오류를 throw합니다. 이 함수들은 Next.js의 실제 request context에서만 작동하기 때문입니다. 테스트를 더 쉽게 하려면 business logic(검증, 계산)을redirect/revalidatePath호출 부분과 분리하세요.
9. 흔한 실수들
"use server" 빠뜨리기 또는 잘못된 위치에 배치하기. Directive는 파일의 첫 줄(또는 Server Component 내에서 인라인으로 선언하는 경우 함수의 첫 줄)에 있어야 합니다. 빠뜨리면 Next.js는 일반 코드로 간주하고 client로 bundle한 후 실행할 때 오류가 발생합니다.
Server Component에서 useFormState/useFormStatus 사용하기. 이 두 hook은 react-dom에 속하며 Client Component에서만 실행됩니다. "use client"를 빠뜨리면 빌드 오류가 발생합니다.
Server Action에서 복잡한 object(class instance, serialize되지 않은 Date) 반환하기. 반환된 데이터는 network를 통해 serialize 가능해야 합니다(JSON처럼). 원본 Date를 반환하면 때때로 숨겨진 오류가 발생합니다. 먼저 문자열로 변환하세요.
모든 것에 Server Action 호출하기, 심지어 서버가 필요하지 않은 로직도. DB와 관계없는 단순 검증만 필요하면 client에서 처리하는 것이 더 빠르며 불필요한 round-trip을 피할 수 있습니다.
JavaScript 오류 시 fallback 없음. Server Actions는 progressive enhancement를 지원하지만 <form action={...}>를 올바르게 사용할 때만 가능합니다. preventDefault()를 하고 직접 action을 호출하면 이 이점을 잃습니다.
10. Server Actions를 사용하면 안 되는 경우
Server Actions는 Next.js 앱의 내부 CRUD form에 적합합니다. 하지만 다음이 필요한 경우:
- mobile app이나 third party가 호출할 public API — 실제 Route Handler(
route.ts)를 작성하세요. versioning, auth가 명확해야 합니다. - Streaming response 또는 매우 큰 file upload를 세밀하게 제어해야 함 — Route Handler가 더 유연합니다.
- Logic이 Next.js context 외부에서 독립적으로 실행되거나 재사용되어야 함 (예: worker, cron job) — 별도 service로 분리하세요. Server Action은 해당 service를 호출하기만 합니다.
11. 결론
Server Actions는 API route를 별도로 구축할 필요 없이 서버에서 form 데이터를 처리하는 코드를 작성하도록 도와줍니다. fetch/JSON boilerplate을 크게 줄입니다. useFormState로 오류를 표시하고, useFormStatus로 pending을 보여주고, revalidatePath로 cache를 업데이트하면 깔끔한 CRUD flow를 얻을 수 있으며, JavaScript가 아직 로드되지 않았을 때도 작동합니다. Zod를 사용해 명확히 검증하고, 외부에서 사용할 public API가 필요하면 실제 Route Handler로 분리하는 것을 기억하세요.