React Compiler: 수동으로 React 앱을 최적화하는 것을 중단하세요
Source: Dev.to
우리 팀 KATA 세션 중에 동료가 이렇게 질문했어요. 여러분도 한 번쯤 생각해봤을 질문이죠:
“React가 이미 변경된 요소만 렌더링한다면, 왜 우리가 직접 최적화를 해야 할까요?”
훌륭한 질문이었습니다. 답변을 통해 우리가 수년간 겪어온 큰 고통점이 드러났고, React 컴파일러가 몇 가지 핵심 영역을 어떻게 해결하는지도 알 수 있었습니다.
아래는 레스토랑 주방이라는 간단한 비유를 통해 본 React 최적화의 진화 과정입니다.
🍝 레스토랑 주방: React가 실제로 동작하는 방식
앱을 주방에 비유해 보세요.
| 역할 | 비유 |
|---|---|
| Head Chef (Parent Component) | 주방 전체를 관리합니다. |
| Line Cooks (Child Components) | 각각의 작업 스테이션을 담당합니다. |
일반적인 React 앱에서는 Head Chef가 무언가를 바꿀 때마다—예를 들어 소금을 다시 채워 넣는 것만으로도—큰 종을 울립니다. 모든 요리사가 작업을 멈추고 다시 시작합니다, 비록 그들의 스테이션이 변하지 않았더라도.
React의 기본 동작: 부모가 다시 렌더링되면 모든 자식이 다시 렌더링됩니다.
수년 동안 우리는 React 최적화 엔진에 지시를 내리기 위해 추가 코드를(훅) 작성해야 했습니다. 이제 단일 컴포넌트가 어떻게 진화했는지 살펴봅시다:
- 훅 없이 (컴파일러에 대한 지시가 없는 상태)
- 훅 사용 (React 최적화 기법에 대한 지시)
- React 컴파일러 코드 (자동 최적화)
컴포넌트의 진화
우리는 다음과 같은 RestaurantMenu 컴포넌트를 사용할 것입니다.
- 요리 목록을 보유합니다.
- (비싼 연산인) 필터링을 수행합니다.
- 아이템 리스트(자식 컴포넌트)를 렌더링합니다.
Phase 1: 코드 (깨끗하지만 느림)
초보자들이 가장 많이 쓰는 코드입니다. 보기에는 깔끔해 보이지만 성능 함정이 숨어 있습니다.
import { useState } from 'react';
// A simple child component
const DishList = ({ dishes, onOrder }) => {
console.log('🍝 Rendering DishList (Child)'); // {/* items... */};
};
export default function RestaurantMenu({ allDishes, theme }) {
const [category, setCategory] = useState('pasta');
// ⚠️ PROBLEM 1: Expensive calculation runs on every render
const filteredDishes = allDishes.filter(dish => {
console.log('🧮 Filtering... (Slow Math)');
return dish.category === category;
});
const handleOrder = dish => {
console.log('Ordered:', dish);
};
return (
<>
{/* Clicking this causes a re‑render */}
<button onClick={() => setCategory('salad')}>Switch Category</button>
{/* ⚠️ PROBLEM 2: Inline arrow function */}
{/* (dish) => handleOrder(dish) creates a brand‑new function on each render,
forcing DishList to re‑render. */}
<DishList dishes={filteredDishes} onOrder={dish => handleOrder(dish)} />
</>
);
}
콘솔에 어떤 일이 일어날까요?
부모가 약간이라도 다시 렌더링될 때(예: 버튼 클릭) 다음이 출력됩니다.
🧮 Filtering... (Slow Math)
🍝 Rendering DishList (Child)
모든 상호작용이 두 문장을 모두 로그에 남깁니다—비효율적!
Phase 2: 훅을 이용한 해결책 (추가 지시사항)
이를 해결하기 위해 전통적으로 훅을 도입합니다: useMemo, useCallback, memo.
import { useState, useMemo, useCallback, memo } from 'react';
// Solution A: Wrap child in memo to prevent useless re‑renders
const DishList = memo(({ dishes, onOrder }) => {
console.log('🍝 Rendering DishList (Child)');
return /* items... */;
});
export default function RestaurantMenu({ allDishes, theme }) {
const [category, setCategory] = useState('pasta');
// Solution B: Cache calculation with useMemo
const filteredDishes = useMemo(() => {
console.log('🧮 Filtering... (Slow Math)');
return allDishes.filter(dish => dish.category === category);
}, [allDishes, category]);
// Solution C: Freeze function with useCallback
const handleOrder = useCallback(dish => {
console.log('Ordered:', dish);
}, []);
return (
<>
<button onClick={() => setCategory('salad')}>Switch Category</button>
{/* ⚠️ THE TRAP: We CANNOT use an inline arrow here!
If we wrote: onOrder={(dish) => handleOrder(dish)}
it would break the optimization because the wrapper creates a new reference.
We must pass the stable function directly. */}
<DishList dishes={filteredDishes} onOrder={handleOrder} />
</>
);
}
이제 콘솔에 어떤 일이 일어날까요?
filteredDishes나 handleOrder에 영향을 주지 않는 이유(예: theme 변경)로 부모가 다시 렌더링되면 아무 로그도 출력되지 않습니다.
(Silent. No logs appear.)
성능은 확보됐지만, 추가된 훅 보일러플레이트 때문에 코드 가독성이 떨어집니다.
Note: 팀원이
onOrder={handleOrder}를onOrder={() => handleOrder()}로 바꾸면 최적화가 조용히 깨집니다—화살표가 매 렌더마다 새로운 함수를 만들기 때문입니다.
Phase 3: React 컴파일러 솔루션 (추가 코드 없음)
이제 React 컴파일러(예: React 18의 자동 메모이제이션)를 도입합니다. 명시적인 훅 없이도 동일한 최적화를 추론할 수 있습니다.
import { useState } from 'react';
// No useMemo, no useCallback, no memo.
export default function RestaurantMenu({ allDishes, theme }) {
const [category, setCategory] = useState('pasta');
// The compiler automatically memoizes this expensive calculation.
const filteredDishes = allDishes.filter(dish => {
console.log('🧮 Filtering... (Slow Math)');
return dish.category === category;
});
// The compiler
automatically stabilizes this function reference.
```jsx
const handleOrder = dish => {
console.log('Ordered:', dish);
};
return (
<>
<button onClick={() => setCategory('salad')}>Switch Category</button>
{/* The child component can stay a plain function component. */}
<DishList dishes={filteredDishes} onOrder={handleOrder} />
</>
);
}
// Plain child component – no need for React.memo.
const DishList = ({ dishes, onOrder }) => {
console.log('🍝 Rendering DishList (Child)');
return /* items... */;
};
What happens now?
- filter는
allDishes또는category가 실제로 변경될 때만 실행됩니다. handleOrder함수는 렌더링 간에 안정적인 참조를 유지합니다.DishList는 props가 실제로 변경될 때만 다시 렌더링됩니다.
All of this is achieved without the manual useMemo, useCallback, or memo boilerplate.
TL;DR
| 단계 | 작성하는 내용 | 얻는 결과 |
|---|---|---|
| 1 – 일반 코드 | 간단하고 읽기 쉬운 코드 | 불필요한 재렌더링 및 비용이 많이 드는 작업 |
| 2 – Hook‑많은 | useMemo, useCallback, memo | 최적화는 되지만 코드가 복잡하고 오류가 발생하기 쉬움 |
| 3 – 컴파일러 | 다시 일반 코드 | 동일(또는 더 나은) 성능을 자동으로 제공 |
React 컴파일러를 사용하면 1단계의 명료성을 유지하면서 2단계의 성능을 얻을 수 있습니다. 이제 “매직” 인라인‑화살표 버그도, 수동 메모이제이션도 필요 없습니다—UI를 생각하는 방식대로 코드를 작성하고, 무거운 작업은 컴파일러가 처리하도록 하면 됩니다.
React 컴파일러 매직 – 레스토랑 비유
const handleOrder = (dish) => {
console.log("Ordered:", dish);
};
return (
<>
<button onClick={() => setCategory('salad')}>Switch Category</button>
{/* ✅ COMPILER MAGIC: We can use an inline arrow again!
The compiler is smart enough to "memoize" this arrow function
wrapper automatically. It sees that `handleOrder` is stable,
so it makes this arrow stable too. */}
<DishList dishes={filteredDishes} onOrder={dish => handleOrder(dish)} />
</>
);
콘솔에서 무슨 일이 일어나나요?
Even though we deleted all the hooks, the result is identical to Phase 2.
🖥️ CONSOLE OUTPUT:
---------------------------------------------
(Silent. No logs appear.)
방금 무슨 일이 일어난 건가요?
React 컴파일러가 빌드 시점에 코드를 분석했습니다. 데이터 흐름을 우리보다 더 잘 이해합니다.
filteredDishes는category가 바뀔 때만 변경된다는 것을 감지합니다.handleOrder를 화살표 함수(dish) => handleOrder(dish)로 감싼 것을 감지합니다.- 해당 화살표 함수 래퍼를 자동으로 캐시해 렌더링마다 동일한 레퍼런스를 유지하도록 합니다.
- 즉, 2단계에서 작성했던 최적화 코드를 백그라운드에서 자동으로 생성해 줍니다.
철학의 전환
수년 동안 우리는 프레임워크에 직접 지시해야 했습니다: “이 변수를 기억해! 이 함수를 고정해!”
React Compiler가 이 문제를 해결합니다!
React는 이제 최적화의 부담을 떠맡습니다. 렌더 사이클과 의존성 배열에 대한 걱정을 멈추고 실제로 중요한 일, 즉 기능 배포에 집중할 수 있게 해줍니다.
이제 뭐 해야 할까?
가장 좋은 점은 React Compiler가 하위 호환(React v17, v18도 지원)이라는 것입니다. 코드를 다시 작성할 필요가 없습니다. 그냥 활성화하면 기존 훅은 그대로 두고 “일반” 컴포넌트를 최적화해 줍니다.
읽어 주셔서 감사합니다! 이번이 Dev.to에 올리는 첫 글이며, Compiler에 대한 제 이해를 정리하기 위해 작성했습니다. 여러분의 피드백을 기다립니다—레스토랑 비유가 이해가 되셨나요? 댓글로 알려 주세요!