2. react-hook-form and zod for pydantic Users

RHF가 값을 들고, zod가 심판하고, resolver가 둘을 잇는다

  • Haram Lee
  • 2026-07-25
  • studies / Topics / React Forms

2.1 Four Players

폼 코드를 처음 열었을 때 “zod가 뭔데?“에서 막혔다. 결국 하나씩 파이썬 쪽 개념에 대응시켜 보면서 이해했다. 클라이언트 폼에는 네 가지가 등장한다.

프론트하는 일파이썬으로 치면
react-hook-form (RHF)폼의 상태 관리자. 지금 입력창에 무엇이 적혀 있는지, 무엇이 수정됐는지, 값이 유효한지를 들고 있다폼 전용 in-memory dict와 dirty 플래그 관리자
zod런타임 값 검증 라이브러리pydantic 그 자체
zodResolverRHF와 zod를 잇는 어댑터. 입력이 바뀔 때마다 이 스키마로 검사하게 한다FastAPI가 요청마다 pydantic 검증을 실행해 주는 연결 부분
필드 정의서버가 내려주는 폼 재료 목록 (key, 타입, 필수 여부, 선택지, 그룹)서버가 주는 스키마 정의 JSON

한 줄로 줄이면 이렇다. RHF가 값을 들고, zod가 값을 심판하고, resolver가 둘을 잇고, 필드 정의가 무엇을 그릴지 알려준다. 네 번째인 필드 정의는 폼을 서버 정의로 동적으로 만드는 경우에만 등장한다. 회원가입 폼처럼 칸이 고정이면 앞의 셋만 있으면 된다.

2.2 The Same Model in pydantic and zod

회원가입 폼으로 비교해 본다. 이메일, 비밀번호, 비밀번호 확인 세 칸이 있고, 두 비밀번호가 같아야 한다. pydantic이라면 이렇게 쓴다.

python
from pydantic import BaseModel, EmailStr, Field, model_validator


class SignUp(BaseModel):
    email: EmailStr
    password: str = Field(min_length=8)
    password_confirm: str

    @model_validator(mode="after")
    def passwords_match(self) -> "SignUp":
        if self.password != self.password_confirm:
            raise ValueError("비밀번호가 일치하지 않습니다")
        return self

zod로 옮기면 이렇다.

ts
import { z } from "zod";

const signUpSchema = z
  .object({
    email: z.string().email(),
    password: z.string().min(8),
    password_confirm: z.string(),
  })
  .superRefine((data, ctx) => {
    if (data.password !== data.password_confirm) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ["password_confirm"],
        message: "비밀번호가 일치하지 않습니다",
      });
    }
  });

type SignUp = z.infer<typeof signUpSchema>;

대응은 거의 일대일이다. z.object가 BaseModel, 필드 제약이 Field(...), superRefine이 @model_validator(mode="after"), ctx.addIssue가 raise ValueError다. z.infer는 스키마에서 TypeScript 타입을 뽑는다. pydantic 모델이 곧 타입 힌트인 것과 같은 효과다.

차이가 하나 있다. addIssue에는 path가 있다. pydantic의 model validator 에러는 모델 전체에 붙지만, 폼에서는 에러를 어느 입력창 밑에 띄울지가 중요하다. 그래서 크로스 필드 규칙이라도 에러를 특정 필드에 붙인다.

2.3 Five Steps to a Form

서버가 필드 정의를 내려주고 클라이언트가 그걸로 폼을 만드는 경우를 보자. 웹훅 연동 설정처럼 연동 종류마다 칸이 다른 폼을 생각하면 된다. 폼 하나가 만들어지기까지 다섯 단계를 거친다.

flowchart TD
    A["① 재료 받기<br/>GET /integrations/webhook/schema<br/>→ fields: FieldMeta[]"]
    B["② 심판 조립<br/>fields → z.object 동적 생성<br/>+ superRefine으로 크로스 필드 규칙"]
    C["③ 폼 개설<br/>useForm({ resolver: zodResolver(schema) })"]
    D["④ 입력 ↔ 검증 루프<br/>타이핑 → RHF 상태 갱신 → zod 실행<br/>→ 에러 인라인 표시, isValid로 버튼 게이트"]
    E["⑤ 제출 변환<br/>폼 값(key 기준) → API payload<br/>→ POST 저장"]
    A --> B --> C --> D --> E

파이썬으로 다시 쓰면 이렇다. 서버가 스키마 정의 JSON을 준다. create_model()로 pydantic 모델을 동적으로 만들고 @model_validator를 붙인다. 그 모델을 요청 핸들러에 연결한다. 입력이 들어올 때마다 model_validate를 실행해 에러를 돌려준다. 통과한 값을 저장 형식으로 바꿔 저장한다.

코드로는 이렇게 생겼다.

tsx
// ② 심판 조립: create_model + model_validator
const schema = buildSchemaFromFields(fields).superRefine((data, ctx) => {
  if (data.retry_count > 0 && !data.retry_interval_sec) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ["retry_interval_sec"],
      message: "재시도 간격을 입력하세요",
    });
  }
});

// ③ 폼 개설
const form = useForm({ resolver: zodResolver(schema), mode: "onChange" });

// ⑤ 제출: handleSubmit은 검증을 통과했을 때만 콜백을 실행한다
const onSubmit = form.handleSubmit((values) => {
  saveIntegration(toPayload(values, fields));
});

handleSubmit이 FastAPI 핸들러와 닮은 지점이다. FastAPI에서 핸들러 본문은 pydantic 검증을 통과한 값만 받는다. handleSubmit에 넘긴 콜백도 zod를 통과한 값만 받는다.

2.4 The Life of One Value

URL 칸에 값을 타이핑하면 일어나는 일을 따라가 본다.

text
[사용자] URL 칸에 "https://hooks.example.com/a" 타이핑
   ↓
[RHF] 내부 상태 갱신: { url: "https://hooks.example.com/a", ... }
   ↓  (mode: 'onChange'라 변경마다)
[zodResolver] 스키마 전체 실행
   ├─ 기본 규칙: z.string().url()     ← 필드 정의의 type에서 나옴
   └─ superRefine: 크로스 필드 규칙   ← 서버 규칙을 손으로 옮긴 것
   ↓
[통과] errors 비움 → isValid 재계산 → 저장 버튼 활성
[실패] ctx.addIssue({ path: ["url"], message }) → path가 가리키는 입력창 밑에 에러 표시

mode는 언제 검증을 돌릴지 정한다. onChange는 값이 바뀔 때마다, onBlur는 칸을 떠날 때, onSubmit은 제출할 때다. onChange는 반응이 가장 빠르지만 검증도 가장 자주 실행된다. 스키마가 가벼우면 상관없지만, 무거우면 3편에서 다룰 문제가 생긴다.

2.5 Flat Keys in the Form, Nested Keys on the Wire

처음에 가장 헷갈린 것은 key가 두 벌이라는 점이었다. 폼 안에서는 평평한 key로 살다가, 저장 직전에 API가 원하는 중첩 경로로 바뀐다.

text
폼 값                      API payload
retry_count: 3        →    delivery: { retry: { count: 3, interval_sec: 10 } }
retry_interval_sec: 10

폼은 평평해야 다루기 쉽고, payload는 서버 모델 모양이어야 한다. 그래서 폼과 API의 경계에서 한 번 변환한다. 필드 정의에 key와 server_key(예: delivery.retry.count)를 같이 두고, 제출할 때 dot-path를 따라 중첩 객체를 만든다. 수정 화면을 열 때는 반대로 서버 값을 평평하게 편다.

ts
function toPayload(values: Record<string, unknown>, fields: FieldMeta[]) {
  const payload = {};
  for (const field of fields) {
    if (field.dependsOn && isEmpty(values[field.dependsOn])) continue;
    setByPath(payload, field.server_key, values[field.key]);
  }
  return payload;
}

dependsOn이 있는 필드는 의존하는 값이 비어 있으면 payload에서 뺀다. 화면에는 늘 보이지만 저장할 때 빠지므로, 화면만 보고는 왜 저장값에 없는지 알기 어렵다. 이런 규칙은 변환 함수 한곳에 모아 둬야 나중에 찾을 수 있다.

2.6 Why Validate Twice

같은 규칙을 클라이언트 zod와 서버 pydantic에서 두 번 검사한다. 역할이 다르다. zod 검증은 입력창 밑에 바로 에러를 보여 주기 위한 UX 장치이고, 서버 검증은 최종 심판이다. FastAPI 앞단에서 프론트가 미리 걸러 주는 관계다.

그러면 서버의 pydantic 모델에서 JSON Schema를 뽑아 클라이언트가 그대로 쓰면 되지 않을까 싶었다. 필드 수준 제약은 옮길 수 있다. 하지만 @model_validator 같은 크로스 필드 규칙은 JSON Schema로 표현할 수 없다. 그래서 그런 규칙은 superRefine으로 손으로 옮긴다. 손으로 옮긴 복제본이므로 서버와 어긋날 수 있고, 최종 기준은 서버 검증이다. 클라이언트 검증을 통과했는데 서버가 422를 돌려줄 수 있다는 전제로 에러 표시를 짜야 한다.

2.7 When superRefine Runs

superRefine 에러가 안 뜬다고 헤맨 적이 있다. 다른 칸에 기본 에러가 남아 있을 때였다.

zod는 객체 자체가 기본 스키마에서 깨지면 그 위에 붙은 refine을 실행하지 않는다. 필수 값이 빠졌거나 타입이 틀린 경우다. refine 안의 코드는 data가 스키마 모양을 갖췄다고 가정하고 쓰기 때문이다. pydantic에서 field validation이 실패하면 model_validator(mode="after")가 실행되지 않는 것과 같다. 어떤 실패에서 refine을 건너뛰는지는 zod 버전마다 조금 다르므로, 의존하기 전에 쓰는 버전에서 확인하는 편이 좋다.

실제로는 문제가 되지 않았다. 제출 버튼을 isValid로 막고 있으니, 기본 에러를 고치면 그때 refine 에러가 나타난다. 사용자는 에러를 한 단계씩 보게 될 뿐이다.

2.8 Reading Values: useWatch vs getValues

폼 값을 읽는 방법은 둘이다.

tsx
// 구독: 값이 바뀌면 이 컴포넌트가 다시 렌더링된다
const retryCount = useWatch({ control: form.control, name: "retry_count" });

// 스냅샷: 호출한 순간의 값을 한 번 읽는다
const handleTest = () => sendTestEvent(form.getValues("url"));

useWatch는 화면에 쓰는 값, 예를 들어 재시도 횟수가 0보다 클 때만 간격 칸을 보여 주는 조건에 쓴다. getValues는 버튼을 누른 순간의 값만 있으면 되는 곳에 쓴다. 파이썬으로 치면 reactive property와 평범한 dict 읽기의 차이다. 렌더링에 쓰지도 않는 값을 useWatch로 구독하면 바뀔 때마다 리렌더만 늘어난다.

2.9 Initial Values from the Server

수정 화면은 서버에서 받은 기존 값으로 폼을 채워야 한다. 처음에는 effect로 채웠다.

tsx
const { data } = useQuery({ queryKey: ["integration", id], queryFn: fetchIntegration });
const form = useForm({ resolver: zodResolver(schema) });

useEffect(() => {
  if (data) form.reset(toFormValues(data));
}, [data]);

이건 1편에서 본 “props가 바뀔 때 effect로 state를 맞추는” 모양이다. RHF의 useForm에는 values 옵션이 있다. 바깥에서 온 값을 넘기면 그 값이 바뀔 때 폼이 따라 갱신된다. 그러면 초기화용 effect가 필요 없어진다.

tsx
const form = useForm({
  resolver: zodResolver(schema),
  values: data ? toFormValues(data) : undefined,
});

defaultValues는 처음 한 번만 쓰이는 값이고, 서버 응답처럼 늦게 도착하는 값에는 values가 맞는다.

Discussion