React Hook Form INP 3,912ms 97% 성능 개선기 🚀

wha1e·2025년 10월 16일

TIL

목록 보기
10/13
post-thumbnail

🤔 만나게 된 이슈

AI 도구를 동적으로 제작하는 페이지를 구현하던 중, 처음에는 기능 구현에만 집중했습니다. 그러다 문득 "이거 좀 느린데?"라는 느낌이 들었고, Chrome 개발자 도구의 Lighthouse를 통해 성능을 측정해 보았습니다. 결과는 예상보다 훨씬, 처참했습니다.

Interaction to Next Paint (INP) 2,800ms

4배 CPU 속도 저하 환경이라고는 하지만, 거의 4초에 육박하는 INP 수치는 명백한 '사용 불가' 수준이었습니다. 사용자가 입력 필드에 글자 하나를 치거나, 질문 블록의 순서를 바꿀 때마다 3초 동안 화면이 멈춘다는 의미였죠.


🤬 문제 상황

느리게 만드는 범인으로 의심되는 곳은 동적으로 질문 블록을 추가하고, 순서를 바꾸고, 내용을 입력하는 메인 편집 영역이었습니다. 초기 코드는 react-hook-formwatch를 사용하여 매우 직관적으로 작성되어 있었습니다.

다음은 문제가 되었던 부모 컴포넌트의 렌더링 코드 일부입니다.

// EditAiApp.tsx (수정 전)

const EditAiApp = ({ appId }: Props) => {
  const {
    register,
    watch, // 폼 전체를 구독하는 watch 함수
    errors, // 폼 전체의 에러 객체
    // ...
  } = useAiAppMakeForm({ appId });

  return (
	  ...
    <section>
      {/* 🤬 문제의 핵심: watch로 전체 배열을 렌더링 */}
      {watch('inputs').map((input, index) => (
        <InputItemBlock
          key={input.id}
          // 🤬 자식에게 모든 것을 넘겨주는 구조
          register={register}
          watch={watch}
          errors={errors}
        />
      ))}
    </section>
    ...
  );
};

표면적으로는 전혀 문제가 없어 보였습니다. react-hook-formuseFieldArray를 사용하도록 이미 1차 수정을 마친 상태였고, key도 클라이언트 단에서 자체적으로 생성해서, 메모이제이션을 활용하고 있었죠.

하지만 글자 하나만 입력해도 모든 것이 멈추는 이 현상의 원인은 대체 무엇이었을까요?


🧨 원인

문제의 핵심 원인은 렌더링 이었습니다. 자식 컴포넌트의 아주 작은 변화가 부모를 리렌더링시키고, 그 부모가 다시 모든 자식을 리렌더링시키는 연쇄 작용이 일어나고 있었습니다.

그냥 서로 렌더링하라고 겁나 때리고 있었던거임..

  1. watch: 부모 컴포넌트에서 watch('inputs')를 사용한 것이 가장 큰 패착이었습니다. watch는 구독하는 데이터의 아주 작은 변화에도 부모 컴포넌트 전체를 리렌더링시킵니다.
    → 즉, 100개의 질문 중 단 하나의 input에 글자 하나만 입력해도, EditAiApp 컴포넌트가 통째로 다시 그려졌습니다.

  2. React.memo 무력화: 자식 컴포넌트(InputItemBlock)를 React.memo로 감싸 최적화를 시도했지만 소용없었습니다. 부모가 리렌더링될 때마다 errors 객체와 watch 함수는 새로운 참조(메모리 주소)를 가진 채로 자식에게 전달되었습니다.
    React.memo는 "어? props가 새로운 주소값을 가졌네? 내용물은 같아도 일단 다른 걸로 간주하고 리렌더링해야지!"라고 판단하여 모든 최적화를 건너뛰었습니다.

  3. 값비싼 브라우저 렌더링 (Reflow): 리액트의 리렌더링 문제를 해결한 뒤에도 AiPromptSectionTextareaAutosize 컴포넌트가 발목을 잡았습니다.
    이 컴포넌트는 글자가 입력될 때마다 높이를 다시 계산하는데, 이 과정에서 페이지 전체 레이아웃을 다시 계산하는 리플로우(Reflow)가 발생했습니다. 이는 브라우저에게 매우 부담스러운 작업으로, 타이핑마다 INP를 높이는 주범이었습니다.


🛠 그렇다면 어떻게 해결해야 하는가?

"개별 구독제로 바꿔.."

이것이 해결의 핵심이었습니다. react-hook-form이 제공하는 control 객체를 통해 각 컴포넌트가 필요한 상태만 격리하여 구독하도록 구조를 완전히 뜯어고쳤습니다..!

1단계: 부모 컴포넌트 - watch 제거 및 fields 사용

부모는 더 이상 watch('inputs')로 렌더링하지 않고, useFieldArray가 제공하는 최적화된 fields 배열로 렌더링합니다. fields 배열은 오직 항목의 추가/삭제/이동 같은 구조적 변경 시에만 업데이트됩니다.

❌ 잘못된 코드

// EditAiApp.tsx (수정 전)
{watch('inputs').map((input, index) => (
  <InputItemBlock watch={watch} errors={errors} ... />
))}

✅ 수정된 코드

// EditAiApp.tsx (수정 후)
{fields.map((field, index) => (
  <InputItemBlock control={control} ... />
))}

2단계: 자식 컴포넌트 - control로 필요한 상태만 구독

자식 컴포넌트는 이제 watcherrors prop 대신 control 객체를 받습니다. 그리고 useWatch, useController, useFormState 같은 훅을 사용해 자기 자신에게 필요한 상태만 구독합니다.

❌ 잘못된 코드

// ShortInput.tsx (수정 전)
const ShortInput = ({ index, register, errors, watch }: Props) => {
  return (
    <>
      {/* watch로 값을 읽고 errors로 에러를 표시 */}
      <Checkbox checked={watch(`inputs.${index}.required`)} ... />
      {errors.inputs?.[index]?.title && <p>...</p>}
    </>
  );
};

✅ 수정된 코드

// ShortInput.tsx (수정 후)
import { useController, useFormState, Control } from 'react-hook-form';

const ShortInput = ({ index, register, control }: Props) => {
  // 'required' 필드만 구독
  const { field } = useController({ name: `inputs.${index}.required`, control });
  // 이 컴포넌트의 에러만 구독
  const { errors } = useFormState({ name: `inputs.${index}`, control });

  return (
    <>
      <Checkbox checked={field.value} onToggle={() => field.onChange(!field.value)} />
      {errors.inputs?.[index]?.title && <p>...</p>}
    </>
  );
};

아직 한 발 남았다 🔫 (성능 개선 결과 확인)

1. React와 브라우저를 구분하자 🧠

리액트 최적화는 끝이 아니었습니다. memo, useCallback, useWatch 등으로 모든 리렌더링 폭풍을 잠재웠음에도 AiPromptSection에서 여전히 높은 INP가 측정되었습니다.

원인은 브라우저에 있었습니다. 범인은 바로 자동 높이 조절 기능을 제공하는 TextareaAutosize 컴포넌트였죠.

이 컴포넌트는 타이핑할 때마다 자신의 높이를 바꾸고, 이로 인해 페이지 전체의 레이아웃을 재계산하는 리플로우(Reflow)를 유발했습니다.

  • React의 리렌더링: 가상 DOM(Virtual DOM) 레벨의 문제입니다. 불필요한 컴포넌트 업데이트를 막는 것이 핵심입니다.
  • 브라우저의 렌더링 (Reflow/Repaint): 실제 DOM 레벨의 문제입니다. 요소의 크기나 위치가 바뀌어 브라우저가 화면을 다시 그리는 비용입니다.

리액트 최적화가 완벽해도, 브라우저에게 "이 페이지 전체를 다시 그려!"라는 값비싼 명령을 계속 내린다면 성능은 결코 좋아질 수 없다는 것을 깨달았습니다. 결국 과감히 TextareaAutosize를 일반 <textarea>로 교체하고 나서야 비로소 성능을 잡을 수 있었습니다.

(디자인에서 협의를 거쳤습니다 흑흑…)

2. 개발 환경을 믿지 말자 🏃‍♂️💨

모든 최적화를 마친 후에도, 개발 환경(npm run dev)에서는 x4 Slowdown기준으로 여전히 600ms대의 INP가 측정되었습니다. 진짜 진짜 마지막으로 한 가지를 더 확인해 보기로 했습니다.

바로 프로덕션 빌드입니다.

# 1. 프로덕션용으로 앱을 빌드합니다.
npm run build

# 2. 빌드된 앱을 프로덕션 모드로 실행합니다.
npm run start

다행히, 프로덕션 환경에서는 모든 상호작용이 150ms 이내로 측정되었습니다.

그 이유는 다음과 같습니다.

  • React Strict Mode: 개발 환경에서는 잠재적 문제를 찾기 위해 의도적으로 컴포넌트를 두 번씩 렌더링합니다.
  • 최적화 부재: 개발 빌드에는 코드 압축, 트리 쉐이킹 등이 적용되지 않고, 수많은 디버깅용 코드가 포함되어 있어 본질적으로 느립니다.
  • 개발 서버 오버헤드: HMR(Hot Module Replacement) 등 개발 편의성을 위한 기능들이 추가적인 부하를 유발합니다.

📚 배운 점 및 참고 문헌

이번 성능 개선 여정을 통해, react-hook-form을 "그냥 사용하는 것"과 "제대로 사용하는 것"의 차이를 뼈저리게 느낄 수 있었습니다.

그 외 배운점들은 아래와 같습니다.

  • watch 대신 useFieldArrayfields를 쓰자 : 동적 배열 폼을 렌더링할 때는 useFieldArray가 반환하는 fields 배열을 사용해야 최적화가 가능합니다.
  • 상태 구독은 최소 단위로: control 객체를 통해 useWatch, useController, useFormState 훅으로 각 컴포넌트가 필요한 상태만 정확히 구독해야 합니다.
  • 원인을 브라우저에서도 찾자: React의 리렌더링 최적화가 끝나도 성능 문제가 지속된다면, 원인은 브라우저의 값비싼 렌더링 작업(Reflow나 Repaint)일 가능성이 높습니다.
  • 프로덕션 빌드를 하자 : 최종 성능은 반드시 프로덕션 빌드 (npm run build && npm run start)로 측정해야 합니다. 개발 환경의 성능은 실제 사용자 경험과 크게 다릅니다.

🔗 참고 자료

profile
상상을 현실로 만드는 FE

0개의 댓글