Use Cases (업데이트: 2026. 6. 2.)

Claude Code로 폼 검증 구현하기: React Hook Form과 Zod

Claude Code로 React Hook Form, Zod, 서버 검증, API 오류 정규화, i18n, 접근성, 테스트를 구현합니다.

Claude Code로 폼 검증 구현하기: React Hook Form과 Zod

마크업보다 데이터 계약을 먼저 정한다

폼 검증은 단순히 “필수 항목을 입력하세요”를 보여 주는 작업이 아닙니다. SaaS 체험 신청, 문의, 결제 전 정보, 예약, 온보딩, 관리자 편집 화면처럼 매출과 운영에 직접 연결됩니다. 겉보기에는 잘 동작해도 서버 검증이 없거나, 두 번 클릭으로 중복 제출되거나, API 오류가 필드로 돌아오지 않으면 실제 운영에서 바로 문제가 됩니다.

Claude Code는 이런 작업에 잘 맞습니다. 검증은 필드, 타입, 제한, 메시지, 실패 처리, 테스트가 분명한 구조를 가지기 때문입니다. React Hook Form은 폼 상태와 제출을 다루고, Zod는 schema, 즉 데이터 계약을 정의합니다. resolver는 React Hook Form과 Zod를 연결하는 어댑터입니다. 서버 검증은 브라우저가 보낸 JSON을 믿지 않고 API에서도 다시 확인하는 것입니다. 오류 정규화는 Zod 오류, 업무 규칙 오류, JSON 파싱 실패를 UI가 처리하기 쉬운 하나의 형태로 맞추는 작업입니다. i18n은 국제화, 접근성은 키보드 사용자와 스크린 리더도 오류를 이해할 수 있게 만드는 것을 뜻합니다.

이 글은 연락처 문의 폼을 예로 React Hook Form, Zod, 서버 검증, API 오류 정규화, 접근성, 다국어 메시지, 테스트를 함께 구현합니다. 관련 내용은 React Hook Form 가이드Zod 검증 가이드를 같이 보면 좋습니다. 공식 자료는 Claude Code overview, React Hook Form useForm, React Hook Form Resolvers, Zod, Next.js Route Handlers, Testing Library를 확인하세요.

flowchart TD
  A["User input"] --> B["React Hook Form"]
  B --> C["zodResolver"]
  C --> D{"Client valid?"}
  D -->|No| E["Accessible field errors"]
  D -->|Yes| F["POST /api/contact"]
  F --> G["Server Zod validation"]
  G --> H["Normalize API errors"]
  H --> I["setError or root message"]
  G --> J["Persist or notify"]

사용 사례별로 검증 규칙을 나눈다

Claude Code에 “폼 검증 추가해줘”라고만 말하면 얕은 결과가 나오기 쉽습니다. 어떤 폼인지 먼저 정해야 합니다.

사용 사례중요한 검증자주 생기는 문제
SaaS 무료 체험회사 이메일, 인원수, 플랜, 약관 동의개인 이메일 허용 여부를 정하지 않음
문의 폼이름, 이메일, 문의 유형, 본문 길이, 스팸 URL 수클라이언트 검증만 하고 API body를 신뢰함
관리자 사용자 편집권한, 역할, 불변 ID, 허용 필드UI에서 숨긴 필드를 API가 받아 버림
예약 또는 결제 전 폼날짜, 수량, 전화번호, 주소, 재고두 번 제출되어 중복 예약이 생김

프롬프트는 이렇게 구체화합니다.

Implement a contact form.
Only change the files in this feature.
Use React Hook Form, Zod, and @hookform/resolvers/zod.
Validate on the client and again in the API using the same schema.
Normalize API failures as { ok: false, errors: [{ path, message }] }.
Include duplicate-submit prevention, aria-invalid, aria-describedby, role="alert",
i18n message keys, and Vitest/Testing Library tests.
Use copy-pasteable TypeScript and React, not pseudocode.

이 지시는 성공 경로보다 실패 경로를 강조합니다. 리뷰할 때도 타입, 서버 경계, 오류 표시, 재전송 방지, 접근성, 테스트를 확인해야 합니다.

복사해서 쓸 수 있는 Zod schema

아래 schema는 B2B 문의 폼을 기준으로 합니다. 오류 문구는 완성된 문장이 아니라 message key입니다. API는 key를 돌려주고 UI가 언어별 문구로 바꿉니다.

// src/features/contact/contactSchema.ts
import { z } from "zod";

export const contactSchema = z
  .object({
    name: z.string().trim().min(1, "validation.name.required").max(60, "validation.name.tooLong"),
    email: z.string().trim().min(1, "validation.email.required").email("validation.email.invalid"),
    plan: z.enum(["starter", "team", "enterprise"], { message: "validation.plan.invalid" }),
    seats: z
      .number({ message: "validation.seats.number" })
      .int("validation.seats.integer")
      .min(1, "validation.seats.min")
      .max(200, "validation.seats.max"),
    message: z.string().trim().min(20, "validation.message.tooShort").max(1000, "validation.message.tooLong"),
    locale: z.enum(["ja", "en"], { message: "validation.locale.invalid" }),
    agreeToTerms: z.boolean().refine((value) => value === true, "validation.terms.required"),
  })
  .strict();

export type ContactFormData = z.infer<typeof contactSchema>;

export const defaultContactValues: ContactFormData = {
  name: "",
  email: "",
  plan: "starter",
  seats: 1,
  message: "",
  locale: "en",
  agreeToTerms: false,
};

seats를 number로 둔 이유는 타입 어긋남을 잡기 위해서입니다. HTML 숫자 입력도 잘못 연결하면 문자열이 됩니다. React Hook Form에서 valueAsNumber를 쓰거나 API용으로 z.coerce.number()를 쓸 수 있지만, 어느 쪽이든 의도적으로 정해야 합니다.

서버 검증과 API 오류 정규화

클라이언트 검증은 사용자를 돕지만 시스템을 보호하지는 않습니다. API에서는 JSON을 unknown으로 받고 Zod가 통과한 뒤에만 업무 로직으로 넘깁니다.

// src/app/api/contact/route.ts
import { z } from "zod";
import { contactSchema, type ContactFormData } from "@/features/contact/contactSchema";

type FieldPath = keyof ContactFormData | "root";

export type ApiFieldError = {
  path: FieldPath;
  message: string;
};

export type ContactApiResponse =
  | { ok: true; id: string }
  | { ok: false; errors: ApiFieldError[] };

function normalizeZodError(error: z.ZodError): ApiFieldError[] {
  return error.issues.map((issue) => {
    const firstPath = issue.path[0];
    return {
      path: typeof firstPath === "string" ? (firstPath as FieldPath) : "root",
      message: issue.message,
    };
  });
}

function jsonResponse(body: ContactApiResponse, status: number): Response {
  return Response.json(body, { status });
}

async function isBlockedDomain(email: string): Promise<boolean> {
  return email.toLowerCase().endsWith("@example.invalid");
}

export async function POST(request: Request): Promise<Response> {
  let body: unknown;

  try {
    body = await request.json();
  } catch {
    return jsonResponse({ ok: false, errors: [{ path: "root", message: "validation.json.invalid" }] }, 400);
  }

  const parsed = contactSchema.safeParse(body);
  if (!parsed.success) {
    return jsonResponse({ ok: false, errors: normalizeZodError(parsed.error) }, 422);
  }

  if (await isBlockedDomain(parsed.data.email)) {
    return jsonResponse({ ok: false, errors: [{ path: "email", message: "validation.email.blocked" }] }, 409);
  }

  // Replace this with database insert, CRM sync, or email notification.
  const id = crypto.randomUUID();
  return jsonResponse({ ok: true, id }, 201);
}

이 구조에서는 Zod 오류, JSON 오류, 차단 도메인 같은 업무 오류가 모두 같은 배열로 돌아옵니다. 그래서 UI는 오류의 출처를 몰라도 됩니다.

React Hook Form에서 접근성까지 처리하기

컴포넌트는 isSubmitting으로 중복 제출을 막고, aria-invalidaria-describedby로 입력과 오류 문구를 연결합니다. role="alert"는 오류가 생겼을 때 보조 기술이 알아차리기 쉽게 합니다.

// src/features/contact/ContactForm.tsx
"use client";

import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { contactSchema, defaultContactValues, type ContactFormData } from "./contactSchema";
import type { ApiFieldError, ContactApiResponse } from "@/app/api/contact/route";

const messages = {
  en: {
    "validation.name.required": "Enter your name.",
    "validation.name.tooLong": "Name must be 60 characters or fewer.",
    "validation.email.required": "Enter your email address.",
    "validation.email.invalid": "Enter a valid email address.",
    "validation.email.blocked": "This email domain is not allowed.",
    "validation.plan.invalid": "Choose a plan.",
    "validation.seats.number": "Seats must be a number.",
    "validation.seats.integer": "Seats must be an integer.",
    "validation.seats.min": "Seats must be at least 1.",
    "validation.seats.max": "Seats must be 200 or fewer.",
    "validation.message.tooShort": "Message must be at least 20 characters.",
    "validation.message.tooLong": "Message must be 1000 characters or fewer.",
    "validation.locale.invalid": "Locale is invalid.",
    "validation.terms.required": "You must agree to the terms.",
    "validation.json.invalid": "The submitted body could not be read.",
    "form.submitError": "Submit failed. Please try again later.",
  },
} as const;

type Locale = keyof typeof messages;
const formFields = ["name", "email", "plan", "seats", "message", "locale", "agreeToTerms"] as const;
type FormField = (typeof formFields)[number];

function t(locale: Locale, key: string): string {
  const table = messages[locale] as Record<string, string>;
  return table[key] ?? key;
}

function isFormField(path: ApiFieldError["path"]): path is FormField {
  return formFields.includes(path as FormField);
}

export function ContactForm({ locale = "en" }: { locale?: Locale }) {
  const [serverMessage, setServerMessage] = useState<string | null>(null);
  const { register, handleSubmit, setError, reset, formState: { errors, isSubmitting } } =
    useForm<ContactFormData>({ resolver: zodResolver(contactSchema), defaultValues: { ...defaultContactValues, locale }, mode: "onBlur" });

  async function onValidSubmit(values: ContactFormData) {
    setServerMessage(null);
    const response = await fetch("/api/contact", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(values),
    });
    const result = (await response.json()) as ContactApiResponse;
    if (!response.ok || !result.ok) {
      const apiErrors = result.ok ? [] : result.errors;
      for (const error of apiErrors) {
        if (isFormField(error.path)) setError(error.path, { type: "server", message: t(locale, error.message) });
        else setServerMessage(t(locale, error.message));
      }
      if (apiErrors.length === 0) setServerMessage(t(locale, "form.submitError"));
      return;
    }
    reset({ ...defaultContactValues, locale });
  }

  return (
    <form onSubmit={handleSubmit(onValidSubmit)} noValidate>
      {serverMessage ? <p role="alert" aria-live="assertive">{serverMessage}</p> : null}
      <label htmlFor="contact-name">Name</label>
      <input id="contact-name" aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? "contact-name-error" : undefined} {...register("name")} />
      {errors.name?.message ? <p id="contact-name-error" role="alert">{t(locale, errors.name.message)}</p> : null}
      <label htmlFor="contact-email">Email address</label>
      <input id="contact-email" type="email" aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? "contact-email-error" : undefined} {...register("email")} />
      {errors.email?.message ? <p id="contact-email-error" role="alert">{t(locale, errors.email.message)}</p> : null}
      <label htmlFor="contact-plan">Plan</label>
      <select id="contact-plan" {...register("plan")}><option value="starter">Starter</option><option value="team">Team</option><option value="enterprise">Enterprise</option></select>
      <label htmlFor="contact-seats">Seats</label>
      <input id="contact-seats" type="number" min={1} max={200} {...register("seats", { valueAsNumber: true })} />
      <label htmlFor="contact-message">Message</label>
      <textarea id="contact-message" rows={6} {...register("message")} />
      <label><input type="checkbox" {...register("agreeToTerms")} />I agree to the terms</label>
      {errors.agreeToTerms?.message ? <p role="alert">{t(locale, errors.agreeToTerms.message)}</p> : null}
      <button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>{isSubmitting ? "Submitting..." : "Submit"}</button>
    </form>
  );
}

실제 서비스에서는 label도 번역 테이블에 넣는 편이 좋습니다. 여기서는 검증 구조가 보이도록 짧게 작성했습니다.

테스트와 실패 사례

// src/features/contact/ContactForm.test.tsx
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ContactForm } from "./ContactForm";
import { contactSchema } from "./contactSchema";

const validInput = {
  name: "Masa",
  email: "masa@example.com",
  plan: "team",
  seats: 3,
  message: "I want to improve validation in a Claude Code workflow.",
  locale: "en",
  agreeToTerms: true,
} as const;

it("accepts valid schema input", () => {
  expect(contactSchema.safeParse(validInput).success).toBe(true);
});

it("rejects string seats", () => {
  expect(contactSchema.safeParse({ ...validInput, seats: "3" }).success).toBe(false);
});

it("does not submit invalid form", async () => {
  const fetchMock = vi.spyOn(globalThis, "fetch");
  render(<ContactForm locale="en" />);
  await userEvent.click(screen.getByRole("button", { name: "Submit" }));
  expect(await screen.findByText("Enter your name.")).toBeInTheDocument();
  expect(fetchMock).not.toHaveBeenCalled();
});

자주 실패하는 부분은 여섯 가지입니다. 첫째, 클라이언트 검증만 두는 것. 둘째, 중복 제출을 막지 않는 것. 셋째, 숫자와 checkbox 타입이 어긋나는 것. 넷째, 서버의 업무 오류가 화면에 나오지 않는 것. 다섯째, 오류를 빨간 글자만으로 처리하고 접근성을 놓치는 것. 여섯째, Claude Code가 관련 없는 파일까지 크게 고치는 것입니다.

수익화와 실제 검증 결과

폼은 전환 경로입니다. 체험 신청 폼이 실패하면 광고비와 SEO 트래픽이 낭비됩니다. 문의 폼의 오류가 불친절하면 영업 기회가 줄어듭니다. ClaudeCodeLab의 Claude Code 교육과 컨설팅에서는 실제 코드베이스를 기준으로 schema 설계, 프롬프트, 테스트, 리뷰 체크리스트를 정리할 수 있습니다.

Masa가 테스트했을 때 첫 구현은 UI는 괜찮았지만 seats가 문자열로 API에 들어갔고, 차단 도메인 오류가 email 필드에 표시되지 않았습니다. valueAsNumber, API 오류 정규화, Testing Library 회귀 테스트를 추가하자 두 문제가 재현 가능하고 수정 가능한 상태가 되었습니다. message key 방식은 일본어와 영어 UI를 동시에 확인할 때 특히 편했습니다.

정리

Claude Code로 폼 검증을 만들 때는 React Hook Form, Zod, 서버 검증, API 오류 정규화, 접근성, i18n, 테스트를 하나의 작업으로 다루어야 합니다. 폼은 외부 입력이 시스템에 들어오는 경계입니다. 그 경계를 명확히 적을수록 Claude Code의 결과도 안정적입니다.

#Claude Code #validation #forms #Zod #React
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.