폼 — react-hook-form 과 zod
폼 — react-hook-form 과 zod
폼은 어떤 앱이든 가장 자주 만나는 부분입니다.
1. Controlled vs Uncontrolled
React 의 입력은 두 모델 중 하나입니다.
Controlled:
const [v, setV] = useState("")
<input value={v} onChange={(e) => setV(e.target.value)} />
React state 가 진실의 출처. 키 입력마다 리렌더가 발생합니다.
Uncontrolled:
const ref = useRef<HTMLInputElement>(null)
<input ref={ref} defaultValue="" />
DOM 이 진실의 출처. 키 입력마다 리렌더 없음. 큰 폼에서는 controlled 의 리렌더 비용이 누적됩니다. 이 자리를 메우는 라이브러리가 자라났습니다.
2. react-hook-form
react-hook-form 은 Bill Luo 가 2019 년에 만든 라이브러리입니다. 라이선스 MIT.
핵심 발상은 uncontrolled 를 기본으로 하고 ref 를 통해 폼 상태를 라이브러리가 관리합니다. 입력마다 리렌더가 일어나지 않으며 검증 시점·필드 단위로만 렌더됩니다.
import { useForm } from "react-hook-form"
function Login() {
const { register, handleSubmit, formState: { errors } } = useForm<{email: string; password: string}>()
const submit = (v: any) => console.log(v)
return (
<form onSubmit={handleSubmit(submit)}>
<input {...register("email", { required: true })} />
{errors.email && <p>이메일 필수</p>}
<input type="password" {...register("password", { minLength: 8 })} />
<button>로그인</button>
</form>
)
}
대안:
- Formik (2018) — 한때 가장 큰 라이브러리. controlled 모델. 현재 유지보수 둔화.
- TanStack Form (2024) — 헤드리스, 프레임워크 호환.
- conform (2023) — 표준 폼 동작 (progressive enhancement) 강조.
3. 스키마 검증 라이브러리
| 라이브러리 | 첫 릴리스 | 만든이 | 비고 |
|---|---|---|---|
| joi | 2012 | hapi 팀 | Node 백엔드 진영. |
| yup | 2015 | jquense | Formik 과 함께 자주 쓰임. |
| zod | 2020 | Colin McDonnell | TS-first. 가장 넓게 채택. |
| valibot | 2023 | Fabian Hiller | 트리 셰이킹 친화. 함수 단위 import. |
| ArkType | 2023 | David Blass | TS 표현식을 그대로 파서로 사용. |
| Effect Schema | 2023 | Effect | Effect 생태계. |
zod 의 핵심:
import { z } from "zod"
const Login = z.object({
email: z.string().email(),
password: z.string().min(8),
remember: z.boolean().default(false),
})
type Login = z.infer<typeof Login> // 정적 타입 자동 추출
const r = Login.safeParse(data)
if (!r.success) console.log(r.error.format())
else console.log(r.data)
같은 스키마에서 정적 타입과 런타임 검증이 동시에 나온다는 점이 자주 인용됩니다.
4. react-hook-form + zod
@hookform/resolvers 가 두 라이브러리를 잇습니다.
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
const Schema = z.object({
email: z.string().email("이메일 형식이 아닙니다"),
password: z.string().min(8, "8자 이상"),
})
function Login() {
const { register, handleSubmit, formState: { errors } } =
useForm<z.infer<typeof Schema>>({ resolver: zodResolver(Schema) })
return (
<form onSubmit={handleSubmit(v => console.log(v))}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button>로그인</button>
</form>
)
}
5. 경계 검증
타입은 컴파일 시점에만 삽니다. 외부에서 들어오는 값 (HTTP 응답 · 폼 제출 · 환경변수 · JSON.parse 결과) 은 런타임에 검증해야 합니다. 이 자리를 경계 (boundary) 라 부릅니다.
자주 보이는 경계 네 곳:
HTTP 응답:
const Resp = z.object({ id: z.string(), title: z.string() })
const r = await fetch("/api/post/1")
const data = Resp.parse(await r.json())
폼 제출 — react-hook-form + zodResolver 가 이 자리를 메웁니다. 단 클라이언트에서만 검증하면 우회될 수 있습니다. 서버에서도 같은 스키마로 다시 검증합니다.
환경변수:
const Env = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
NODE_ENV: z.enum(["development", "test", "production"]),
})
export const env = Env.parse(process.env)
JSON.parse · localStorage — 저장 시점과 로드 시점 사이 스키마가 바뀔 수 있습니다. 로드할 때 스키마 검증 후 실패 시 마이그레이션 또는 초기화.
6. Standard Schema
Standard Schema (2024) 는 zod · valibot · ArkType · Effect Schema 등이 합의한 공통 인터페이스입니다. 라이브러리 간 어댑터 없이 폼 도구가 어떤 스키마든 받을 수 있도록 합니다.
7. Server Action 과 결합
19 의 Server Actions 와 zod 를 묶는 작은 도구가 자라났습니다.
next-safe-action— Server Action 입력을 zod 로 검증.zsa— 비슷한 자리.
폼은 클라이언트 + 서버 양쪽에서 검증되어야 한다는 원칙이 이 도구들의 출발점입니다.
8. 큰 폼 + 비동기 검증
const SignUp = z.object({
email: z.string().email(),
password: z.string().min(8),
confirm: z.string(),
age: z.coerce.number().int().min(14, "14세 이상만 가입"),
}).refine((d) => d.password === d.confirm, {
message: "비밀번호가 일치하지 않습니다",
path: ["confirm"],
})
비동기 검증 (이메일 중복):
const SignUp = z.object({
email: z.string().email().refine(async (v) => {
const r = await fetch(`/api/check?email=${encodeURIComponent(v)}`)
return r.ok
}, "이미 사용 중인 이메일"),
})
9. Server Action 에서 같은 스키마 재사용
// shared/schemas.ts
export const SignUp = z.object({...})
// app/sign-up/actions.ts
"use server"
import { SignUp } from "@/shared/schemas"
export async function signUp(_: unknown, form: FormData) {
const r = SignUp.safeParse(Object.fromEntries(form))
if (!r.success) return { errors: r.error.flatten().fieldErrors }
await db.user.create({ data: r.data })
return { ok: true }
}
10. 자주 걸리는 자리
클라이언트만 검증 — 폼 검증을 클라이언트에서만 하면 fetch 로 직접 우회당합니다. 서버에서도 같은 스키마로 다시 검증합니다.
Controlled 의 리렌더 비용 — 큰 폼을 모두 controlled 로 만들면 키 입력마다 거의 모든 컴포넌트가 리렌더됩니다. react-hook-form 또는 분리된 useState 가 답입니다.
refine 의 비용 — 동기 refine 안에서 무거운 계산을 하면 매 키 입력마다 실행됩니다. debounce 또는 비동기 refine 으로 처리합니다.
선택적 필드와 빈 문자열 — HTML 폼은 비워둔 입력을 "" 로 보냅니다. zod 에서 optional() 만 쓰면 통과하지 않습니다. z.string().optional().or(z.literal("")) 또는 전처리 transform.
에러 메시지의 i18n — zod 의 기본 메시지는 영어입니다. setErrorMap 으로 한국어 사전을 갈아끼웁니다 (zod-i18n-map 같은 보조 라이브러리).
하고픈 말
react-hook-form + zod 조합은 폼 작성 비용을 크게 줄여줍니다. 같은 스키마가 클라이언트·서버 양쪽에서 검증과 타입 추출을 동시에 해 주기 때문입니다. 경계 검증은 외부 데이터를 코드에 들이는 첫 자리에 놓는 게 가장 단순한 답입니다.
Next
- bundlers
- tauri-mobile-admob
react-hook-form · zod 공식 · zod GitHub · @hookform/resolvers · valibot · ArkType · TanStack Form · Standard Schema 를 참고합니다.