Zod + react-hook-form의 글루 코드 지옥, Schema-First로 탈출하기

Zod + react-hook-form의 글루 코드 지옥, Schema-First로 탈출하기

zodResolver 1줄이 숨기고 있는 ~25줄의 포맷 변환·타입 단언·에러 우선순위 버그를, 스키마 하나로 프론트-백엔드 에러 루프를 닫는 아키텍처로 청산하는 법

Schema-First react-hook-form Zod 글루 코드 Railway-Oriented 폼 검증 타입 안전성 에러 UX
광고

핵심 이슈: "글루 코드"라는 보이지 않는 기술 부채

솔직히 말하면, 저도 zodResolver(schema) 한 줄이면 끝이라고 생각했던 시절이 있었습니다. Zod으로 스키마 정의하고, useForm에 resolver 꽂고, JSX 뿌리면 되니까요. 그런데 실제로 프로덕션 폼을 만지다 보면 — 비동기 유저네임 중복 체크를 붙이는 순간, 서버에서 내려온 422 에러를 필드에 매핑하는 순간 — 갑자기 setError, clearErrors, as keyof FormType 캐스팅이 우르르 쏟아집니다. dev.to에 올라온 "The Glue Code Tax" 시리즈가 이걸 한 줄 한 줄 세어봤는데, 포맷 변환 코드만 약 25줄, 타입 단언(as 캐스트) 3~4개, 그리고 이 코드를 커버하는 테스트는 보통 0개입니다.

사용자 입장에서 보면 문제는 더 미묘합니다. react-hook-form에서 에러 우선순위는 "마지막에 호출한 setError가 이긴다"는 암묵적 규칙입니다. 스키마 에러, 비동기 검증 에러, 서버 에러 — 세 소스가 동시에 존재할 때 어떤 메시지가 보일지는 호출 순서에 대한 개발자의 기억력에 의존합니다. 서버가 "이미 등록된 이메일"이라고 알려줬는데, 유저가 입력값을 고치지도 않았는데 다음 validation run에서 스키마 에러로 덮어씌워지는 버그 — Figma에서는 절대 안 보이지만, 실제 인터랙션에서는 사용자를 미치게 만드는 그 1px 같은 에러 UX 버그입니다.

맥락 해석: Schema-First + Railway-Oriented라는 해법의 구조

"Schema-First React Forms" 시리즈(dev.to, sakobume)가 제안하는 @railway-ts/use-form의 핵심은 간단합니다. 스키마 하나가 프론트엔드 폼과 백엔드 파이프라인을 동시에 드라이브한다는 것. resolver 어댑터가 없습니다. zodResolver 같은 브릿지 패키지 — 그 ~8.5 kB짜리 — 가 사라집니다. 스키마를 useForm에 직접 넣고, InferSchemaType으로 타입이 전파되니까 form.getFieldProps("usernam")처럼 오타를 치면 즉시 TypeScript 에러가 뜹니다. as keyof 캐스팅이 0개라는 건, 라이브러리 업그레이드 때 런타임에서 터질 타입 구멍도 0개라는 뜻입니다.

에러 우선순위는 결정론적(deterministic) 3-레이어 시스템으로 해결됩니다. 스키마 검증이 가장 낮은 우선순위(1), 비동기 필드 검증이 중간(2), 서버 에러가 최고(3). 서버가 "이미 등록된 이메일"이라고 했으면, 스키마 검증을 통과하더라도 서버 에러가 계속 표시됩니다. 유저가 해당 필드를 수정하면 그제야 서버 에러가 클리어되고 스키마 검증으로 돌아갑니다. 이걸 컴포넌트 코드에서 관리할 필요가 없습니다. form.errors.email만 읽으면 됩니다. 사용자 입장에서는 항상 "지금 가장 중요한 에러"를 보게 되는 거죠.

비동기 검증 쪽도 눈여겨볼 만합니다. fieldValidators로 선언적으로 등록하면, 훅이 알아서 로딩 상태(form.validatingFields.username), stale response 폐기(마지막으로 발행된 요청이 이김, 마지막으로 도착한 응답이 아님), 그리고 스키마 검증 실패 시 API 호출 자체를 게이트하는 로직을 처리합니다. 기존에는 이게 전부 개발자가 직접 짜야 하는 race condition 디버깅 영역이었습니다. 여기에 로딩 스켈레톤이나 "확인 중..." 인디케이터를 넣으려면 form.validatingFields만 참조하면 되니까, UX 인터랙션 디자인과의 갭도 줄어듭니다.

진짜 킬러 피처는 풀스택 루프입니다. 같은 스키마가 백엔드 validate() 함수에 들어가고, 실패 시 formatErrors()Record<string, string> 형태를 반환합니다. 프론트엔드의 form.setServerErrors(await res.json())가 기대하는 포맷과 정확히 동일합니다. 필드명 매핑 없음. 포맷 변환 없음. Object.entries(...).forEach(...) 루프 없음. 백엔드가 뱉는 에러 형태와 프론트엔드가 먹는 에러 형태가 스키마 공유로 인해 구조적으로 일치합니다.

시사점: 그래서 당장 갈아탈 건가요?

냉정하게 봐야 할 부분이 있습니다. @railway-ts는 아직 Zod이나 react-hook-form 대비 생태계와 커뮤니티 규모가 작습니다. 팀에 도입하려면 chain, object, required 같은 새 스키마 DSL을 학습해야 하고, 기존 Zod 스키마를 마이그레이션하는 비용도 존재합니다. 다만 이 라이브러리가 Standard Schema v1을 통해 Zod·Valibot 스키마도 직접 받는다고 명시한 점은 중요합니다. 기존 Zod 스키마를 버리지 않고도 resolver 어댑터를 제거하고 에러 우선순위 시스템을 얻을 수 있다면, 마이그레이션 경로가 훨씬 부드러워집니다.

더 본질적인 시사점은 "검증 로직이 세 곳에 흩어져 있을 때, 그 폼이 왜 invalid인지에 대한 답이 세 개"라는 문제 인식 자체입니다. 이건 특정 라이브러리의 문제가 아니라, 폼 아키텍처 설계의 문제입니다. Zod + react-hook-form을 계속 쓰더라도 최소한 비동기 검증의 race condition 처리, 서버 에러의 우선순위 관리, 그리고 포맷 변환 코드의 테스트 커버리지는 의식적으로 챙겨야 합니다.

전망: 폼은 "상태 관리"가 아니라 "에러 아키텍처"

결국 이 논의가 가리키는 방향은 명확합니다. React 폼의 복잡성은 상태 관리가 아니라 에러 소스의 다층 구조에서 옵니다. Schema-First 접근법은 이 복잡성을 스키마라는 단일 진실의 원천(Single Source of Truth)으로 수렴시키려는 시도이고, Railway-Oriented 에러 처리는 그 파이프라인을 타입-세이프하게 합성하는 방법론입니다. 번들 사이즈 측면에서도 resolver 패키지 하나가 빠지는 건 Core Web Vitals에 민감한 팀이라면 무시할 수 없는 차이입니다.

사실 이건 flexbox로 해결되는 레이아웃 문제가 아닙니다. 폼 에러 UX는 디자인 시안에서 가장 소홀하게 다뤄지면서, 실제 사용자 여정에서 가장 치명적인 이탈 지점입니다. 기획자가 "이메일 중복 시 에러 표시"라고 한 줄 적어놓은 것 뒤에, 개발자가 감당해야 하는 에러 우선순위·race condition·타입 안전성의 무게를 — 이 Schema-First 아키텍처가 구조적으로 덜어줄 수 있다면, 적어도 한 번은 진지하게 검토할 가치가 있습니다.

출처

더 많은 AI 트렌드를 Seedora 앱에서 확인하세요