최근 프론트엔드 생태계의 가장 큰 화두는 단연 React Compiler입니다.
하지만 새로운 기술을 프로젝트에 도입할 때 단순히 "좋다니까 쓴다"는 접근보다는, 이 도구가 우리가 겪던 어떤 '인지적 부하(Cognitive Load)'와 엔지니어링 한계를 해결해 주는지 명확히 이해하는 것이 중요합니다.
React Compiler를 기존 프로젝트에 성공적으로 안착시키기 위한 첫 단추로, 오늘은 React의 렌더링 원리와 전통적인 수동 최적화 기법들의 명암을 딥다이브 해보겠습니다.
React에서 컴포넌트가 리렌더링을 트리거하는 조건은 명확합니다.
1. 자신의 State가 변경될 때
2. 부모 컴포넌트가 렌더링될 때
3. 구독하고 있는 Context의 값이 변경될 때
이 중에서 고도화된 웹 애플리케이션을 개발할 때 가장 빈번하게 마주하는 성능 병목은 '부모 컴포넌트가 렌더링되면서 전달하는 Props의 참조값(Reference)이 변경될 때'입니다.
JavaScript에서 객체나 함수는 렌더링될 때마다 새로운 메모리 주소를 할당받습니다. 내부 값이 완전히 동일하더라도 React는 이를 '변경된 Prop'으로 인식하여 불필요한 하위 컴포넌트의 렌더링을 유발합니다.
memo, useMemo, useCallback이러한 불필요한 렌더링을 막기 위해 우리는 지금까지 세 가지 핵심 도구를 조합하여 사용해 왔습니다.
import React, { useState, useMemo, useCallback } from 'react';
interface ChildProps {
data: { name: string };
onClick: () => void;
}
// 1. React.memo: Props가 변경될 때만 리렌더링
const ChildComponent = React.memo(({ data, onClick }: ChildProps) => {
console.log('Child Rendered');
return <button onClick={onClick}>{data.name}</button>;
});
const ParentComponent = () => {
const [count, setCount] = useState(0);
// 2. useMemo: 고비용 연산 결과나 객체의 참조값 유지
const data = useMemo(() => ({ name: 'React' }), []);
// 3. useCallback: 함수의 참조값 유지
const handleClick = useCallback(() => {
console.log('Clicked');
}, []);
return (
<div className="p-4">
<p>Count: {count}</p>
<button onClick={() => setCount(prev => prev + 1)}>증가</button>
{/* data나 handleClick이 매번 새로 생성된다면 React.memo는 무용지물이 됨 */}
<ChildComponent data={data} onClick={handleClick} />
</div>
);
};
이 패턴은 성능 최적화에 강력하지만 치명적인 단점이 있습니다. 바로 개발자의 인지적 부하가 급증한다는 것입니다.
의존성 배열(deps)에 값을 누락하면 즉시 '오래된 클로저(Stale Closure)' 버그로 이어지고, 불안한 마음에 모든 곳에 메모이제이션을 남발하면 오히려 메모리 오버헤드가 발생하여 초기 렌더링 성능을 깎아먹게 됩니다.
메모이제이션 훅에 의존하기 전에, 근본적인 구조 개선(Architecture Modernization)을 통해 렌더링을 최적화할 수 있습니다.
상태를 최상단에 몰아두지 않고, 최대한 해당 상태를 사용하는 하위 컴포넌트와 가깝게 배치하는 기법입니다. 상태가 변경되는 범위를 국소적으로 제한하여 렌더링 트리의 오염을 막습니다.
children Props 패턴을 통한 렌더링 격리무거운 컴포넌트를 부모 컴포넌트 내부에서 직접 렌더링하지 않고, children으로 넘겨받아 렌더링하면 부모의 상태가 변하더라도 children으로 전달된 컴포넌트는 리렌더링되지 않습니다.
React의 기본 Context API는 상태의 파편화가 어렵고, 값이 변경될 때마다 하위 컴포넌트 전체를 렌더링시킵니다.
따라서 복잡한 전역 상태는 Zustand 등과 같이 Selector를 제공하는 라이브러리를 활용하여, 컴포넌트가 자신에게 필요한 상태만 정확히 구독(Subscribe)하도록 설계하는 것이 효율적입니다.
// Zustand를 활용한 선택적 상태 구독 예시
const userName = useUserStore((state) => state.user.name);
// user 객체의 다른 프로퍼티가 변경되어도 이 컴포넌트는 리렌더링되지 않음
앞서 살펴본 수동 메모이제이션 기법들은 프로젝트의 규모가 커지고 비즈니스 로직이 복잡해질수록 코드의 가독성을 심각하게 해칩니다.
React Compiler는 이 지점을 정확히 타격합니다.
코드를 빌드 타임에 분석하여 언제 컴포넌트를 리렌더링해야 할지, 언제 이전 값을 재사용할지 자동으로 결정합니다. 즉, 개발자가 머리를 싸매고 작성하던 useMemo와 useCallback의 역할을 컴파일러가 대신해 줍니다. 개발자는 온전히 UI 설계와 비즈니스 로직(Product Mindset)에만 집중할 수 있게 되는 것이죠.
"React Compiler는 단순히 성능을 올려주는 도구가 아니라, 프론트엔드 개발자의 DX(Developer Experience)를 근본적으로 혁신하는 패러다임 전환입니다."
수동 최적화의 한계와 비용을 뼈저리게 이해하고 나면, React Compiler가 가져다줄 변화가 얼마나 가치 있는지 체감할 수 있습니다.
이번 포스팅에서는 렌더링 최적화의 본질을 다루어 보았습니다. 다음 포스팅에서는 실제 프로덕션 환경(Next.js, TypeScript 기반)에 React Compiler를 점진적으로 마이그레이션하는 구체적인 전략과 도입 후의 지표 변화를 다루어 보겠습니다.