본문 바로가기
React

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

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

 

B2B회사에서 다양한 형태의 form을 사용하는 제품을 만들면서
form 관련 공통 컴포넌트를 리팩터링한 이야기

(코드는 보안상의 이유로 변형하여 일부만 작성하였습니다.)

 

 

리팩터링 배경 및 필요성

 

1. 제품의 특성상 중요한 form

담당한 제품은 PaaS 솔루션으로, 하위 기능마다 생성/수정 페이지 혹은 모달이 기본적으로 하나 이상 포함되어 있었으며, 각 페이지에는 다수의 입력 필드(input)가 포함되어 있었다.

이때 입력 필드는 단순한 text input, number input, date input 등만 있는 것이 아니라, 복합적인 구조도 많았다. 아코디언 등을 다른 컴포넌트와 함께 사용한 구조, 중첩된 구조, 추가/삭제가 가능해야 하는 구조 등 매우 다양했다. 따라서 form이 많이 사용되었고, 중요했다.

 

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

비슷한 생성 페이지가 반복되는 일이 많아서 form 관련 컴포넌트들을 `<Form />` , `<TextInput />` 등의 공통 컴포넌트로 만들어서 사용하고 있었다.

그런데 문제는 과거 프로젝트 코드 기반이라는 점이었다. 이전에는 기획상 생성 페이지가 단순했고, 페이지 전체가 반복되는 경우가 많았다. 따라서 아래 코드처럼 하나의 `<Form />`이라는 공통 컴포넌트에서 form을 생성하고, input을 관리했다. 이때 관련 속성들은 props로 넘겨서 관리하는 방식으로 구성되어 있었다.

 

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

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

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

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

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

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

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

 

 

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

// schema
import { object, string } from "yup";

export const schema = object().shape({
  name: string().required("필수입니다.").matches(/^A-z/, "잘못된 형식입니다."),
});
// formProperties

const formProperties =  [
  {
    key: "name",
    name: "이름",
    type: ["textInput"],
    isRequired: true,
    helperText:
      "알파벳만 가능합니다.",
  },
]

 

여기서 사용한 `<Form />`은 아래의 구조처럼 input type에 맞게 input을 생성하는 팩터리 패턴을 포함하고 있다.

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

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

  return (
    <FormProvider {...methods}>
      <form onSubmit={handleSubmit(onSubmit)}>
        {property?.map(
          (
            { key, helperText, isRequired, type, name, ...inputProps }, // input관련 Props들이 추가됨
            index,
          ) => (
            <FormGroup
              key={`${key}-${index}`}
              label={name}
              isRequired={isRequired}
              {...props} // 다양한 props가 추가됨
            >
              <InputFactory
                {...inputProps} // 다양한 props가 추가됨
              />
              <HelperText
                {...helperTextProps} // 다양한 props가 추가됨
              />
            </FormGroup>
          ),
        )}

        <FormButtons
          {...buttonProps} // 다양한 props가 추가됨
        />
      </form>
    </FormProvider>
  );
};

 

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

바로 코드에 주석 처리한 props 부분들이 점점 증가하게 된 것이다. 과도한 추상화로 결합도가 높아졌고, 유지보수가 어려워졌다. 기획이 조금이라도 바뀌는 순간 공통 컴포넌트를 계속 수정해야 했으며, props의 수가 과도하게 증가했다.

 

3. 좋은 타이밍: 타계열사와의 협업

해당 컴포넌트에 대한 리팩터링의 필요성을 간절하게 느끼던 차에 타계열사와의 협업을 진행하게 되었고, 두 프로젝트를 합쳐서 새로운 프로젝트를 만드는 수준의 협업이 되면서 리팩터링을 할 수밖에 없는 기회가 생겼다. 언뜻 들으면 일을 다시 해야 하는 어려운 상황일 수도 있었겠지만 오히려 기회라는 생각이 드는 상황이었다.

 

 

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

 

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

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

`<form>` 태그를 사용하여 구현하며, form을 사용하는 모든 페이지에서 react-hook-form의 공통 로직을 작성한다.

  • 장점: 유연한 대응 가능, Form을 잘못 만들었을 경우를 고려하지 않아도 됨
  • 단점: react-hook-form의 동일한 설정도 페이지마다 반복적으로 세팅 필요
// 생성 페이지
import { yupResolver } from "@hookform/resolvers/yup";
import { useForm } from "react-hook-form";
import { schema } from "./schema";

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

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

  return (
    <>
      <div>생성 페이지 예시</div>
      <form onSubmit={handleSubmit(onSubmit ?? ((data) => console.log(data)))} >
		{* input 및 button 등 추가 *}
      </form>
    </>
  );
};

 

2) 공통 컴포넌트를 별도로 구현하여 사용할 경우

React-hook-form의 FormProvider를 사용한 Form component를 구현하며, 필요한 속성은 props로 넘기고 input 등은 children으로 입력하도록 한다.

  • 장점: 구현을 잘 해놓을 경우 반복적인 코드 감소, 휴먼 에러 감소(기본값 지정 가능 등으로)
  • 단점: 구현을 잘못할 위험성 있음, FormProvider 사용으로 depth가 깊어질 경우 렌더링 최적화를 고려해야 할 수 있음
// 공통 컴포넌트로 구현한 Form.tsx

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

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: yupResolver(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 CreatePage = () => {
  const onSubmit = (data) => console.log("formtest-submit!!: ", data);

  return (
    <>
      <div>생성 페이지 예시</div>
      <Form
        onSubmit={onSubmit}
        schema={schema}
        defaultValues={{ name: "내이름" }}
      >
				{* input 및 button 등 추가 *}
      </Form>
    </>
  );
};

 

 

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

1) html 태그만 사용하기

  • 장점: 유연성 극대화
  • 단점: 코드 재사용성이 너무 떨어져서 너무 길어짐

 

고민 1의 첫 번째 상황: 공통 컴포넌트 구현하지 않고, <form> 태그를 사용하는 경우

 

2-1) css만 적용된 input component 사용

  • 장점: 유연성 높은 편
  • 단점: register에 넘겨야 하는 값을 반복적으로 기입해야 해서 휴먼 에러 발생 가능성 높아짐, 코드 길어짐
    - render props 패턴을 사용하는 방안도 함께 검토했으나 ref를 고려해야 하는 등의 어려움이 있었음

2-2) useFormContext로 wrapping 한 Input component 구현

  • 장점: register, errors 등을 일일이 지정하지 않아도 됨
  • 단점: wrapping 한 컴포넌트를 별도로 만들어야 함

 

고민 1의 두 번째 상황: 공통 컴포넌트인 Form 컴포넌트를 구현하여 사용하는 경우

 

3-1) useFormContext로 wrapping 한 Input component 구현 
기본적으로 <form> 태그를 사용한 방법의 b와 같은 경우로 같은 장/단점을 가짐

  • 추가 장/단점은 Form 컴포넌트를 사용하는 장/단점이 추가됨
  • 또한 regisger, errors 전달을 input이나 error message마다 할 필요가 없어지는 장점 있음

3-2) 합성 컴포넌트 구현

useFormContext와 react의 ContextApi를 사용하여 합성 컴포넌트 구현

  • 장점: 유연한 대응 가능, 코드 반복 사용 감소 (register뿐만 아니라 id도 상위에서만 한번 사용하면 됨)
  • 단점: context api 사용으로 리렌더링 최적화 필요, input 종류에 따른 컴포넌트 구현 필요

 

4) radix-ui 사용

radix-ui의 radix-form 사용

  • 장점: radix가 합성 컴포넌트 구조를 사용하기에 유연한 대응이 가능하며, 직접 구현한 컴포넌트보다 안정적일 수 있음
  • 단점: preview 버전으로 변동 가능성 있으며, 프로젝트에 비해 과한 기능이 있을 수 있음

 

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

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

 

 

...

 

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


Ref.

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

 

 

 

댓글