학습 목표·선수 지식: 클라이언트 검증 뒤 서버 필드 오류를 입력에 연결합니다. 선수 지식: React 폼·async/await.
이 글에서 정리하는 내용
Server Actions와 React Hook Form을 같이 쓸 때 클라이언트 검증과 서버 검증 역할이 겹쳐 보이는 상황을 기준으로 원인을 좁히고, 실제 프로젝트에서 어떤 설정과 코드 구조를 확인해야 하는지 정리합니다. 단순 개념 소개가 아니라 오류가 난 순간 바로 확인할 순서와 선택 기준을 중심으로 설명합니다.
실습 경로: 현재 ZIP → 전체 코드. 먼저 설명에서 지정한 파일만 읽고, 설정·테스트 파일은 필요한 때 확인하세요.
Server Actions와 React Hook Form의 역할 차이

Server Actions와 React Hook Form을 같이 쓸 때는 “사용자 입력 경험”과 “서버에서 신뢰할 검증”을 분리해서 봐야 합니다. 클라이언트 검증은 빠른 피드백을 주고, Server Action 검증은 실제 저장 전에 반드시 다시 확인하는 안전장치입니다.
먼저 form 제출이 일반 action으로 Server Action에 들어가는지, React Hook Form의 handleSubmit을 거쳐 호출되는지 확인합니다. 에러 메시지 표시 흐름은 React Hook Form 에러 메시지 표시 문제 해결도 함께 보면 좋습니다.
클라이언트 검증과 서버 검증을 나누는 기준
클라이언트 검증은 빈 값, 형식 오류, 즉각적인 입력 피드백에 강합니다. 하지만 브라우저에서 통과했다고 해서 서버가 그 값을 믿으면 안 됩니다. 권한, 중복 데이터, DB 제약, 최종 schema 검증은 Server Action 안에서 다시 처리해야 합니다.
Zod schema를 같이 쓴다면 클라이언트에서는 React Hook Form resolver로 사용자 피드백을 주고, 서버에서는 같은 의미의 schema로 FormData를 다시 검증합니다. 중복처럼 보여도 목적이 다릅니다. 하나는 UX이고, 하나는 신뢰 경계입니다.
아래 actions.ts 전체 코드에서 app/form.tsx의 useForm과 app/actions.ts의 createInquiry가 서로 다른 실행 위치를 맡는 것을 확인합니다.
실무에서는 schema를 완전히 따로 흩뜨리면 시간이 지나며 기준이 달라질 수 있습니다. 가능한 한 같은 필드 정의를 공유하거나, 최소한 클라이언트와 서버 에러 메시지가 같은 규칙을 설명하도록 맞춰야 합니다.
Zod schema를 함께 쓸 때의 구조
Zod schema를 함께 쓸 때 가장 중요한 기준은 서버 검증을 생략하지 않는 것입니다. React Hook Form resolver가 있어도 사용자는 요청을 직접 보낼 수 있고, 브라우저 검증은 쉽게 우회될 수 있습니다.
Server Action에서 검증 실패를 반환한다면 화면은 그 상태를 사용자가 이해할 수 있게 보여줘야 합니다. Next.js의 form guide처럼 useActionState를 쓰는 구조에서는 서버가 반환한 에러를 form 상태로 연결하고, React Hook Form을 함께 쓰는 구조에서는 제출 흐름이 중복되지 않게 정리합니다.
성공·실패 상태를 화면에 보여주는 흐름

성공·실패 상태는 제출 버튼 주변, field error, 전체 form 메시지로 나눠 보여주는 것이 좋습니다. 서버에서 실패한 값은 field별 에러로 돌려줄 수 있으면 가장 좋고, 권한이나 중복 요청처럼 field 하나에 묶기 어려운 문제는 form-level message로 보여줍니다.
성공 후에는 form reset, redirect, toast, 목록 재검증 중 어떤 흐름이 맞는지 정합니다. 저장은 됐는데 화면이 stale하게 남는다면 Server Action 자체보다 이후 갱신 흐름이 빠진 것일 수 있습니다.
간단한 폼과 복잡한 폼의 선택 기준
간단한 문의 폼이라면 Server Action과 기본 form만으로도 충분할 수 있습니다. 입력이 많고 즉각적인 field validation, dirty state, 복잡한 UI 제어가 필요하다면 React Hook Form을 함께 쓰는 편이 낫습니다.
구조를 정할 때는 “이 폼은 Server Action 기본 제출”, “이 폼은 React Hook Form으로 클라이언트 검증 후 Server Action 호출”, “서버 검증 에러는 fieldErrors로 반환”처럼 책임을 명확히 적어 두면 유지보수가 쉬워집니다. 클라이언트 검증을 서버 검증의 대체재로 보지 않고, 빠른 피드백과 최종 신뢰 검증을 각각 맡기는 것이 기준입니다.
수정 후에는 빈 값과 형식 오류가 클라이언트에서 바로 표시되는지, 중복·권한 같은 서버 전용 오류가 올바른 필드 또는 form-level 메시지로 돌아오는지, 성공 시 reset·redirect·재검증이 의도대로 동작하는지를 각각 확인합니다.
서버 field error를 React Hook Form에 연결하는 순서
클라이언트 검증을 통과해도 이메일 중복, 권한 부족, 이미 마감된 신청처럼 서버에서만 판단할 수 있는 오류가 남습니다. 이 오류를 상단에 한 문장으로만 보여주면 사용자는 어느 입력을 고쳐야 하는지 알기 어렵습니다. Server Action이 필드별 오류를 반환하고 React Hook Form의 setError로 연결하면 같은 입력 영역에서 바로 수정할 수 있습니다.
handleSubmit에서 검증된 값을FormData로 만들어 Server Action에 전달합니다.- 서버에서는 Zod
safeParse와 데이터베이스 검증을 모두 실행합니다. - 실패하면
{ fieldErrors: { email: ['이미 가입된 이메일입니다.'] } }처럼 필드 이름을 유지해 반환합니다. - 클라이언트는 반환된 키를 순회하며
setError('email', { message })로 입력창에 연결합니다. - 성공했을 때만
reset, 이동 또는 완료 메시지를 실행합니다.
네트워크 오류처럼 특정 입력과 연결할 수 없는 실패는 root.server 오류로 분리하는 편이 좋습니다. 이렇게 하면 필드 오류와 전체 요청 오류가 섞이지 않고, 사용자는 고칠 수 있는 문제와 다시 시도해야 하는 문제를 구분할 수 있습니다.
최종 검증에서는 잘못된 값을 브라우저에서 직접 제출하는 경우도 확인해야 합니다. 클라이언트 코드는 우회할 수 있으므로 저장 직전의 Server Action 검증이 항상 최종 기준이어야 합니다.
하나의 제출 경로로 연결하는 전체 실습
아래 프로젝트는 handleSubmit → FormData → createInquiry → safeParse → setError 흐름입니다. form action과 onSubmit을 동시에 등록하지 않습니다. 서버는 unknown 입력에서 필드를 다시 추출하고 검증하며, 실패 시 email/message 필드 이름으로 오류를 돌려줍니다. demo@example.com은 서버 전용 정책 거절을 재현하는 학습값입니다. DB·인증·메일 발송은 제공하지 않으며 성공 문구도 검증 완료라고만 표시합니다.
빈 입력은 클라이언트 오류, demo@example.com과 충분한 본문은 서버 필드 오류, 다른 올바른 이메일은 검증 완료가 기대 결과입니다. 네트워크 실패는 root.server로 표시하고 성공 때만 reset합니다. 브라우저 UI 조작은 별도 확인 과제이고 서버 함수를 직접 호출한 테스트는 클라이언트 검증을 우회해도 safeParse가 작동하는지 검사합니다.
현재 실습 ZIP
전체 실행 파일
아래는 함께 실행하는 단일 완성 프로젝트입니다. ZIP을 풀고 프로젝트 폴더에서 npm install, npm run build, npm run start를 실행합니다. 개발은 npm run dev, 타입 검사는 npm run typecheck입니다. Node.js 20.9 이상, 검증 환경 Node.js 24.19.0 / Next.js 16.3.5 / React 19.3.0 / TypeScript 7.0.2입니다.
README.md
# 실습 4440
Node.js 20.9 이상 (검증: 24.19.0). 이 폴더에서 npm install, npm run build, npm run start. 개발은 npm run dev. 타입 검사는 npm run typecheck.
Next.js 16.3.5 / React 19.3.0 / TypeScript 7.0.2. 외부 DB·인증 서비스는 제공하지 않습니다.
검증: 로컬 production build와 HTTP 검사. 브라우저 조작과 실제 배포는 별도 확인합니다.
app/actions.ts
"use server";
import { schema, type Result } from "./schema";
export async function createInquiry(data: FormData): Promise<Result> {
const result = schema.safeParse({
email: data.get("email"),
message: data.get("message"),
});
if (!result.success) {
const fieldErrors: Partial<Record<"email" | "message", string[]>> = {};
for (const issue of result.error.issues) {
const key = issue.path[0];
if (key === "email" || key === "message")
(fieldErrors[key] ??= []).push(issue.message);
}
return { ok: false, fieldErrors, message: "입력을 다시 확인하세요." };
}
if (result.data.email === "demo@example.com")
return {
ok: false,
fieldErrors: { email: ["서버 정책 거절을 재현하는 주소입니다."] },
message: "다른 학습용 이메일을 입력하세요.",
};
return {
ok: true,
message: "입력 검증 완료. 이 예제는 저장하거나 메일을 보내지 않습니다.",
};
}
app/form.tsx
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { schema, type Values } from "./schema";
import { createInquiry } from "./actions";
export default function InquiryForm() {
const [notice, setNotice] = useState("");
const {
register,
handleSubmit,
setError,
clearErrors,
reset,
formState: { errors, isSubmitting },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { email: "", message: "" },
});
const submit = handleSubmit(async (values) => {
setNotice("");
clearErrors("root.server");
const data = new FormData();
data.set("email", values.email);
data.set("message", values.message);
try {
const result = await createInquiry(data);
if (!result.ok) {
for (const name of ["email", "message"] as const) {
const message = result.fieldErrors[name]?.[0];
if (message) setError(name, { type: "server", message });
}
setError("root.server", { message: result.message });
return;
}
reset();
setNotice(result.message);
} catch {
setError("root.server", {
message: "요청을 완료하지 못했습니다. 연결을 확인하고 다시 시도하세요.",
});
}
});
return (
<form onSubmit={submit} noValidate>
<label htmlFor="email">이메일</label>
<input
id="email"
{...register("email")}
aria-invalid={Boolean(errors.email)}
aria-describedby="email-error"
/>
<p id="email-error">{errors.email?.message}</p>
<label htmlFor="message">본문</label>
<textarea
id="message"
{...register("message")}
aria-invalid={Boolean(errors.message)}
aria-describedby="message-error"
/>
<p id="message-error">{errors.message?.message}</p>
<button disabled={isSubmitting}>
{isSubmitting ? "검증 중" : "검증 요청"}
</button>
<p role="alert">{errors.root?.server?.message}</p>
<p role="status">{notice}</p>
</form>
);
}
app/layout.tsx
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}
app/page.tsx
import InquiryForm from "./form";
export default function Page() {
return (
<main>
<h1>문의 입력 검증</h1>
<InquiryForm />
</main>
);
}
app/schema.ts
import { z } from "zod";
export const schema = z.object({
email: z.string().trim().email("이메일 형식을 확인하세요."),
message: z
.string()
.trim()
.min(10, "본문은 10자 이상 입력하세요.")
.max(1000, "본문은 1000자 이하입니다."),
});
export type Values = z.infer<typeof schema>;
export type Result =
| { ok: true; message: string }
| {
ok: false;
fieldErrors: Partial<Record<keyof Values, string[]>>;
message: string;
};
next-env.d.ts
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
package.json
{
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build --webpack",
"start": "next start",
"typecheck": "next typegen && tsc --noEmit"
},
"engines": {
"node": ">=20.9.0"
},
"dependencies": {
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0",
"zod": "4.6.2",
"react-hook-form": "7.88.0",
"@hookform/resolvers": "5.9.1"
},
"devDependencies": {
"typescript": "7.0.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/node": "22.20.2"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "esnext"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"plugins": [
{
"name": "next"
}
],
"allowJs": true,
"incremental": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
실제 검증: next build exit 0; 서버 Action 직접 호출 4가지(빈값·형식오류·서버정책거절·성공) 통과. HTTP 200; 브라우저 UI·실제 네트워크 실패 미검증. 코드 뷰어와 ZIP의 모든 파일을 바이트/복사 텍스트로 대조했습니다.
공식 자료 재확인: 2026-09-12. 이 글의 실행하지 않은 브라우저/배포 점검은 독자 확인 과제로 구분합니다.
공식 기준과 확인 범위
확인일: 2026-09-12. API 설명은 Next.js 16 App Router 기준이며 과거 릴리스 글은 본문의 태그 범위를 유지합니다. 부분 API 코드는 기존 프로젝트에 통합하는 예시입니다.
- https://nextjs.org/docs/app/guides/forms
- https://zod.dev/basics
- https://react-hook-form.com/docs/useform/seterror
같이 읽으면 좋은 글
이 글이 도움이 되었나요?
Next.js 학습 순서
필수 13개 · 전체 27개
읽음 기록 관리
전체 과정 목차 (27개)
- 필수 길잡이 · Next.js App Router 학습 순서: 설치부터 배포까지
- 필수 학습 · Next.js package.json: scripts dependencies 이해
- 필수 학습 · Next.js App Router로 만드는 첫 프로젝트 완성 실습 가이드
- 필수 학습 · Next.js에서 .next 폴더는 어떤 역할을 할까?
- 필수 학습 · Next.js 동적 라우트 완전 정리: [slug], params, catch-all
- 선택 참고 · Next.js params should be awaited 해결: App Router 기준
- 선택 참고 · Next.js window is not defined 오류 해결: 브라우저 API를 안전하게 쓰기
- 선택 참고 · Next.js hydration failed 오류 해결: 원인과 해결 방법
- 필수 학습 · Axios 사용법: React Next.js에서 API 요청 구조 잡는 법
- 선택 참고 · Next.js Route Handler 405 오류 해결: GET/POST 파일 위치와 메서드 설정 확인
- 필수 학습 · Next.js Server Actions + React Hook Form 검증 기준 현재 글
- 선택 참고 · Next.js useSearchParams Suspense 오류 해결: 빌드 실패 기준
- 선택 참고 · Next.js fetch 캐시 문제 해결: 데이터가 바뀌었는데 화면이 그대로일 때
- 선택 참고 · Next.js Dynamic server usage 오류 해결: cookies headers 기준
- 선택 참고 · Next.js 환경변수 적용 오류 해결: .env.local을 바꿨는데 값이 안 바뀔 때
- 필수 학습 · Next.js Metadata API 완전 정리: 정적 metadata와 generateMetadata
- 선택 참고 · Next.js metadata가 적용되지 않을 때 확인할 7가지
- 선택 참고 · Next.js Image remotePatterns 오류 해결: 외부 이미지 도메인 허용하기
- 필수 학습 · Next.js redirects 설정: next.config.js에서 URL 이동 처리
- 필수 학습 · Next.js SEO 체크리스트: metadata·초기 HTML·OG 이미지 점검
- 선택 참고 · Next.js SEO 완전 가이드: App Router metadata부터 배포 확인까지
- 필수 학습 · Next.js 16 성능 최적화 체크리스트: 번들·이미지·캐시·배포
- 필수 학습 · Next.js 렌더링 성능 최적화: React 화면이 느릴 때 기준
- 필수 학습 · Next.js 16 Proxy 마이그레이션: Node.js Runtime·matcher 검증
- 선택 참고 · Next.js 보안 패치 기준: v16.2.5 영향 범위 점검
- 선택 참고 · Next.js SEO SSR 적용법: 검색 노출과 렌더링 구조 잡기
- 시점·기록 · Next.js 16.3.0-canary.106의 useCache deprecation 경고와 hybrid not-found 수정 이해하기
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.