본문 바로가기
React

디자인 패턴을 활용한 Form 컴포넌트 리팩터링: 유연성과 재사용성 모두 확보하기(1)

by 복숭아 우유씨 2025. 1. 19.

 

복잡한 형태의 form을 유연하게 사용하기 위해,
form 관련 공통 컴포넌트를 리팩터링한 이야기

(보안 및 저작권 보호를 위해, 특정 회사의 내부 코드나 기밀 정보를 제외하였으며,

일반적인 기술 개념과 패턴을 기반으로 서술하였습니다.)

 

 

리팩터링 배경 및 필요성

 

1. 복잡한 form이 많이 사용되는 환경

서비스의 특성상 복잡한 form이 많이 필요했다. 다수의 페이지와 모달에서 생성/수정 과정이 있었다.

이때 입력 필드는 단순한 text input, number input, date input 등만 있는 것이 아니라, 복합적인 구조도 많았다. 다른 컴포넌트 내부에 중첩되어 있거나, 동적인 폼, 추가/삭제가 필요하기도 하는 등 매우 다양했다.

따라서 일관된 UX를 유지하면서도 확장성을 고려한 Form 컴포넌트가 필요했다.

 

2. 기존 코드의 한계 발견: 과도한 추상화로 인한 확장의 어려움

초기 기획은 단순했고 변동 가능성을 개발자들이 예측할 수 없었기에, 초기에 개발된 Form 컴포넌트는 재사용성을 높이기 위해 높은 수준으로 추상화되어 있었다. 그러나 기획도 확장되며 어려움이 발생했다.

 

초기 개발시, 하나의 <Form /> 컴포넌트에서 모든 폼을 생성하고 입력 필드를 관리하도록 설계되었으며, 필요한 속성들은 props로 전달되는 방식이었다. 

 

아래는 Form 컴포넌트를 사용하는 생성 페이지의 예시이다. `<Form />` 컴포넌트 하나로 일관된 form을 만들 수 있다는 장점이 있다.

// Form 컴포넌트를 사용하는 페이지

import { schema, formProperties } from "..";
import { Form } from "@/components";

export const ExampleUsingForm = () => {
  const submitHandler = (data) => {
    // ...
  };

  return (
    <div>
      {/* ...기타 코드... */}

        <Form
          schema={schema}
          property={formProperties}
          onSubmit={(data) => submitHandler(data)}
        />

      {/* ...기타 코드... */}
    </div>
  );
};

 

 

이때 schema에는 react-hook-form의 useForm에서 사용할 schema이고, property는 만들 form에 대한 정보를 담고 있는 객체의 배열이다.

여기서 사용한 `<Form />`은 아래의 구조처럼 내부에 input을 만드는 팩터리 패턴을 포함하고 있다.

import { FormProvider, useForm } from "react-hook-form";
import { InputGenerater, HelperText } from "@/components";

export const CustomFormExample = ({
  schema,
  property,
  onSubmit,
  // ...
  // 점점 props가 증가함
}) => {
  const methods = useForm<any>({
    defaultValues,
  });
  const {
    handleSubmit,
    formState: { errors },
  } = methods;

  return (
    <FormProvider {...methods}>
      <form onSubmit={handleSubmit(onSubmit)}>
        {/* ...form 관련 코드들... */}
        
          <InputFactory
            {...inputProps} // 다양한 props가 추가됨
          />
          
        {/* ...form 관련 코드들... */}    
      </form>
    </FormProvider>
  );
};

 

기획이 변경없이 일관된 경우에는 위의 구조로 최대한 재사용성을 확보하는 일이 좋았으나, 기획/디자인이 다양해지면서 다음과 같은 문제가 발생했다.

  • props가 지나치게 많아지고 복잡해짐
  • 기획이 변경될 때마다 공통 컴포넌트를 수정해야 하는 상황 발생
  • 과도한 추상화로 결합도 높아져서 유지보수 어려움 증가

3. 좋은 타이밍

마침 시기적으로도 리팩터링이 가능한 시간이 생겨서 리팩터링을 진행하게 되었다.

 

 

리팩터링 과정에서 했던 고민들

 

고민1: form을 하나의 공통 컴포넌트인 `<Form />`을 별도로 구현하여 관리할 것인가?

방법 1) 공통 컴포넌트를 사용하지 않을 경우

HTML의 `<form>` 태그를 사용하여 구현하며, 각 페이지마다 react-hook-form의 로직을 작성하는 방법

  • 장점: 유연한 대응 가능, 공통 컴포넌트를 잘못 만들었을 경우를 고려하지 않아도 됨
  • 단점: 공통 로직/설정 (ex. validation, 기본값 등)도 매번 반복적으로 세팅 필요
// <form>을 사용하는 페이지 예시 (해당 과정을 매번 반복해야함)

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { schema } from "./schema";

export const ExamplePageUsingTag = () => {
  const methods = useForm({
    resolver: zodResolver(schema), 
    defaultValues: { name: "내이름" },
  });
  const {
    handleSubmit,
    register,
    formState: { errors },
  } = methods;

  const onSubmit = (data) => {...}

  return (
    <>
      <form onSubmit={handleSubmit(onSubmit ?? ((data) => console.log(data)))} >
		{* input 및 button 등 직접 추가 *}
      </form>
    </>
  );
};

 

2) 공통 `<Form />` 컴포넌트 구현

React-hook-form의 `FormProvider`를 사용한 `<Form />` 컴포넌트를 구현하고, 필요한 속성을 props로 전달하는 방식.

이때 input 등은 children으로 입력하도록 한다.

  • 장점: 중복 코드 감소, 유지보수 용이(기본 설정 공통화로)
  • 단점: 잘못 설계할 경우, 확장성 이슈 / 렌더링 최적화를 고려해야 할 수 있음 
// 공통 컴포넌트로 구현한 Form.tsx 예시

import { zodResolver } from "@hookform/resolvers/zod";
import { type ReactNode } from "react";
import {
  FormProvider,
  useForm,
  type FieldValues,
  type SubmitHandler,
  type UseFormProps,
} from "react-hook-form";
import type { ObjectSchema } from "zod";

type FormProps<T extends FieldValues> = UseFormProps & {
  children: ReactNode;
  schema: ObjectSchema<T>;
  onSubmit?: SubmitHandler<T>;
  className?: string;
};

export const Form = <T extends FieldValues>({
  children,
  schema,
  onSubmit,
  mode,
  className,
  ...props
}: FormProps<T>) => {
  const methods = useForm<T>({
    mode: mode ?? "all",
    resolver: zodResolver(schema),
    ...props,
  });
  const { handleSubmit } = methods;

  return (
    <FormProvider {...methods}>
      <form
        className={className}
        onSubmit={handleSubmit(onSubmit ?? ((data) => console.log(data)))}
      >
        {children}
      </form>
    </FormProvider>
  );
};

 

// 직접 구현한 공통 컴포넌트인 <Form />을 사용하는 생성 페이지 예시

import { schema } from "./schema";

export const ExamplePageUsingForm = () => {
  const onSubmit = (data) = { ... }

  return (
    <>
      <Form
        onSubmit={onSubmit}
        schema={schema}
        defaultValues={{ name: "내이름" }}
      >
		{/* input 및 button 등 추가 */}
      </Form>
    </>
  );
};

 

 

고민 2: Input 컴포넌트는 어떻게 구현할 것인가?

input을 어떻게 구현할지 form을 구현한 상황에 따라서도 구분해서 고민했다.

 

  form 구현 방식 (고민1) 장점 단점
html 태그만 사용 모든 상황 유연성 극대화 - 코드 재사용성이 매우 감소
- 반복 코드가 증가
스타일만 적용된 Input Component 구현  <form> 태그 사용 - UI 일관성 유지 가능
- 상대적으로 유연한 설계 가능
- react-hook-form에서 입력 필드에 사용하는 설정을 매번 입력해야 함
react-hook-form의
useFormContext 활용
- react-hook-form에서 입력 필드에 사용하는 설정을 별도로 전달하지 않아도 됨 - useFormContext로 wrapping한 컴포넌트 추가 구현 필요
공통 <Form /> 컴포넌트 구현하여 사용 - react-hook-form에서 입력 필드에 사용하는 설정을 별도로 전달하지 않아도 됨
- 중복 코드 감소, 유지보수 용이
- useFormContext로 wrapping한 컴포넌트 추가 구현 필요
- 잘못 설계할 경우, 확장성 이슈 / 렌더링 최적화를 고려해야 할 수 있음
합성 컴포넌트
패턴 적용
- 유연성 증가
- 코드 반복 사용 감소
- 리렌더링 최적화 필요
- input 종류에 따른 컴포넌트 추가 구현 필요
Headless UI
라이브러리 사용
(ex. Radix-UI)
모든 상황 - 보다 안정적인 UI 구현 가능
- 합성 컴포넌트 패턴으로 유연성 증가
- 라이브러리 버전 이슈 발생 가능성 있음
- 프로젝트에 비해 과한 기능이 있을 수 있음

 

 

선택: 공통 컴포넌트인 <Form /> 구현 및 radix-ui를 사용한 구조

기획의 변경, 복잡한 form 구조로 최대한 유연하면서 보다 안정적으로 대응할 수 있는 구조를 사용하는 것으로 결정하였으며, 반복되는 구조는 서서히 컴포넌트화 하는 방향으로 접근하기로 했다.

 

 

...

 

구체적인 리팩터링 방법은 다음 글에서 이어서 쓰도록 하겠다.

2025.02.16 - [React] - 디자인 패턴을 활용한 Form 컴포넌트 리팩터링: 유연성과 재사용성 모두 확보하기(최종)

 


Ref.

1. [번역] DRY - 잘못된 추상화의 일반적인 원인

2. https://www.react-hook-form.com/

 

 

댓글