06 · Developer Guide · 레시피
폼 검증 — react-hook-form + zodForm Validation
모든 폼에서 같은 검증 패턴을 쓰면 사용자 학습 비용이 줄고 버그도 한곳에서 잡힙니다.
클라이언트와 서버가 같은 zod 스키마를 공유하는 것이 핵심입니다.
언제 쓰는가
- 상담·새가족·중보기도 등 폼 종류가 많을 때 검증 로직 통일
- 이메일·휴대전화·필수 동의처럼 표준 규칙이 반복될 때
- 서버 사이드 입력 검증을 클라이언트와 같은 코드로 유지하고 싶을 때
① 단일 스키마 (클라이언트·서버 공유)
lib/schemas/counsel.tsts
// lib/schemas/counsel.ts — 공유 스키마
import { z } from "zod";
const KO_PHONE = /^01[016789]-?\d{3,4}-?\d{4}$/;
export const CounselSchema = z.object({
type: z.enum(["personal", "youth", "couple", "other"], {
required_error: "상담 유형을 선택해 주세요.",
}),
name: z
.string()
.trim()
.min(2, "이름을 정확히 적어 주세요.")
.max(20, "이름이 너무 깁니다."),
phone: z
.string()
.trim()
.regex(KO_PHONE, "예: 010-1234-5678"),
email: z
.string()
.trim()
.email("이메일 형식을 확인해 주세요.")
.optional()
.or(z.literal("")),
memo: z
.string()
.trim()
.min(10, "최소 10자 이상 적어 주세요.")
.max(2000, "2,000자 이내로 적어 주세요."),
consent: z.literal(true, {
errorMap: () => ({ message: "개인정보 수집·이용에 동의가 필요합니다." }),
}),
});
export type CounselInput = z.infer<typeof CounselSchema>;② react-hook-form으로 연결
components/preview/CounselForm.tsxtsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { CounselSchema, type CounselInput } from "@/lib/schemas/counsel";
import { Field } from "@/components/preview/Field";
export function CounselForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CounselInput>({
resolver: zodResolver(CounselSchema),
mode: "onBlur",
});
const onSubmit = handleSubmit(async (values) => {
const res = await fetch("/api/counsel", {
method: "POST",
body: JSON.stringify(values),
headers: { "Content-Type": "application/json" },
});
if (!res.ok) throw new Error("submit failed");
});
if (isSubmitSuccessful) return <ThankYou />;
return (
<form onSubmit={onSubmit} noValidate className="space-y-5">
<Field label="이름" error={errors.name?.message} required>
<input {...register("name")} autoComplete="name" />
</Field>
<Field label="휴대전화" error={errors.phone?.message} required>
<input {...register("phone")} inputMode="tel" autoComplete="tel" />
</Field>
<Field label="이메일 (선택)" error={errors.email?.message}>
<input {...register("email")} type="email" autoComplete="email" />
</Field>
<Field label="상담 내용" error={errors.memo?.message} required>
<textarea {...register("memo")} rows={6} />
</Field>
<Field error={errors.consent?.message}>
<label className="flex items-center gap-2 text-[13px]">
<input type="checkbox" {...register("consent")} />
개인정보 수집·이용에 동의합니다.
</label>
</Field>
<button type="submit" disabled={isSubmitting} className="rounded-md bg-accent px-5 py-3 text-white">
{isSubmitting ? "전송 중..." : "신청하기"}
</button>
</form>
);
}③ Field 래퍼 — 라벨·에러·접근성 한 번에
components/preview/Field.tsxtsx
// components/preview/Field.tsx — 라벨 + 입력 + 에러 묶음
export function Field({
label,
required,
error,
children,
}: {
label?: string;
required?: boolean;
error?: string;
children: React.ReactNode;
}) {
const id = React.useId();
return (
<div>
{label && (
<label htmlFor={id} className="text-[13px] font-medium">
{label} {required && <span className="text-red-500">*</span>}
</label>
)}
{React.cloneElement(children as React.ReactElement, {
id,
"aria-invalid": !!error,
"aria-describedby": error ? `${id}-err` : undefined,
className:
"mt-1 w-full rounded-md border bg-white px-3 py-2 text-[14px] " +
(error ? "border-red-400" : "border-border"),
})}
{error && (
<p id={`${id}-err`} className="mt-1 text-[12px] text-red-600">
{error}
</p>
)}
</div>
);
}④ 서버에서도 같은 스키마로 재검증
app/api/counsel/route.tsts
// app/api/counsel/route.ts — 서버에서도 같은 스키마로 검증
import { NextResponse } from "next/server";
import { CounselSchema } from "@/lib/schemas/counsel";
import { createClient } from "@/lib/supabase/server";
export async function POST(req: Request) {
const body = await req.json();
const parsed = CounselSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const supabase = await createClient();
const { error } = await supabase.from("applications").insert({
form_kind: "counsel",
data: parsed.data,
});
if (error) {
return NextResponse.json({ error: "db" }, { status: 500 });
}
return NextResponse.json({ ok: true });
}