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 });
}

설계 포인트

Need Help

도움이 필요하신가요?

주님의교회 PCL 디자인 시스템을 적용하시다가 막히는 부분이 있다면, 다음 경로로 직접 문의하실 수 있습니다.