codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
노트›frontend

폼 — react-hook-form 과 zod

2026-04-28 게시· 2026-05-18 갱신·0회 조회

폼 — 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 를 참고합니다.

frontend 카테고리의 다른 글

카테고리 전체 보기 →
  • 도메인 위젯의 통일성 — 4개 도메인에 3개 위젯만 두지 마라
  • 관리자 UI — ResourceTable SSOT 패턴
  • 페이지 로딩 UX
  • 네이티브 통합 — OS 기능들
  • OCR · STT · TTS
  • SQLite — 로컬 앱의 단일 파일 DB