복잡한 형태의 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/
'React' 카테고리의 다른 글
디자인 패턴을 활용한 Form 컴포넌트 리팩터링: 유연성과 재사용성 모두 확보하기(최종) (0) | 2025.02.16 |
---|---|
i18next-react와 Google Sheets를 활용한 국제화(i18n) 자동화 도입기(2) - i18next적용하기(namespace 구분하기) (3) | 2024.10.27 |
i18next-react와 Google Sheets를 활용한 국제화(i18n) 자동화 도입기(1) - intro/도구 선정 (4) | 2024.09.18 |
[원티드 챌린지] 리액트 테스트와 최적화 (0) | 2023.07.26 |
댓글