본문으로 건너뛰기

© 2026 Molayo

Smashing헤드라인2026. 05. 20. 08:06

React 및 Next.js에서 동적 폼 (Dynamic Forms) 구축하기

요약

React 개발자들이 흔히 사용하는 React Hook Form과 Zod 스택이 복잡한 조건부 로직과 파생 값이 포함된 동적 폼을 처리할 때 직면하는 한계를 분석합니다. 단순한 UI 컴포넌트 중심의 접근 방식 대신, 폼을 JSON 스키마 기반의 데이터로 취급하는 SurveyJS 방식과의 비교를 통해 상황에 맞는 적절한 설계 모델을 제시합니다.

핵심 포인트

  • React Hook Form과 Zod는 단순한 CRUD 폼에는 매우 효율적이지만, 복잡한 필드 간 상호작용이 있는 폼에서는 한계가 있음
  • 폼의 로직이 복잡해지면 컴포넌트 트리가 비즈니스 로직을 담는 그릇이 되어 멘탈 모델이 무너질 수 있음
  • 복잡한 동적 폼의 경우 폼을 UI 컴포넌트가 아닌 JSON 기반의 데이터(Schema)로 취급하는 접근 방식이 대안이 될 수 있음
  • 상황에 따라 컴포넌트 중심 모델과 데이터 중심 모델 중 적절한 추상화를 선택하는 것이 중요함

이 기사는 맞춤형 웹 폼을 위한 JavaScript UI 라이브러리인 SurveyJS의 소중한 후원을 받았습니다. 데이터에 대한 완전한 소유권을 유지하세요. SaaS의 제한 없이 여러분의 앱(React/Angular/Vue)에서 JSON 기반의 폼을 구축할 수 있습니다. 감사합니다!

대부분의 React 개발자들이 명시적으로 논의하지 않아도 공유하고 있는 멘탈 모델 (Mental Model)이 있습니다. 그것은 바로 폼 (Forms)은 항상 컴포넌트 (Components)여야 한다는 것입니다. 이는 다음과 같은 스택을 의미합니다:

React Hook Form: 로컬 상태 (Local State) 관리용 (최소한의 리렌더링 (Re-renders), 인체공학적인 필드 등록 (Field Registration), 명령형 상호작용 (Imperative Interaction)).
Zod: 유효성 검사 (Validation) 용 (입력 정확성, 경계 유효성 검사, 타입 안전 파싱 (Type-safe Parsing)).
React Query: 백엔드용 (Submission, Retries, Caching, Server Sync 등).

그리고 대다수의 폼 — 로그인 화면, 설정 페이지, CRUD 모달 등 — 에 있어서 이 방식은 매우 잘 작동합니다. 각 요소가 자신의 역할을 수행하고, 깔끔하게 조합되며, 여러분은 제품을 차별화하는 실제 애플리케이션 부분으로 넘어갈 수 있습니다.

하지만 가끔씩, 이전 답변에 따라 달라지는 가시성 규칙 (Visibility Rules)이나, 세 개의 필드를 거쳐 연쇄적으로 작용하는 파생 값 (Derived Values) 같은 것들이 쌓이기 시작하는 폼이 나타납니다. 어쩌면 누적 합계에 따라 건너뛰거나 보여줘야 하는 페이지 전체가 될 수도 있습니다.

여러분은 첫 번째 조건문을 useWatch와 인라인 분기 (Inline Branch)로 처리하며, 이는 괜찮습니다. 그다음 또 다른 조건문이 나옵니다. 그러다 보면 Zod 스키마 (Schema)가 일반적인 방식으로 표현할 수 없는 필드 간 규칙 (Cross-field Rules)을 인코딩하기 위해 superRefine을 찾게 됩니다. 그러고 나면 단계 탐색 (Step Navigation)에 비즈니스 로직 (Business Logic)이 새어 나오기 시작합니다. 어느 시점에 이르면, 여러분은 자신이 구축한 것을 바라보며 이 폼이 더 이상 단순한 UI가 아니라는 것을 깨닫게 됩니다. 그것은 오히려 의사 결정 프로세스 (Decision Process)에 가까우며, 컴포넌트 트리 (Component Tree)는 단지 그것을 저장하기 위해 우연히 선택된 장소일 뿐입니다.

이 지점이 바로 React에서 폼 (Forms)에 대한 멘탈 모델 (Mental Model)이 무너진다고 생각하는 부분이며, 이는 정말 누구의 잘못도 아닙니다. RHF + Zod 스택은 설계된 목적에 따라 매우 훌륭하게 작동합니다. 문제는 우리가 그 추상화 (Abstractions)가 문제와 일치하지 않는 지점을 지나서도 계속해서 그것을 사용하려는 경향이 있다는 것입니다. 왜냐하면 대안을 선택하려면 폼에 대해 완전히 다른 방식으로 생각해야 하기 때문입니다.

이 글은 바로 그 대안에 관한 것입니다. 이를 보여주기 위해, 우리는 정확히 동일한 멀티 스텝 폼 (Multi-step form)을 두 번 구축할 것입니다:

  • 제출을 위해 React Query와 연결된 React Hook Form + Zod를 사용하는 방식,
  • 폼을 컴포넌트 트리 (Component Tree)가 아닌 데이터 — 즉, 단순한 JSON 스키마 (JSON Schema) — 로 취급하는 SurveyJS를 사용하는 방식.

요구사항, 조건부 로직 (Conditional logic), 마지막 API 호출은 모두 동일합니다. 그런 다음 무엇이 이동했고 무엇이 그대로 남았는지를 정확히 매핑하고, 어떤 모델을 언제 사용해야 하는지 결정할 수 있는 실질적인 방법을 제시할 것입니다.

우리가 구축할 폼:

이 폼은 4단계 흐름을 사용합니다:

1단계: 상세 정보 (Details)

  • 이름 (필수),
  • 이메일 (필수, 유효한 형식).

2단계: 주문 (Order)

  • 단가 (Unit price),
  • 수량 (Quantity),
  • 세율 (Tax rate),
  • 파생 값 (Derived):
    • 소계 (Subtotal),
    • 세금 (Tax),
    • 총액 (Total).

3단계: 계정 및 피드백 (Account & Feedback)

  • 계정이 있습니까? (예/아니오)

  • '예'인 경우 → 사용자 이름 (Username) + 비밀번호 (Password), 둘 다 필수.

  • '아니오'인 경우 → 1단계에서 이미 수집된 이메일 사용.

  • 만족도 점수 (1–5)

  • 4점 이상인 경우 → "어떤 점이 좋았나요?"라고 질문

  • 2점 이하인 경우 → "어떤 점을 개선할 수 있을까요?"라고 질문

4단계: 검토 (Review)

  • total >= 100인 경우에만 나타남

  • 최종 제출.

이것은 극단적인 사례는 아닙니다. 하지만 아키텍처 (Architectural) 차이를 드러내기에는 충분합니다.

파트 1: 컴포넌트 중심 (Component-Driven) (React Hook Form + Zod)

설치 (Installation)

npm install react-hook-form zod @hookform/resolvers @tanstack/react-query

Zod 스키마 (Zod Schema)

Zod 스키마부터 시작해 보겠습니다. 왜냐하면 보통 그곳에서 폼의 형태가 결정되기 때문입니다. 처음 두 단계인 개인 정보 및 주문 입력의 경우, 필수 문자열, 최소값이 있는 숫자, 그리고 열거형 (Enum) 등 모든 것이 간단합니다. 흥미로운 부분은 조건부 규칙 (Conditional rules)을 표현하려고 할 때 시작됩니다.

import { z } from "zod";
export const formSchema = z.object({
firstName: z.string().min(1, "Required"),
...

usernamepasswordoptional()로 타입이 지정되어 있다는 점에 주목하세요.

이 필드들은 조건부로 필수 사항(conditionally required)임에도 불구하고 말이죠. 이는 Zod의 타입 레벨 스키마 (type-level schema)가 객체의 *형태 (shape)*를 설명할 뿐, 필드가 중요해지는 시점을 결정하는 규칙을 설명하지 않기 때문입니다.

조건부 필수 사항은 superRefine 내부에 존재해야 합니다. superRefine은 형태 (shape)가 검증된 후에 실행되며 전체 객체에 접근할 수 있습니다. 이러한 분리는 결함이 아니라, 도구가 설계된 방식 그대로입니다. 즉, superRefine은 스키마 구조 자체에서 표현할 수 없는 필드 간 로직 (cross-field logic)이 들어가는 곳입니다.

여기서 또한 주목할 점은 이 스키마가 표현하지 않는 것들입니다. 이 스키마에는 페이지 (pages)에 대한 개념도, 어느 시점에 어떤 필드가 보이는지에 대한 개념도, 그리고 네비게이션 (navigation)에 대한 개념도 없습니다. 그 모든 것들은 다른 어딘가에 존재하게 될 것입니다.

폼 컴포넌트 (Form Component)

import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
...

여기서 꽤 많은 일들이 일어나고 있으며, 각 요소가 어디에 위치하게 되었는지 천천히 살펴볼 가치가 있습니다.

  • 파생된 값들 (derived values) — subtotal, tax, total — 은 useWatchuseMemo를 통해 컴포넌트 내에서 계산됩니다. 이 값들은 실시간 필드 값에 의존하며, 이 외에 이들을 둘 자연스러운 장소가 없기 때문입니다.
  • username, password, positiveFeedback, improvementFeedback에 대한 가시성 규칙 (visibility rules)은 JSX 내의 인라인 조건문 (inline conditionals)으로 존재합니다.
  • 단계 건너뛰기 로직 (step-skipping logic) — total >= 100일 때만 리뷰 페이지가 나타나는 것 — 은 showSubmit 변수와 3단계의 렌더링 조건에 내장되어 있습니다.
  • 네비게이션 (navigation) 자체는 우리가 수동으로 증가시키는 useState 카운터일 뿐입니다.
  • React Query가 재시도 (retries), 캐싱 (caching), 무효화 (invalidation)를 처리합니다. 폼은 검증된 데이터를 가지고 mutation.mutate를 호출하기만 하면 됩니다.

이것들이 딱히 틀린 것은 아닙니다. 이것은 여전히 관용적인 (idiomatic) React 방식이며, RHF (React Hook Form)가 리렌더링 (re-renders)을 격리하는 방식 덕분에 컴포넌트의 성능도 상당히 뛰어납니다.

하지만 만약 당신이 이 코드를 작성하지 않은 사람에게 이 코드를 건네주며 어떤 조건에서 리뷰 페이지가 나타나는지 설명해 달라고 요청한다면, 그들은 단 한 줄로 설명할 수 있는 규칙을 재구성하기 위해 showSubmit, 3단계 렌더링 조건, 그리고 네비게이션 버튼 로직이라는 세 개의 서로 다른 지점을 모두 추적해야 할 것입니다.

폼은 작동하지만, 그 동작이 시스템으로서 명확하게 조사(inspectable)되지 않습니다. 머릿속으로 코드를 실행해 보아야만 합니다.

더 중요한 점은, 이를 변경하려면 엔지니어링 인력이 필요하다는 것입니다. 리뷰 단계가 나타나는 시점을 조정하는 것과 같은 작은 수정조차도 컴포넌트를 편집하고, 검증 (validation)을 업데이트하고, 풀 리퀘스트 (pull request)를 생성하고, 리뷰를 기다린 뒤 다시 배포하는 과정을 의미합니다.

파트 2: 스키마 기반 (Schema-Driven, SurveyJS)

이제 스키마를 사용하여 동일한 흐름을 구축해 보겠습니다.

설치 (Installation)

npm install survey-core survey-react-ui @tanstack/react-query

survey-core

SurveyJS의 폼 렌더링을 구동하는 MIT 라이선스의 플랫폼 독립적인 런타임 엔진(runtime engine)이며, 여기서 우리가 주목하는 부분입니다. 이 엔진은 JSON 스키마를 받아 내부 모델을 구축하고, 그렇지 않았다면 React 컴포넌트 내에 존재했을 모든 것들을 처리합니다. 즉, 가시성 표현식 (visibility expressions) 평가, 파생된 값 (derived values) 계산, 페이지 상태 관리, 검증 (validation) 추적, 그리고 실제로 어떤 페이지들이 보여졌는지에 따라 무엇이 "완료"인지를 결정하는 작업 등을 수행합니다.

survey-react-ui

해당 모델을 React와 연결하는 UI / 렌더링 레이어입니다. 본질적으로 엔진의 상태가 변경될 때마다 리렌더링되는 <Survey model={model} /> 컴포넌트입니다. SurveyJS UI 라이브러리는 Angular, Vue3 및 기타 많은 프레임워크용으로도 제공됩니다.

이 둘을 함께 사용하면 제어 흐름 (control flow)을 단 한 줄도 작성하지 않고도 완전히 기능하는 멀티 페이지 폼 런타임을 가질 수 있습니다.

앞서 언급했듯이 스키마 (schema) 형식 자체는 단순한 JSON일 뿐이며, DSL (Domain Specific Language)이나 독점적인 기술을 사용하지 않습니다. 이를 인라인 (inline)으로 작성하거나, 파일에서 가져오거나, API로부터 페치 (fetch)하거나, 데이터베이스 컬럼에 저장한 뒤 런타임 (runtime)에 하이드레이션 (hydrate)할 수 있습니다.

데이터로서의 동일한 폼

여기 동일한 폼을 이번에는 JSON 객체로 표현한 모습이 있습니다. 이 스키마는 구조, 유효성 검사 (validation), 가시성 규칙 (visibility rules), 파생 계산 (derived calculations), 페이지 네비게이션 (page navigation) 등 모든 것을 정의하며, 이를 런타임에 평가하는 Model에 전달합니다. 전체 모습은 다음과 같습니다:

export const surveySchema = {
title: "Order Flow",
showProgressBar: "top",
...

잠시 이 코드를 RHF (React Hook Form) 버전과 비교해 보겠습니다.

  • usernamepassword를 조건부로 필수 항목으로 만들었던 superRefine 블록이 사라졌습니다. visibleIf: "{hasAccount} = 'Yes'"isRequired: true와 결합되어, 해당 필드 자체에서 두 가지 관심사를 함께 처리하며, 이는 사용자가 해당 설정을 찾을 것으로 기대되는 위치에 있습니다.
  • subtotal, tax, total을 계산하던 useWatch + useMemo 체인은 서로의 이름을 참조하는 세 개의 expression 필드로 대체되었습니다.
  • RHF 버전에서는 showSubmit과 3단계 렌더링 분기를 추적해야만 재구성할 수 있었던 리뷰 페이지 조건도 포함됩니다.
  • 마지막으로, 네비게이션 버튼 로직은 페이지 객체의 단일 visibleIf 속성으로 처리됩니다.

동일한 로직이 존재합니다. 다만 스키마를 사용하면 로직이 컴포넌트 전반에 흩어져 있는 대신, 독립적으로 확인할 수 있는 특정 위치에 존재하게 됩니다.

또한, 스키마가 subtotal, tax, total에 대해 type: 'expression'을 사용한다는 점에 주목하세요. expression은 읽기 전용이며 주로 계산된 값을 표시하는 데 사용됩니다. SurveyJS는 정적 콘텐츠를 위해 type: 'html'도 지원하지만, 계산된 값을 위해서는 expression이 적절한 선택입니다.

이제 React 측면을 살펴보겠습니다.

렌더링 및 제출

매우 간단합니다. onCompleteuseMutation이나 일반적인 fetch를 통해 동일한 방식으로 API에 연결하면 됩니다:

import { useState, useEffect, useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import { Model } from "survey-core";
...

onComplete는 사용자가 마지막으로 보이는 (visible) 페이지에 도달했을 때 실행됩니다. 따라서 total이 100을 넘지 않아 리뷰 페이지가 건너뛰어지더라도, SurveyJS가 "마지막 페이지"의 의미를 결정하기 전에 가시성 (visibility)을 먼저 평가하기 때문에 여전히 올바르게 실행됩니다.

그 다음, sender.data에는 계산된 값들(subtotal, tax, total)이 일급 필드 (first-class fields)로서 모든 답변과 함께 포함되므로, API 페이로드 (payload)는 RHF 버전에서 onSubmit을 통해 수동으로 조립했던 것과 동일합니다.

mutationRef 패턴은 매 렌더링마다 변경되는 값 대신 안정적인 이벤트 핸들러 (event handler)가 필요한 곳이라면 어디에서나 사용할 수 있는 방식이며, SurveyJS에 특화된 기능은 아닙니다.

이제 React 컴포넌트에는 비즈니스 로직 (business logic)이 전혀 남아있지 않습니다. useWatch도, 조건부 JSX도, 단계 카운터 (step counter)도, useMemo 체인도, superRefine도 없습니다. React는 자신이 실제로 잘하는 일, 즉 컴포넌트를 렌더링하고 이를 API 호출에 연결하는 일에 집중하고 있습니다.

React에서 무엇이 빠져나갔는가?

관심사 (Concern)RHF 스택SurveyJS
가시성 (Visibility)JSX 분기 (branches)visibleIf
.........

React에 남아있는 것은 레이아웃 (layout), 스타일링 (styling), 제출 연결 (submission wiring), 그리고 앱 통합 (app integration)이며, 다시 말해 React가 실제로 설계된 목적에 부합하는 것들입니다.

그 외의 모든 것은 스키마 (schema)로 이동했습니다. 스키마는 단순한 JSON 객체이기 때문에 데이터베이스에 저장할 수 있고, 애플리케이션 코드와 독립적으로 버전을 관리할 수 있으며, 배포 없이도 내부 도구를 통해 편집할 수 있습니다.

리뷰 페이지를 트리거하는 임계값 (threshold)을 변경해야 하는 프로덕트 매니저(PM)는 컴포넌트를 건드리지 않고도 이를 수행할 수 있습니다. 이는 폼의 동작이 빈번하게 진화하고 항상 엔지니어에 의해 주도되지 않는 팀에게 의미 있는 운영상의 차이를 제공합니다.

각 접근 방식을 언제 사용해야 하는가?

제가 사용하는 유용한 기준은 다음과 같습니다: 폼을 완전히 삭제한다고 상상해 보세요. 무엇을 잃게 될까요?

  • 만약 화면(Screens)에 관한 것이라면, 컴포넌트 중심의 폼 (component-driven forms)이 필요합니다.
  • 만약 임계값(thresholds), 분기 규칙(branching rules), 그리고 실제 의사결정을 인코딩하는 조건부 요구사항(conditional requirements)과 같은 비즈니스 로직 (business logic)에 관한 것이라면, 스키마 엔진 (schema engine)이 필요합니다.

마찬가지로, 여러분에게 다가올 변경 사항이 주로 레이블(labels), 필드(fields), 레이아웃(layout)에 관한 것이라면 React Hook Form (RHF)으로 충분할 것입니다. 만약 운영(ops) 팀이나 법무(legal) 팀이 티켓을 발행하지 않고도 어느 화요일 오후에 즉시 조정해야 할 수도 있는 조건(conditions), 결과(outcomes), 규칙(rules)에 관한 것이라면, SurveyJS를 사용한 스키마 모델 (schema model)이 더 정직한 선택입니다.

이 두 가지 접근 방식은 실제로 서로 경쟁 관계에 있지 않습니다. 이들은 서로 다른 범주의 문제들을 다루며, 피해야 할 실수는 로직의 무게에 맞지 않게 추상화 (abstraction)를 잘못 매칭하는 것입니다. 즉, 익숙한 도구라는 이유로 규칙 시스템 (rule system)을 컴포넌트처럼 취급하거나, 폼이 3단계로 늘어나고 조건부 필드 (conditional field)가 생겼다는 이유만으로 정책 엔진 (policy engine)을 찾는 것과 같은 실수입니다.

여기서 우리가 구축한 폼은 의도적으로 그 경계 근처에 위치하며, 차이점을 드러낼 만큼 충분히 복잡하지만 비교가 불공정하게 느껴질 정도로 극단적이지는 않습니다. 여러분의 코드베이스에서 다루기 힘들어졌던 대부분의 실제 폼들도 아마 그 경계 근처에 있을 것이며, 문제는 대개 그것들이 실제로 무엇인지에 대해 누군가 이름을 붙였느냐 하는 것입니다.

다음과 같은 경우에 React Hook Form + Zod를 사용하세요:

AI 자동 생성 콘텐츠

본 콘텐츠는 Smashing Magazine의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0