왜 폼 설계 패턴에 고민이 필요할까?
React 개발자라면 react-hook-form + Zod + React Query 조합이 폼 구현의 '표준'처럼 느껴질 때가 있습니다. 로그인, 설정 페이지, 기본 CRUD 모달에는 정말 잘 맞죠. 각 라이브러리가 명확한 역할을 하고, 조합도 깔끔합니다.
하지만 폼이 점점 복잡해지기 시작하면 이야기가 달라집니다. 이전 답변에 따라 필드가 보였다 안 보였다 하는 조건부 표시 규칙, 여러 필드를 가로지르는 파생 값 계산, 특정 금액 이상일 때만 나타나는 검토 페이지 등이 추가되면, 컴포넌트 트리 안에 비즈니스 로직이 스며들기 시작합니다.
이 시점에서 폼은 더 이상 단순한 UI가 아닙니다. 하나의 '의사결정 프로세스'가 되죠. 그런데 우리는 여전히 UI 컴포넌트라는 틀 안에 이 로직을 우겨넣고 있습니다. 이 글에서는 이러한 복잡한 폼을 구현하는 두 가지 근본적으로 다른 접근법을 비교해보고, 언제 어떤 방식을 선택해야 하는지 실무적인 기준을 제시합니다. 이 분석은 Smashing Magazine의 폼 설계 관련 글을 근거로 하고 있습니다.

사례 분석: 동일한 다단계 폼, 두 가지 구현
비교를 위해 4단계로 이루어진 주문 폼을 가정해보겠습니다.
- 개인 정보: 이름, 이메일(필수)
- 주문 정보: 단가, 수량, 세율 → 파생 값: 소계, 세금, 총액 자동 계산
- 계정 & 피드백:
- 계정 보유 여부(예/아니오) → '예'면 아이디/비밀번호 필수
- 만족도(1-5점) → 4점 이상이면 긍정 피드백, 2점 이하면 개선 피드백 요청
- 검토: 총액 100 이상일 때만 표시
구현 1: 컴포넌트 주도 (React Hook Form + Zod)
Zod 스키마부터 정의합니다. 조건부 필수 항목(아이디/비밀번호)은 superRefine으로 처리해야 합니다.
// schema.ts
import { z } from "zod";
export const formSchema = z.object({
firstName: z.string().min(1, "필수 항목입니다"),
email: z.string().email("유효한 이메일을 입력해주세요"),
price: z.number().min(0),
quantity: z.number().min(1),
taxRate: z.number(),
hasAccount: z.enum(["Yes", "No"]),
// 조건부 필수이지만 타입은 optional
username: z.string().optional(),
password: z.string().optional(),
satisfaction: z.number().min(1).max(5),
positiveFeedback: z.string().optional(),
improvementFeedback: z.string().optional(),
}).superRefine((data, ctx) => {
// 조건부 검증 로직
if (data.hasAccount === "Yes") {
if (!data.username) {
ctx.addIssue({ code: "custom", path: ["username"], message: "필수 항목입니다" });
}
if (!data.password || data.password.length < 6) {
ctx.addIssue({ code: "custom", path: ["password"], message: "6자 이상 입력해주세요" });
}
}
if (data.satisfaction >= 4 && !data.positiveFeedback) {
ctx.addIssue({ code: "custom", path: ["positiveFeedback"], message: "좋았던 점을 공유해주세요" });
}
if (data.satisfaction <= 2 && !data.improvementFeedback) {
ctx.addIssue({ code: "custom", path: ["improvementFeedback"], message: "개선할 점을 알려주세요" });
}
});
폼 컴포넌트에서는 useWatch로 실시간 값 추적, useMemo로 파생 값 계산, JSX 조건문으로 필드 표시 제어를 합니다. 검토 페이지 표시 조건(total >= 100)은 showSubmit 변수와 렌더링 조건문에 분산되어 있습니다.
한계: 로직이 컴포넌트(표시 제어), 스키마(검증), 상태(네비게이션)에 흩어져 있어 전체적인 행위를 한눈에 파악하기 어렵습니다. 사소한 규칙 변경도 코드 수정과 재배포가 필요하죠.

구현 2: 스키마 주도 (SurveyJS)
동일한 폼을 JSON 스키마로 정의해보면 완전히 다른 모습이 됩니다.
// survey-schema.json
{
"title": "주문 흐름",
"pages": [
{
"name": "details",
"elements": [
{ "type": "text", "name": "firstName", "isRequired": true },
{ "type": "text", "name": "email", "inputType": "email", "isRequired": true }
]
},
{
"name": "order",
"elements": [
{ "type": "text", "name": "price", "inputType": "number", "defaultValue": 0 },
{ "type": "text", "name": "quantity", "inputType": "number", "defaultValue": 1 },
{ "type": "dropdown", "name": "taxRate", "defaultValue": 0.1 },
{ "type": "expression", "name": "subtotal", "expression": "{price} * {quantity}" },
{ "type": "expression", "name": "tax", "expression": "{subtotal} * {taxRate}" },
{ "type": "expression", "name": "total", "expression": "{subtotal} + {tax}" }
]
},
{
"name": "account",
"elements": [
{ "type": "radiogroup", "name": "hasAccount", "choices": ["Yes", "No"] },
{
"type": "text",
"name": "username",
"visibleIf": "{hasAccount} = 'Yes'",
"isRequired": true
},
{
"type": "text",
"name": "password",
"inputType": "password",
"visibleIf": "{hasAccount} = 'Yes'",
"isRequired": true
},
{ "type": "rating", "name": "satisfaction", "rateMin": 1, "rateMax": 5 },
{ "type": "comment", "name": "positiveFeedback", "visibleIf": "{satisfaction} >= 4" },
{ "type": "comment", "name": "improvementFeedback", "visibleIf": "{satisfaction} <= 2" }
]
},
{
"name": "review",
"visibleIf": "{total} >= 100",
"elements": []
}
]
}
차이점 정리
| 관심사 | RHF + Zod 스택 | SurveyJS (스키마) |
|---|---|---|
| 표시 제어 | JSX 조건문 | visibleIf 속성 |
| 파생 값 | useWatch + useMemo | expression 타입 필드 |
| 교차 필드 규칙 | superRefine | 스키마 내 조건식 |
| 네비게이션 | step 상태 관리 | 페이지 visibleIf |
| 규칙 위치 | 파일 전반 분산 | 스키마 내 중앙 집중 |
React 컴포넌트는 이제 레이아웃, 스타일링, 제출 API 연결만 담당합니다. 모든 비즈니스 로직은 JSON 스키마 안에 명시적으로 정의되죠.
국내 개발 환경에서의 적용 맥락: 한국의 SI 프로젝트나 대규모 엔터프라이즈 시스템에서는 폼의 비즈니스 규칙이 빈번하게 변경되는 경우가 많습니다. 법규 개정, 내부 정책 변경 등으로 인해 기획자나 운영팀이 직접 규칙을 수정해야 할 필요가 있다면, 스키마 주도 방식이 더 현실적인 해결책이 될 수 있습니다. JSON 스키마를 DB에 저장하거나 CMS로 관리하면 재배포 없이 실시간 적용이 가능하니까요.

실무 선택 가이드: 어떤 접근법을 써야 할까?
간단한 기준을 하나 제시합니다. "이 폼을 완전히 삭제한다면, 무엇을 잃게 될까?" 생각해보세요.
- 화면(UI)을 잃는다 → 컴포넌트 주도 폼이 적합합니다.
- 비즈니스 로직(규칙, 조건, 의사결정 흐름)을 잃는다 → 스키마 엔진이 더 나은 선택입니다.
React Hook Form + Zod를 선택할 때
- 폼이 기본 CRUD에 가깝고 로직이 단순할 때
- 모든 비즈니스 규칙을 개발자가 소유하고 관리할 때
- UI 변경이 주를 이루고, 백엔드가 여전히 진리의 원천일 때
- 국내 스타트업이나 소규모 프로젝트처럼 빠른 프로토타이핑이 중요할 때
SurveyJS(또는 유사 스키마 엔진)를 선택할 때
- 폼이 복잡한 비즈니스 의사결정을 인코딩할 때
- 규칙이 UI와 독립적으로 진화해야 할 때 (예: 기획자/법무팀 요구사항)
- 로직을 가시화, 감사, 버전 관리해야 할 때
- 동일한 폼을 여러 프론트엔드(React, Angular, Vue)에서 재사용해야 할 때
- 대규모 엔터프라이즈나 금융, 의료 등 규제가 엄격한 분야에서
주의사항과 다음 학습 방향
스키마 주도 방식의 한계: 학습 곡선이 있습니다. 새로운 문법(visibleIf, expression)을 익혀야 하죠. 또한, 매우 특화된 커스텀 UI 컴포넌트가 필요하다면 스키마 엔진의 확장성에 제약을 느낄 수 있습니다. 컴포넌트 주도 방식의 한계는 앞서 설명한 대로 로직 분산과 변경의 어려움입니다.
함께 보면 좋은 글: 이 글에서 다룬 '선언적 스키마' 접근법은 "클라우드 연결이 끊겨도 안전하게 AI를 구동하는 법 Microsoft Sovereign Cloud 업데이트 핵심"에서 살펴본 오프라인 AI의 자율성 개념과, "구글 AI 에지의 온디바이스 함수 호출, 이제 아이폰에서도 가능해졌다"에서 논의된 에지 컴퓨팅의 로컬 실행 패러다임과 맥을 같이합니다. 모두 '중앙 집중식 제어'에서 '로컬 자율성'으로의 패러다임 전환을 보여주죠.
다음 단계: Formik, Final Form 등 다른 폼 라이브러리와의 비교, 또는 JSON 스키마를 직접 파싱하고 렌더링하는 커스텀 엔진 구축에 도전해보는 것도 좋은 학습이 될 것입니다. 복잡성과 유연성 사이에서 팀과 프로젝트에 가장 적합한 균형점을 찾는 것이 핵심입니다.