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

Interaction to Next Paint (INP) 2,800ms
4배 CPU 속도 저하 환경이라고는 하지만, 거의 4초에 육박하는 INP 수치는 명백한 '사용 불가' 수준이었습니다. 사용자가 입력 필드에 글자 하나를 치거나, 질문 블록의 순서를 바꿀 때마다 3초 동안 화면이 멈춘다는 의미였죠.
느리게 만드는 범인으로 의심되는 곳은 동적으로 질문 블록을 추가하고, 순서를 바꾸고, 내용을 입력하는 메인 편집 영역이었습니다. 초기 코드는 react-hook-form의 watch를 사용하여 매우 직관적으로 작성되어 있었습니다.
다음은 문제가 되었던 부모 컴포넌트의 렌더링 코드 일부입니다.
// 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-form의 useFieldArray를 사용하도록 이미 1차 수정을 마친 상태였고, key도 클라이언트 단에서 자체적으로 생성해서, 메모이제이션을 활용하고 있었죠.

하지만 글자 하나만 입력해도 모든 것이 멈추는 이 현상의 원인은 대체 무엇이었을까요?
문제의 핵심 원인은 렌더링 이었습니다. 자식 컴포넌트의 아주 작은 변화가 부모를 리렌더링시키고, 그 부모가 다시 모든 자식을 리렌더링시키는 연쇄 작용이 일어나고 있었습니다.
.png)
그냥 서로 렌더링하라고 겁나 때리고 있었던거임..
watch: 부모 컴포넌트에서 watch('inputs')를 사용한 것이 가장 큰 패착이었습니다. watch는 구독하는 데이터의 아주 작은 변화에도 부모 컴포넌트 전체를 리렌더링시킵니다.
→ 즉, 100개의 질문 중 단 하나의 input에 글자 하나만 입력해도, EditAiApp 컴포넌트가 통째로 다시 그려졌습니다.React.memo 무력화: 자식 컴포넌트(InputItemBlock)를 React.memo로 감싸 최적화를 시도했지만 소용없었습니다. 부모가 리렌더링될 때마다 errors 객체와 watch 함수는 새로운 참조(메모리 주소)를 가진 채로 자식에게 전달되었습니다.
React.memo는 "어? props가 새로운 주소값을 가졌네? 내용물은 같아도 일단 다른 걸로 간주하고 리렌더링해야지!"라고 판단하여 모든 최적화를 건너뛰었습니다.