Claude Code로 React Hook Form을 안전하게 구현하는 방법
useForm, Zod 검증, 오류 표시, 제출 상태, 테스트까지 Claude Code로 안정적인 React 폼을 만드는 절차.
Claude Code에 맡기기 전에 폼 계약부터 정한다
React Hook Form은 React에서 폼을 만들 때 자주 쓰는 가벼운 라이브러리입니다. 모든 입력을 useState로 직접 들고 있기보다 브라우저의 폼 동작을 활용하면서 register, handleSubmit, formState로 입력값, 제출, 오류를 다룹니다. 처음 배우는 입장에서는 값이 어디에 모이는지, 검증이 언제 실행되는지, 제출 중 버튼을 어떻게 막는지 이해하기 쉽습니다.
Claude Code를 함께 쓰면 컴포넌트, Zod 스키마, API route, 테스트까지 한 번에 초안을 만들 수 있습니다. 하지만 폼은 상담 신청, 제품 체험, 결제 전 설문, 뉴스레터 구독처럼 매출과 직접 연결되는 경우가 많습니다. 단순히 “폼 만들어줘”라고 요청하면 보기에는 괜찮지만 접근 가능한 오류 표시, 서버 검증, 제출 중 상태가 빠진 구현이 나올 수 있습니다.
이 글에서는 문의 폼을 예로 useForm, zodResolver, 필드 오류, 제출 상태, 서버 재검증, 테스트 가능성, 안전한 Claude Code 프롬프트까지 정리합니다. React 구조 전반은 Claude Code React 개발, Zod 설계는 Claude Code Zod 검증과 함께 보면 좋습니다.
구조: schema를 중심에 둔다
React Hook Form은 폼 흐름을 담당합니다. Zod는 어떤 입력이 올바른지 정의합니다. @hookform/resolvers/zod의 zodResolver는 두 도구를 연결해 React Hook Form 검증 시점에 Zod 스키마를 실행합니다.
flowchart TD
A["사용자 입력"] --> B["React Hook Form register"]
B --> C["zodResolver로 schema 검증"]
C --> D{"입력이 유효한가"}
D -->|아니오| E["필드 오류 표시"]
D -->|예| F["handleSubmit으로 제출"]
F --> G["API에서도 같은 schema 검증"]
G --> H["저장, 알림, CRM 연동"]
쉽게 말하면 useForm은 폼 관리자, Zod schema는 규칙표, resolver는 둘을 연결하는 어댑터입니다. Claude Code에 폼 수정을 맡길 때 이 세 부분을 분리해서 말하면 실수로 전체를 갈아엎을 위험이 줄어듭니다. 나중에 필드나 선택지를 추가할 때도 schema, UI, API, 테스트를 함께 고치라고 요청할 수 있습니다.
공식 자료는 React Hook Form의 useForm 문서, React Hook Form Resolvers, Zod API 문서, React의 <input> 문서, Claude Code의 overview와 commands를 기준으로 확인하세요.
복사해서 쓸 수 있는 Zod schema
먼저 검증 규칙을 별도 파일로 분리합니다. 아래 예시는 이름, 이메일, 문의 종류, 본문, 연락 동의를 가진 문의 폼입니다. z.infer는 스키마에서 TypeScript 타입을 만들어 주므로 타입과 런타임 검증을 따로 관리하지 않아도 됩니다.
// src/features/inquiry/inquirySchema.ts
import { z } from "zod";
export const inquirySchema = z.object({
name: z
.string()
.trim()
.min(1, "이름을 입력하세요")
.max(80, "이름은 80자 이내로 입력하세요"),
email: z
.string()
.trim()
.email("올바른 이메일 주소를 입력하세요"),
category: z.enum(["consulting", "support", "billing"], {
error: "문의 종류를 선택하세요",
}),
message: z
.string()
.trim()
.min(10, "본문은 10자 이상 입력하세요")
.max(1000, "본문은 1000자 이내로 입력하세요"),
agreeToContact: z.boolean().refine((value) => value, {
message: "연락에 대한 동의가 필요합니다",
}),
});
export type InquiryFormValues = z.infer<typeof inquirySchema>;
category를 enum으로 둔 이유는 제출값을 고정하기 위해서입니다. 실제 프로젝트에서는 이 값으로 영업, 지원, 청구 담당자에게 라우팅하거나 CRM 필드를 채웁니다. Claude Code 프롬프트에는 “화면 라벨은 한국어, 제출값은 영어 고정값”처럼 UI 문구와 데이터 값을 따로 적는 것이 안전합니다.
useForm으로 입력, 오류, 제출 상태 처리하기
다음은 폼 컴포넌트입니다. mode: "onBlur"는 사용자가 입력 필드를 떠났을 때 검증한다는 뜻입니다. 일반 사용자용 문의 폼에서는 타이핑할 때마다 빨간 오류를 보여주는 것보다 덜 부담스럽습니다. 최종 제출 시에는 handleSubmit이 다시 검증합니다.
// src/features/inquiry/InquiryForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { inquirySchema, type InquiryFormValues } from "./inquirySchema";
async function sendInquiry(values: InquiryFormValues) {
const response = await fetch("/api/inquiry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!response.ok) {
throw new Error("Failed to send inquiry");
}
}
export function InquiryForm() {
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<InquiryFormValues>({
resolver: zodResolver(inquirySchema),
mode: "onBlur",
defaultValues: {
name: "",
email: "",
message: "",
agreeToContact: false,
},
});
const onSubmit = async (values: InquiryFormValues) => {
try {
await sendInquiry(values);
reset();
} catch {
setError("root", {
type: "server",
message: "전송에 실패했습니다. 잠시 후 다시 시도하세요.",
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="name">이름</label>
<input
id="name"
autoComplete="name"
aria-invalid={errors.name ? "true" : "false"}
aria-describedby={errors.name ? "name-error" : undefined}
{...register("name")}
/>
{errors.name && (
<p id="name-error" role="alert">
{errors.name.message}
</p>
)}
</div>
<div>
<label htmlFor="email">이메일</label>
<input
id="email"
type="email"
autoComplete="email"
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}
</div>
<div>
<label htmlFor="category">문의 내용</label>
<select
id="category"
aria-invalid={errors.category ? "true" : "false"}
aria-describedby={errors.category ? "category-error" : undefined}
{...register("category")}
>
<option value="">선택하세요</option>
<option value="consulting">도입 상담</option>
<option value="support">기술 지원</option>
<option value="billing">청구 또는 계약</option>
</select>
{errors.category && (
<p id="category-error" role="alert">
{errors.category.message}
</p>
)}
</div>
<div>
<label htmlFor="message">본문</label>
<textarea
id="message"
rows={6}
aria-invalid={errors.message ? "true" : "false"}
aria-describedby={errors.message ? "message-error" : undefined}
{...register("message")}
/>
{errors.message && (
<p id="message-error" role="alert">
{errors.message.message}
</p>
)}
</div>
<label>
<input type="checkbox" {...register("agreeToContact")} />
답변을 위해 입력 내용을 확인하고 연락하는 것에 동의합니다
</label>
{errors.agreeToContact && (
<p role="alert">{errors.agreeToContact.message}</p>
)}
{errors.root && <p role="alert">{errors.root.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "전송 중..." : "문의 보내기"}
</button>
</form>
);
}
중요한 점은 오류 문구를 필드 가까이에 두고 aria-describedby로 입력과 연결하는 것입니다. 이는 화면만 보는 사용자뿐 아니라 스크린 리더 사용자에게도 필요합니다. 접근성 전반은 Claude Code 접근성 구현 글도 참고할 수 있습니다.
서버에서도 같은 schema로 다시 검증하기
프론트 검증은 사용자 경험을 위한 것입니다. 보안 경계는 아닙니다. 사용자는 브라우저 폼을 거치지 않고 API를 직접 호출할 수 있습니다. 그래서 API에서도 같은 스키마를 사용해 payload를 검증해야 합니다.
// app/api/inquiry/route.ts
import { NextResponse } from "next/server";
import { inquirySchema } from "@/features/inquiry/inquirySchema";
export async function POST(request: Request) {
const payload = await request.json().catch(() => null);
const parsed = inquirySchema.safeParse(payload);
if (!parsed.success) {
return NextResponse.json(
{
error: "Invalid inquiry",
fields: parsed.error.flatten().fieldErrors,
},
{ status: 400 },
);
}
// TODO: DB 저장, 이메일 발송, CRM 연동을 여기에 추가한다.
return NextResponse.json({ ok: true });
}
Claude Code에 API를 맡길 때는 “inquirySchema를 재사용하고, 실패 시 400과 field errors를 반환하고, 실제 이메일과 CRM은 TODO로 남겨라”라고 쓰면 됩니다. 비밀키, 재시도, 중복 제출 처리는 별도 작업으로 분리하는 편이 리뷰하기 좋습니다.
테스트 가능한 폼으로 만들기
폼은 눈으로만 확인하면 쉽게 깨집니다. 최소한 빈 제출, 정상 제출, 서버 실패, 제출 버튼 비활성화를 테스트하세요. 아래 예시는 Vitest와 React Testing Library로 오류 표시와 fetch 호출을 검증합니다.
// src/features/inquiry/InquiryForm.test.tsx
import { afterEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { InquiryForm } from "./InquiryForm";
afterEach(() => {
vi.unstubAllGlobals();
});
test("빈 값으로 제출하면 검증 오류를 표시한다", async () => {
render(<InquiryForm />);
await userEvent.click(screen.getByRole("button", { name: "문의 보내기" }));
expect(await screen.findAllByRole("alert")).toHaveLength(5);
});
test("올바른 입력이면 API로 제출한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
render(<InquiryForm />);
await userEvent.type(screen.getByLabelText("이름"), "Masa");
await userEvent.type(screen.getByLabelText("이메일"), "masa@example.com");
await userEvent.selectOptions(screen.getByLabelText("문의 내용"), "consulting");
await userEvent.type(
screen.getByLabelText("본문"),
"React Hook Form을 안전하게 도입하고 싶습니다.",
);
await userEvent.click(
screen.getByLabelText("답변을 위해 입력 내용을 확인하고 연락하는 것에 동의합니다"),
);
await userEvent.click(screen.getByRole("button", { name: "문의 보내기" }));
expect(fetchMock).toHaveBeenCalledWith(
"/api/inquiry",
expect.objectContaining({ method: "POST" }),
);
});
Claude Code에는 “먼저 실패하는 테스트를 작성한 뒤 구현하라”고 지시할 수 있습니다. 실제 브라우저 흐름은 Claude Code Playwright 테스트와 연결하고, 완료 이벤트 측정은 Claude Code 분석 구현처럼 서버 성공 이후에 붙이는 편이 정확합니다.
Claude Code 프롬프트 템플릿
좋은 프롬프트는 범위, 제약, 검증 명령, 하지 말아야 할 일을 함께 담습니다.
React Hook Form과 Zod로 문의 폼을 구현해 주세요.
범위:
- src/features/inquiry 와 app/api/inquiry 만 수정
- useForm, zodResolver, schema에서 파생한 TypeScript 타입 사용
- 필드: name, email, category, message, agreeToContact
- field error는 role="alert"와 aria-describedby로 표시
- isSubmitting이 true일 때 submit 버튼 비활성화
- API route에서도 같은 Zod schema로 safeParse
- Vitest + Testing Library 테스트 추가
확인:
- npm test -- InquiryForm
- npm run typecheck
하지 말 것:
- 새 UI 라이브러리 추가 금지
- 기존 category 값 변경 금지
- 실제 이메일, CRM, 비밀키 처리는 이번 작업에서 제외
수정 요청도 구체적으로 써야 합니다. “분류 하나 추가”가 아니라 “라벨은 교육 상담, 제출값은 training, schema enum, select, API 검증, 테스트, 분석 매핑을 함께 수정”처럼 계약을 명시하세요.
사용 사례와 설계 차이
| 사용 사례 | 적합한 구조 | 주의점 |
|---|---|---|
| 문의 폼 | Zod + React Hook Form + API 재검증 | 버튼 클릭이 아니라 성공한 문의를 측정 |
| 프로필 편집 | 기존 데이터를 defaultValues에 넣기 | 저장 후 reset(savedValues)로 dirty 상태 정리 |
| 구매 전 설문 | select, radio, checkbox 조합 | 제출값을 상품 또는 CRM ID와 맞추기 |
| 관리자 검색 폼 | 가벼운 검증과 URL query 동기화 | 키 입력마다 API를 호출하지 않기 |
공통 원칙은 화면 라벨과 제출값을 분리하는 것입니다. 라벨은 번역되거나 바뀔 수 있지만 제출값은 안정적이어야 합니다. 보고서, 자동화, 백엔드 코드가 그 값을 기준으로 움직이기 때문입니다.
흔한 실수
첫째, 브라우저에서만 검증하는 것입니다. API route에서도 같은 schema를 import하고 데이터를 처리하기 전에 safeParse를 실행하세요.
둘째, isSubmitting이 너무 빨리 false로 돌아오는 것입니다. onSubmit 안에서 비동기 작업을 await하지 않으면 제출 중 상태를 유지할 수 없습니다.
셋째, 오류 메시지를 필드와 멀리 떨어뜨리는 것입니다. 상단에 “오류가 있습니다”만 보여주면 사용자가 무엇을 고쳐야 하는지 알기 어렵습니다.
넷째, Claude Code가 새 디자인 시스템을 만들게 두는 것입니다. 이미 TextField, Select, Button, toast 컴포넌트가 있다면 반드시 재사용하라고 적으세요.
다섯째, 제출 후 경로를 잊는 것입니다. 성공 메시지, 감사 페이지, 이메일 알림, analytics 이벤트, CRM 동기화를 명확한 작업으로 분리해야 합니다.
수익 동선으로서의 폼
폼 품질은 보기 좋은지보다 어떤 퍼널을 지탱하는지로 판단해야 합니다. 무료 PDF 신청, 상품 리드, 유료 템플릿 구매, 도입 상담 중 무엇이 목표인지 먼저 정하세요. 그다음 Claude Code에 필드 축소, 오류 문구 개선, 테스트 추가를 맡기는 순서가 안정적입니다.
혼자 학습한다면 제품 페이지의 Claude Code 자료를 확인하고, 팀 적용을 고민한다면 교육 및 상담에서 운영 설계를 함께 잡을 수 있습니다. 폼은 작은 컴포넌트지만 콘텐츠와 매출 사이의 관문입니다.
실제로 시험해 본 결과
Masa가 작은 문의 흐름에 이 구조를 적용했을 때 가장 효과가 컸던 것은 schema를 한 파일로 모은 일이었습니다. UI에는 선택지를 추가했지만 API 허용값을 잊는 실수를 막을 수 있었기 때문입니다. 두 번째로 도움이 된 것은 빈 제출과 정상 제출 테스트였습니다. Claude Code에 후속 수정을 맡긴 뒤에도 오류 표시 누락과 fetch 호출 문제를 빠르게 발견했습니다. 실무에서는 폼을 단순 UI가 아니라 입력 계약으로 다루는 편이 유지보수에 강합니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.