React Hook Form: #3 퍼포먼스 최적화 설계

ε( ε ˙³˙)з ○º·2025년 6월 29일
0
post-thumbnail

Intro

React Hook Form은 리렌더링을 최소화하기 위해 Uncontrolled 컴포넌트를 기반으로 설계된 라이브러리다. 하지만 복잡한 폼을 개발하다 보면 의도하지 않은 리렌더링과 구독 누락으로 인한 퍼포먼스 병목을 쉽게 마주하게 된다.

React Hook Form의 불필요한 렌더링을 줄이기 위한 구독 최적화와 퍼포먼스 설계 포인트를 정리해보았다.


formState 구독 누락, 언제 문제가 될까?

React Hook Form은 formState를 Proxy 객체로 감싸 컴포넌트가 실제로 참조한 속성만 구독하도록 설계되어 있다. 이러한 구조로 불필요한 상태 변화에 컴포넌트가 리렌더링되지 않지만 정확히 이해하지 않으면 실무에서 구독이 누락되는 케이스가 발생할 수 있다.

const { formState } = useForm();
const isDirty = formState.isDirty;
  • 이 코드는 formState를 구독하지 않는다. 리렌더링이 필요한 상태가 변경되더라도, 컴포넌트가 구독을 등록하지 않았기 때문에 화면이 업데이트되지 않는다.

React Hook Form은 formState를 구조 분해로 참조할 때만 구독이 활성화된다.

🔍 React Hook Form : formState

Returned formState is wrapped with a Proxy to improve render performance and skip extra logic if specific state is not subscribed to. Therefore make sure you invoke or read it before a render in order to enable the state update.

  • 반환된 formState는 렌더 성능을 개선하고 구독되지 않은 상태에 대해 불필요한 연산을 건너뛰기 위해 Proxy로 감싸져 있다. 따라서 상태 업데이트를 활성화하려면 렌더링 전에 반드시 해당 값을 호출하거나 읽어야 한다.
const { formState } = useForm();

if (formState.isDirty) { // ❌ 
  // ... 로직
}

const { formState: { isDirty } } = useForm();

if (isDirty) { // ✅ 구독 활성화
  // ... 로직
}
  • 구조 분해를 통해 직접 속성을 참조해야 Proxy가 해당 속성을 추적하고 상태 변경 시 해당 컴포넌트의 리렌더링이 정상적으로 동작한다.

useFormState vs useWatch

React Hook Form에서 필드 단위 상태 구독을 최적화할 때 주로 사용하는 API는 useFormStateuseWatch다. 두 훅 모두 구독 범위를 좁히고, 리렌더링을 최소화하는 데 사용되지만 동작 방식과 퍼포먼스 특성은 다르다.

useFormState: formState Proxy 기반 상태 구독

useFormStateformState 내부에서 특정 상태값만 필드 단위로 선택적으로 구독할 수 있도록 지원하는 훅이다.

const { isDirty } = useFormState({ control, name: ['username'] });
  • useFormState는 formState Proxy 객체를 통해 상태를 추적한다. Proxy로 감싼 상태라서, 구독하지 않은 속성의 변경은 리렌더링을 되지 않지만 구독한 formState 속성은 React Hook Form이 상태 변경을 감지할 때마다 컴포넌트를 리렌더링하도록 트리거한다.

useWatch: subscription model 기반 필드 값 단독 구독

useWatch는 formState를 전혀 통하지 않고 특정 필드의 value만 직접 구독하는 훅이다.

const username = useWatch({ control, name: 'username' });
  • useWatch는 React Hook Form의 subscription model을 이용해 필드 값을 구독한다. 이 subscription은 React Hook Form의 내부 observer 패턴을 통해 필드 단위로 최적화된 구독이 활성화된다.
    useWatch는 필드 값 변경 시, 해당 필드를 구독 중인 컴포넌트만 리렌더링되며 formState의 변경에는 전혀 반응하지 않는다.

🔍observer 패턴이란?
observer 패턴은 특정 상태를 여러 구독자(observer)가 감시하고 있다가 상태가 변경되면 구독자들에게 값이 바뀌었다고 알리는 설계 패턴이다.

React Hook Form은 useWatch로 필드 값을 구독한 컴포넌트를 내부적으로 observer 목록에 등록해두고 해당 필드 값이 바뀔 때 필드 값만 추적하고 구독한 컴포넌트에만 업데이트를 푸시한다.


React Hook Form + useMemo 조합

React Hook Form은 자체적으로 리렌더링 최적화가 잘 설계된 라이브러리지만 복잡한 폼이나 다이나믹 필드를 다룰 때는 React의 useMemo를 적극적으로 병행해야 퍼포먼스를 좋게 끌어올릴 수 있다.

특히 register로 input을 렌더링할 때, 불필요한 컴포넌트 리렌더링을 방지하는 구조를 설계하는 것이 핵심이다.

🔍 왜 useMemo가 필요할까?
React Hook Form은 register를 통해 input을 연결하면 기본적으로 Uncontrolled 방식으로 동작하여 입력 시 React state를 업데이트하지 않기 때문에 리렌더링은 매우 적다.

하지만 실무에서는 아래와 같은 상황에서 여전히 리렌더링이 발생할 수 있다.

  • 동일한 input을 여러 번 렌더링하는 경우 (다이나믹 폼 반복)
  • 부모 컴포넌트가 리렌더링될 때 input 컴포넌트가 함께 리렌더링되는 경우
  • input을 포함한 리스트가 매번 새롭게 생성되는 경우

이러한 상황에서는 input 컴포넌트를 메모이제이션하지 않으면, React가 input 컴포넌트를 새로운 컴포넌트로 인식하여 불필요한 리렌더링이 전파될 수 있다.

input 컴포넌트 React.memo 다이나믹 input 컴포넌트는 React.memo로 감싸서 리렌더링 범위를 더욱 제한하는 것이 좋다.

const InputField = React.memo(({ register, name, label }) => {
  console.log(`${name}`);

  return (
    <div>
      <label>{label}</label>
      <input {...register(name)} />
    </div>
  );
});
  • React.memo는 전달받은 props가 변경되지 않으면 컴포넌트를 리렌더링하지 않는다. register 함수는 React Hook Form이 내부적으로 안정적인 참조를 유지하기 때문에 대부분의 경우 register를 props로 전달해도 input 컴포넌트를 안전하게 메모이제이션할 수 있다.

useFieldArray 다이나믹 폼 최적화

다이나믹 input을 추가, 삭제하는 복잡한 폼을 개발할 때는 useFieldArray를 사용하는 것이 가장 안정적이며 React Hook Form에서 권장하는 방법이다.

하지만 useFieldArray는 불필요한 리렌더링을 자주 유발하며 특히 반복 input 렌더링, key 관리, 리렌더링 전파 구조를 제대로 설계하지 않으면 의도하지 않은 리렌더링이 과도하게 발생할 수 있다.

const { control, register } = useForm();
const { fields, append, remove } = useFieldArray({ control, name: 'users' });

{fields.map((field, index) => (
  <div key={field.id}>
    <input {...register(`users.${index}.name`)} />
    <button type="button" onClick={() => remove(index)}>삭제</button>
  </div>
))}

<button type="button" onClick={() => append({ name: '' })}>추가</button>
  • fields는 input 배열의 상태를 관리하며 field.id는 React Hook Form이 안정적으로 관리하는 고유 key 값으로 반드시 사용해야 한다. 배열 추가/삭제는 append, remove를 통해 안전하게 처리해야 한다.

🚨 자주 발생하는 리렌더링 병목

fields 배열 변경 시, 모든 input 리렌더링
useFieldArray는 배열에 아이템을 추가하거나 삭제할 때 fields 배열이 변경되고, map으로 렌더링된 모든 input 컴포넌트가 재생성되는 구조다.

특히 부모 컴포넌트의 상태가 변경될 경우 fields 전체가 다시 매핑되며 불필요한 리렌더링이 급격히 발생할 수 있다.

🧹 input 컴포넌트 React.memo 분리
다이나믹 input 컴포넌트는 반드시 React.memo로 감싸야 리렌더링 범위를 줄일 수 있다.

const DynamicInput = React.memo(({ register, name, onRemove }) => {
  return (
    <div>
      <input {...register(name)} />
      <button type="button" onClick={onRemove}>삭제</button>
    </div>
  );
});

{fields.map((field, index) => (
  <DynamicInput
    key={field.id}
    register={register}
    name={`users.${index}.name`}
    onRemove={() => remove(index)}
  />
))}
  • React.memo를 사용하면 배열 추가/삭제 시 변경된 컴포넌트만 리렌더링되고 기존 input 컴포넌트는 그대로 유지된다.

🧹 useWatch로 필드 값만 구독
formState를 사용하면 배열이 변경될 때 formState 전체를 다시 읽어야 하기 때문에 필드 값을 추적할 경우 useWatch로 필드만 직접 구독하는 것이 더 가볍다.

const userNames = useWatch({ control, name: 'users' });
  • useWatch를 사용하면 특정 필드 값만 구독하여 리렌더링을 고립시킬 수 있다. formStateisDirty, errors처럼 전역 상태를 구독하는 구조보다 퍼포먼스가 더 안정적이다.

🧹 key 관리 주의
map을 사용할 때 반드시 React Hook Form이 제공하는 field.id를 key로 사용해야 한다.

{fields.map((field, index) => (
  <DynamicInput
    key={field.id} // 반드시 field.id 사용
    register={register}
    name={`users.${index}.name`}
    onRemove={() => remove(index)}
  />
))}
  • index를 key로 사용하면 배열 추가/삭제 시 input이 잘못 매칭되는 문제가 발생할 수 있다. 특히 removeappend가 빈번한 폼일수록 key 관리가 매우 중요하다.

💭 마무리하며

React Hook Form은 기본적으로 리렌더링을 최소화하도록 설계된 라이브러리지만 구조를 어떻게 설계하느냐에 따라 여전히 불필요한 리렌더링이 충분히 발생할 수 있다.

부모, 자식 컴포넌트 간 렌더링 전파, formState 구독 방식, useWatch 사용 패턴, key 관리 같은 실무에서 자주 놓치는 부분을 잘 설계해야 퍼포먼스를 안정적으로 유지할 수 있다. 💪🏻


📚 Reference


이 글은 공식 문서를 기반으로 내용을 정리한 포스팅입니다.
혹시 내용 중 틀린 부분이나 보완할 부분이 있다면 댓글로 남겨주시면 감사하겠습니다. 🙏🏻

profile
console.log(🐛🔨🧐🍀)

0개의 댓글