조합형 Form 컴포넌트 설계

조합형 Form 컴포넌트 설계

들어가며

최근 프로젝트를 진행하면서 React Hook Form(RHF)과 Material UI(MUI)를 기반으로 다양한 Form 화면을 구현하였습니다.

프로젝트 초기에 Form UI의 일관성을 유지하기 위해 공통 Field 컴포넌트를 구축하여 사용하고 있었습니다.

<Field.Text
  name="name"
  label="Name"
/>

개발자는 위와 같은 방식으로 입력 필드를 빠르게 구성할 수 있었고, React Hook Form과 MUI를 직접 연결할 필요 없이 공통 컴포넌트만 사용하면 되었습니다.

실제로 서비스에서는 다음과 같은 다양한 입력 컴포넌트를 제공하고 있었습니다.

  • Field.Text

  • Field.Select

  • Field.Autocomplete

  • Field.DatePicker

  • Field.DateTimePicker

  • Field.Checkbox

초기에는 이러한 구조가 개발 생산성과 UI 일관성 측면에서 충분히 효과적이라고 생각했습니다.

하지만 프로젝트가 확장되고 화면 요구사항이 다양해지면서 기존 구조의 한계가 점차 드러나기 시작했습니다.

Monolithic 구조의 한계

초기 Field 컴포넌트는 Label 출력, Validation 처리, React Hook Form 연결, Input 렌더링까지 모든 역할을 하나의 컴포넌트 내부에서 처리하는 구조였습니다.

Field
├─ FormLabel
├─ Controller
└─ Input Component

이러한 구조는 일반적인 입력 화면에서는 사용하기 편리했습니다.

하지만 실제 서비스에서는 단순한 입력 필드보다 복합적인 형태의 UI가 자주 등장했습니다.

Input 옆에 버튼이 있는 UI

대표적인 예로 입력 필드 옆에 버튼이 함께 배치되는 UI가 있습니다.

예를 들어 중복 확인, 인증번호 요청, 주소 검색 등의 기능은 다음과 같은 형태로 구현됩니다.

image1.png

기존 Field.Text는 Label, Input, HelperText가 하나의 컴포넌트로 묶여 있었기 때문에 단순한 Flex 정렬만으로는 원하는 레이아웃을 구현하기 어려웠습니다.

  • align-items: flex-start를 사용하면 Label과 버튼이 같은 높이에 정렬됨

  • align-items: center를 사용하면 Input과 버튼의 수직 정렬이 어색해짐

  • align-items: flex-end를 사용하면 기본 상태에서는 괜찮지만 에러 메시지가 표시될 경우 버튼 위치가 함께 밀려 정렬이 깨짐

결국 버튼 위치를 맞추기 위해 Label 높이와 간격을 계산한 margin-top: 22px과 같은 하드코딩이 필요했습니다.

이는 Label, Input, HelperText가 강하게 결합된 구조가 복합 UI를 구성하는 데 적합하지 않다는 것을 보여주는 사례였습니다.

범위 입력(Range Field)의 HelperText 문제

또 다른 사례는 범위 입력 UI였습니다.

범위 입력은 사용자 입장에서는 하나의 입력 항목이지만 실제 구현은 시작값과 종료값 두 개의 입력 필드로 구성됩니다.

image2.png

기존 구조에서는 각각의 입력 필드가 독립적인 Field로 동작했기 때문에 Validation Error 발생 시 HelperText도 개별적으로 출력되었습니다.

특히 너비가 좁은 화면에서는 에러 메시지가 여러 줄로 줄바꿈되면서 전체 높이가 크게 증가하는 문제가 발생했습니다.

또한 시작값과 종료값의 에러 메시지 길이가 서로 다를 경우 레이아웃 높이가 달라져 화면이 불안정하게 보이는 문제도 있었습니다.

사용자는 "범위"라는 하나의 값을 입력하고 있는데 에러 메시지는 두 개로 분산되어 표시되는 점 역시 UX 측면에서 아쉬운 부분이었습니다.

공통 컴포넌트를 우회하는 사례 발생

프로젝트가 복잡해질수록 일부 화면에서는 기존 Field 컴포넌트만으로 요구사항을 충족하기 어려워졌습니다.

결국 일부 화면에서는 공통 컴포넌트를 사용하지 않고 React Hook Form의 Controller와 MUI 컴포넌트를 직접 사용하는 방식이 사용되기 시작했습니다.

<Controller
  name="period"
  control={control}
  render={({ field }) => (
    <TextField {...field} />
  )}
/>

공통 컴포넌트가 있음에도 불구하고 우회 구현이 발생한다는 점은 해당 컴포넌트의 확장성이 부족하다는 신호였습니다.

이 문제를 해결하기 위해 기존 구조를 다시 검토하게 되었습니다.

Composition 구조를 검토하게 된 이유

기존 구조의 가장 큰 문제는 하나의 컴포넌트가 너무 많은 책임을 가지고 있다는 점이었습니다.

이를 해결하기 위해 React의 Composition Pattern을 검토하게 되었습니다.

Composition은 하나의 거대한 컴포넌트가 모든 기능을 담당하는 대신, 작은 역할 단위 컴포넌트를 조합하여 UI를 구성하는 방식입니다.

대표적인 예시는 다음과 같습니다.

<Card>
  <Card.Header />
  <Card.Body />
  <Card.Footer />
</Card>

각 컴포넌트는 자신의 역할에만 집중하고, 필요한 형태로 자유롭게 조합할 수 있습니다.

이러한 구조는 Design System에서도 자주 사용되는 패턴이며 확장성과 재사용성이 높다는 장점이 있습니다.

Field를 역할 중심으로 분리하기

기존에는 하나의 컴포넌트가 Label, Validation, Form 연결, Error 출력까지 모두 담당했습니다.

이를 다음과 같이 역할 단위로 분리하였습니다.

<Field>
  <Field.Label />
  <Field.Control />
  <Field.HelperText />
</Field>

각 컴포넌트는 다음과 같은 역할을 담당합니다.

  • Field : 레이아웃 구성 및 공통 상태 관리

  • Field.Label : 라벨 출력

  • Field.Control : React Hook Form 연결 및 입력 요소 렌더링

  • Field.HelperText : 에러 메시지 및 안내 문구 출력

역할을 분리하면서 컴포넌트의 책임이 명확해졌고, UI를 훨씬 유연하게 구성할 수 있게 되었습니다.

가장 큰 변화, Field.Group

이번 리팩토링에서 가장 중요한 변화는 Field.Group의 도입이었습니다.

실제 서비스에서는 여러 개의 입력 요소가 하나의 의미를 가지는 경우가 많습니다.

대표적인 예가 기간 선택 UI입니다.

<Field>
  <Field.Label required>
   기간
  </Field.Label>

  <Field.Group>
   <Field.Control name="startDate" />
   <span>~</span>
   <Field.Control name="endDate" />
  </Field.Group>

  <Field.HelperText />
</Field>

기존 구조에서는 시작일과 종료일이 각각 독립적인 필드로 동작했기 때문에 에러 메시지가 중복으로 출력되고, 그룹 단위의 UX를 제공하기 어려웠습니다.

반면 Field.Group을 도입한 이후에는 여러 입력 요소를 하나의 필드 단위로 관리할 수 있게 되었고, 레이아웃과 Validation 처리도 훨씬 자연스럽게 구성할 수 있게 되었습니다.

조합형 구조가 가져온 확장성

Composition 구조를 도입한 이후에는 새로운 요구사항이 등장하더라도 별도의 전용 컴포넌트를 만들 필요가 거의 없어졌습니다.

예를 들어 태그 입력 UI도 기존 컴포넌트를 조합하여 구현할 수 있습니다.

<Field>
  <Field.Label>
    Tags
  </Field.Label>

  <Field.Control
    render={() => (
      <>
        <TextField />
        <ChipList />
      </>
    )}
  />

  <Field.HelperText />
</Field>

이처럼 새로운 기능을 위해 컴포넌트를 계속 추가하는 대신 기존 컴포넌트를 조합하여 다양한 UI를 구현할 수 있게 되었습니다.

적용 결과

Composition 구조를 적용한 이후 다음과 같은 효과를 얻을 수 있었습니다.

  • Form UI 구조의 일관성 확보

  • 복합 입력 UI 대응 능력 향상

  • 새로운 입력 타입 추가 용이

  • MUI slotProps 활용성 증가

  • 공통 컴포넌트 우회 구현 감소

  • 유지보수 비용 절감

  • 재사용성 향상

특히 신규 요구사항이 발생하더라도 기존 컴포넌트를 수정하기보다 조합을 통해 해결할 수 있게 되면서 확장성이 크게 향상되었습니다.

마치며

처음에는 단순히 React Hook Form 래퍼 컴포넌트를 개선하는 작업이라고 생각했습니다.

하지만 실제로는 "공통 컴포넌트를 어떻게 설계해야 장기적으로 확장 가능한가"에 대한 고민이 더 중요한 문제였습니다.

이번 경험을 통해 모든 기능을 하나의 컴포넌트에 추가하는 Monolithic 방식보다는 역할을 분리하고 필요한 기능을 조합할 수 있는 Composition 방식이 훨씬 유연하다는 것을 확인할 수 있었습니다.

앞으로도 Form 컴포넌트뿐만 아니라 Design System 전반에 이러한 설계 방식을 적용하여 재사용성과 확장성을 높여나갈 계획입니다.

nature

Site footer