📚 CS/React

[React] React Hook Form으로 의존성 관리하기

dev.daisy 2025. 12. 15. 21:17
React Hook Form을 사용해 직접 구현해보면서 가장 인상 깊었던 부분은, 폼을 구성하는 컴포넌트들이 더 이상 서로의 내부를 알 필요가 없어진다는 점이었습니다. 이전에는 Input 하나를 수정할 때도 상위 폼의 로직이나 상태 구조를 함께 고려해야 했지만, 컨텍스트 기반 구조에서는 각 컴포넌트가 자신의 역할에만 집중할 수 있었습니다. 또한 유효성 검사 로직을 Zod 스키마로 한 곳에 모으면서, “이 폼이 어떤 데이터를 받고 어떤 규칙을 가지는지”를 코드 한 파일에서 명확하게 파악할 수 있게 되었습니다.

이 패턴이 모든 폼에 정답은 아닐 수 있지만, 입력 필드가 많고 요구사항 변경이 잦은 폼이라면 충분히 적용해볼 만한 구조라고 느꼈습니다. 단순히 React Hook Form을 사용하는 것을 넘어 의존성을 어떻게 관리할 것인지 고민하는 계기가 되었고, 앞으로 폼을 설계할 때 구조부터 먼저 생각하게 되는 경험을 할 수 있었습니다.

복잡한 폼을 개발하다 보면 컴포넌트 간의 결합이 점점 강해지거나, 특정 상황에서만 동작하는 구조가 되는 경우가 많습니다. 폼 로직이 Input 컴포넌트 내부 깊숙이 들어가 있거나, 여러 컴포넌트가 서로의 내부 상태를 알아야만 정상적으로 동작하는 구조는 시간이 지날수록 유지보수를 어렵게 만듭니다.

 

이 문제의 중심에는 컴포넌트 의존성이라는 개념이 있습니다. 하나의 컴포넌트가 다른 컴포넌트나 특정 데이터 구조, 비즈니스 로직에 강하게 묶여 있을수록 코드는 읽기 어려워지고, 수정 범위는 넓어지며, 재사용 가능성은 낮아집니다.


왜 컴포넌트 의존성을 고민해야 할까?

컴포넌트 개발에서 의존성이란 하나의 컴포넌트가 다른 컴포넌트, 특정 데이터 형태, 혹은 특정 로직에 기대어 동작하는 상태를 의미합니다. 의존성이 높은 구조에서는 작은 변경 하나가 연쇄적인 수정으로 이어지기 쉽고, 컴포넌트의 책임 또한 불분명해집니다.

 

반대로 컴포넌트가 자신의 역할에만 집중하도록 설계되면, 코드를 읽고 이해하기 쉬워지고 수정 범위도 자연스럽게 제한됩니다. 특정 화면에 묶이지 않은 컴포넌트는 다른 곳에서도 재사용할 수 있고, 외부 영향이 적기 때문에 테스트 코드 작성 또한 수월해집니다. 결국 의존성을 낮추는 일은 단순한 코드 정리가 아니라, 유지보수성과 확장성을 갖춘 소프트웨어를 만드는 핵심적인 원칙이라고 볼 수 있습니다. React Hook Form은 이러한 의존성 문제를 해결하는 데 도움을 주는 라이브러리입니다.


React Hook Form의 기본 개념과 Context API

React에서 useState를 사용해 폼 상태를 관리하면, 입력할 때마다 상태가 변경되면서 불필요한 리렌더링이 발생하기 쉽습니다. 입력 필드가 많아질수록 이 비용은 더 커지고, 성능 저하로 이어질 수 있습니다.

 

React Hook Form은 register 함수를 통해 네이티브 입력 요소의 ref를 직접 참조하는 비제어 컴포넌트(Uncontrolled Component) 방식을 사용합니다. 이 방식에서는 입력 중에는 리렌더링이 발생하지 않고, 검증 오류가 발생하거나 폼을 제출하는 시점처럼 DOM이 변경되는 순간에만 리렌더링이 일어납니다. 

 

하지만 폼이 복잡해지고 컴포넌트 구조가 깊어지면 문제가 발생하는데, 최상위에서 호출한 useForm의 반환값인 register, handleSubmit 등을 여러 단계에 걸쳐 내려보내야 하는 props drilling이 생깁니다.

function MyForm() {
  const { register, handleSubmit } = useForm();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <FormField register={register} />
    </form>
  );
}

function FormField({ register }) {
  return <MyInput register={register} />;
}

function MyInput({ register }) {
  return <input {...register("my_field")} />;
}

 

이 구조에서는 폼 로직이 컴포넌트 트리를 따라 계속 전달되며, 중간 컴포넌트들은 실제로 사용하지 않는 props를 단순히 전달만 하게 됩니다. 이를 해결하기 위해 React Hook Form은 Context API를 기반으로 한 FormProvideruseFormContext를 제공합니다.

 

FormProvider는 useForm에서 반환된 값들을 Context로 감싸 하위 컴포넌트 트리에 제공합니다. useFormContext는 하위 컴포넌트에서 이 컨텍스트에 직접 접근할 수 있도록 도와주는 훅입니다. 이 두 API를 사용하면 컴포넌트 깊이에 상관없이 props drilling 없이 폼 상태와 메서드에 접근할 수 있습니다. 이 구조가 컴파운드 컴포넌트 패턴을 적용하기 위한 기반이 됩니다.


컴파운드 컴포넌트 패턴으로 폼 구조 정리하기

컴파운드 컴포넌트 패턴은 여러 컴포넌트가 암묵적으로 상태를 공유하면서 하나의 기능을 완성하는 구조를 말합니다. HTML의 <select>와 <option> 태그 관계를 떠올리면 이해하기 쉽습니다.

 

React Hook Form에서는 부모 폼 컴포넌트가 FormProvider를 통해 상태와 로직을 제공하고, 자식 Input이나 SelectBox 컴포넌트가 useFormContext를 통해 이를 소비하는 구조가 됩니다. 각 컴포넌트는 독립적으로 동작하지만, 전체 폼의 일부로 자연스럽게 연결됩니다.

이 패턴을 적용하면 폼 내부 구현을 알 필요 없이 선언적으로 폼을 구성할 수 있고, 각 입력 컴포넌트는 특정 폼에 종속되지 않은 상태로 재사용할 수 있습니다.


제네릭을 활용한 HookForm 컴포넌트 설계

폼의 상태와 로직을 관리하는 부모 컴포넌트는 다음과 같이 설계할 수 있습니다.

import { DefaultValues, FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

interface HookFormProps<T> {
  children: React.ReactNode;
  onSubmit?: (data: T) => Promise<any>;
  className?: string;
  schema?: z.ZodType<T>;
  defaultValues?: Partial<T>;
}

const HookForm = <T extends {}>({
  children,
  onSubmit,
  className,
  schema,
  defaultValues,
}: HookFormProps<T>) => {
  const methods = useForm<T>({
    resolver: schema ? zodResolver(schema) : undefined,
    defaultValues: defaultValues as DefaultValues<T>,
  });

  const submit: SubmitHandler<T> = async (data) => {
    await onSubmit?.(data);
  };

  return (
    <FormProvider {...methods}>
      <form className={className} onSubmit={methods.handleSubmit(submit)}>
        {children}
      </form>
    </FormProvider>
  );
};

export default HookForm;

 

이 컴포넌트는 제네릭 <T>를 사용해 특정 폼 타입에 의존하지 않도록 설계되었습니다. schema와 onSubmit을 외부에서 주입받는 구조로 만들어, 폼 컴포넌트가 특정 API 호출이나 유효성 검사 로직에 직접 의존하지 않도록 했습니다. FormProvider를 통해 하위 컴포넌트는 모두 동일한 폼 컨텍스트를 공유하게 됩니다.


컨텍스트를 소비하는 Input 컴포넌트

Input 컴포넌트는 더 이상 register를 props로 전달받지 않습니다. 대신 useFormContext를 사용해 필요한 메서드를 직접 가져옵니다.

import { useFormContext } from 'react-hook-form';

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  name: string;
}

const Input = ({ label, name, ...rest }: InputProps) => {
  const { register } = useFormContext();

  return (
    <div>
      <label htmlFor={name}>{label}</label>
      <input id={name} {...register(name)} {...rest} />
    </div>
  );
};

export default Input;

 

이 구조 덕분에 Input 컴포넌트는 특정 폼의 구조나 로직을 전혀 알 필요가 없는 완전히 독립적인 UI 컴포넌트가 됩니다.


커스텀 SelectBox와 setValue 활용

네이티브 input이 아닌 커스텀 컴포넌트는 register를 직접 사용할 수 없습니다. 이런 경우 useFormContext에서 제공하는 setValue를 사용해 폼 상태를 수동으로 동기화할 수 있습니다.

 

선택이 변경될 때 UI 상태는 로컬 상태로 관리하고, 동시에 setValue를 호출해 React Hook Form의 상태를 업데이트하면 됩니다. 이렇게 하면 커스텀 컴포넌트도 폼의 검증과 제출 흐름에 자연스럽게 포함됩니다.


Zod를 활용한 유효성 검사 분리

유효성 검사 로직을 각 Input 컴포넌트 내부에 흩어두면 관리가 어려워집니다. Zod를 사용하면 모든 검증 규칙을 하나의 스키마로 정의할 수 있고, z.infer를 통해 타입까지 함께 추론할 수 있습니다.

export const signupSchema = z.object({
  id: z.string().min(1, '아이디는 필수입니다'),
  password: z.string().min(1, '비밀번호는 필수입니다'),
  email: z.string().email('이메일 형식이 아닙니다'),
});

export type SignupFormType = z.infer<typeof signupSchema>;

 

이렇게 정의된 스키마는 useForm 초기화 시 resolver로 연결되어 폼 전반에 적용됩니다.

회원가입 폼 구성하기

앞서 만든 요소들을 조합하면 다음과 같이 선언적인 회원가입 폼을 구성할 수 있습니다.

<HookForm schema={signupSchema} onSubmit={handleSignup}>
  <HookForm.Input name="id" label="아이디" />
  <HookForm.Input name="password" label="비밀번호" />
  <HookForm.Input name="email" label="이메일" />
  <button type="submit">가입하기</button>
</HookForm>

 

폼 내부 구현을 알 필요 없이, 구조만 보고도 어떤 폼인지 파악할 수 있는 형태가 됩니다.


마무리

이번 글에서는 React Hook Form을 중심으로 복잡한 폼에서 발생하기 쉬운 컴포넌트 의존성 문제를 어떻게 구조적으로 해결할 수 있는지 정리해 보았습니다. FormProvider와 useFormContext를 통해 props drilling 없이 폼 상태를 공유하고, 컴파운드 컴포넌트 패턴을 적용해 컴포넌트 간 관계를 명확히 하면서도 각 입력 컴포넌트를 독립적으로 유지할 수 있었습니다. 여기에 Zod를 결합해 유효성 검사와 타입 정의를 UI로부터 분리함으로써, 폼 로직 · 검증 규칙 · UI 표현이 서로 얽히지 않는 구조를 만들 수 있었다는 점이 인상깊었습니다.

 

직접 구현해보면서 가장 인상 깊었던 부분은 폼을 구성하는 컴포넌트들이 더 이상 서로의 내부를 알 필요가 없어진다는 점이었습니다. 이전에는 Input 하나를 수정할 때도 상위 폼의 로직이나 상태 구조를 함께 고려해야 했지만, 컨텍스트 기반 구조에서는 각 컴포넌트가 자신의 역할에만 집중할 수 있었습니다. 또한 유효성 검사 로직을 Zod 스키마로 한 곳에 모으면서, “이 폼이 어떤 데이터를 받고 어떤 규칙을 가지는지”를 코드 한 파일에서 명확하게 파악할 수 있게 되었습니다.

 

이 패턴이 모든 폼에 정답은 아닐 수 있지만, 입력 필드가 많고 요구사항 변경이 잦은 폼이라면 충분히 적용해볼 만한 구조라고 느꼈습니다. 단순히 React Hook Form을 사용하는 것을 넘어 의존성을 어떻게 관리할 것인지 고민하는 계기가 되었고, 앞으로 폼을 설계할 때 구조부터 먼저 생각하게 되는 경험을 할 수 있었습니다.