React Compiler 캐시 슬롯 배정 규칙
TL;DR
- React Compiler는 소스에
useMemo를 추가하지 않는다. - 컴파일 산출물에는 dependency 비교와 결과 재사용을 위한 캐시 슬롯이 생긴다.
_c(n)의n은 해당 함수에서 필요한 캐시 슬롯 수다.- 기본 단위는
dependency 슬롯 + 산출물 슬롯이다. - 같은 계산이라도 scope가 분리되거나 분기 안에 놓이면 슬롯 수가 달라진다.
React Compiler 산출물을 보면 _c(1), _c(2), _c(4), _c(12) 같은 호출이 나온다. 숫자는 런타임에 커지는 배열 크기가 아니라, 컴파일러가 함수 안에서 필요하다고 판단한 캐시 슬롯 수다. 핵심 규칙은 간단하다.
캐시 슬롯 수 = reactive scope별 dependency 슬롯 + 산출물 슬롯
useMemo가 아니라 캐시 배열
수동 메모이제이션은 보통 useMemo로 쓴다.
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
React Compiler는 이 코드를 그대로 useMemo 호출로 바꾸지 않는다. 산출물은 이전 입력을 비교하고, 입력이 같으면 이전 결과를 꺼내 쓰는 형태에 가깝다.
let t0;
if ($[0] !== dep1 || $[1] !== dep2) {
t0 = compute(dep1, dep2);
$[0] = dep1;
$[1] = dep2;
$[2] = t0;
} else {
t0 = $[2];
}
위 scope에는 dependency 두 개와 산출물 하나가 있다. 그래서 세 칸이 필요하다.
| 슬롯 | 역할 |
|---|---|
| dependency | 다음 렌더에서 이전 입력과 비교하기 위한 값 |
| 산출물 | dependency가 같을 때 재사용할 결과 |
_c(1): dependency 없는 산출물
상수 JSX는 비교할 입력이 없다. 그래도 React element 객체를 매 렌더마다 새로 만들 필요가 없기 때문에 산출물 슬롯 하나가 생긴다.
function App() {
return <Text color="red" />;
}
단순화한 산출물은 다음과 같다.
function App() {
const $ = _c(1);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) { t0 = <Text color="red" />; $[0] = t0; } else { t0 = $[0]; }
return t0;
}
| dependency | 산출물 | 합계 |
|---|---|---|
| 0 | 1 | _c(1) |
_c(2): props 하나와 JSX 하나
props 하나로 JSX를 만들면 입력 슬롯 하나와 산출물 슬롯 하나가 필요하다.
function Text({ color }: { color: string }) {
return <span style={{ color }}>hello</span>;
}
function Text({ color }) {
const $ = _c(2);
let t0;
if ($[0] !== color) { t0 = <span style={{ color }}>hello</span>; $[0] = color; $[1] = t0; } else { t0 = $[1]; }
return t0;
}
| dependency | 산출물 | 합계 |
|---|---|---|
| 1 | 1 | _c(2) |
이 숫자는 “메모할 값이 두 개”라는 뜻이 아니다. 이전 color를 비교하기 위한 자리 하나와, 재사용할 JSX 자리 하나가 있다는 뜻이다.
_c(4): 계산 scope와 JSX scope
계산 결과를 만든 뒤 그 결과로 JSX를 만들면 scope가 두 개로 나뉠 수 있다.
function Label({ value }: { value: string }) {
const text = value.toUpperCase();
return <span>{text}</span>;
}
읽는 방식은 다음과 같다.
| scope | dependency | 산출물 | 슬롯 |
|---|---|---|---|
| 계산 | value |
text |
2 |
| JSX | text |
<span>{text}</span> |
2 |
| 합계 | _c(4) |
컴포넌트 하나가 곧 scope 하나인 것은 아니다. 컴파일러는 함수 안의 reactive scope를 나누고, 각 scope에 필요한 입력과 출력을 슬롯으로 배정한다.
_c(12): 분기별 슬롯 묶음
분기 안에 같은 구조가 반복되면 각 분기마다 슬롯 묶음이 생길 수 있다.
function FruitLabel({ fruit }) {
if (fruit === "strawberry") {
const text = fruit.toUpperCase();
return <span>{text}</span>;
}
if (fruit === "carrot") { const text = fruit.toUpperCase(); return <span>{text}</span>; }
const text = fruit.toUpperCase();
return <span>{text}</span>;
}
각 분기는 계산 scope 2칸, JSX scope 2칸을 가진다.
| 분기 | 계산 scope | JSX scope | 소계 |
|---|---|---|---|
| strawberry | 2 | 2 | 4 |
| carrot | 2 | 2 | 4 |
| fallback | 2 | 2 | 4 |
| 합계 | _c(12) |
그래서 캐시 인덱스가 0, 1 다음에 2, 3을 건너뛰고 4, 5로 이동하는 것처럼 보인다. 건너뛴 것이 아니라 첫 번째 분기의 JSX scope가 쓰는 자리다.
실제 산출물 확인
프로젝트에 React Compiler를 켜고 단순 컴포넌트를 컴파일하면 같은 구조가 나온다.
function Badge(t0) {
const $ = _c(2);
const { status } = t0;
let t1;
if ($[0] !== status) { t1 = <span data-badge={status}>{status}</span>; $[0] = status; $[1] = t1; } else { t1 = $[1]; }
return t1;
}
status dependency 하나와 JSX 산출물 하나가 있으므로 _c(2)가 된다. 전체 src를 컴파일해 수집한 결과도 같은 방향이었다.
| 항목 | 값 |
|---|---|
| 컴파일한 파일 | 386 |
| 슬롯이 생긴 파일 | 183 |
| 메모이제이션된 함수 | 433 |
| 슬롯 총합 | 7,690 |
| 함수당 슬롯 p50 / p90 / 최대 | 9 / 47 / 167 |
react-compiler-healthcheck 결과는 Successfully compiled 658 out of 658 components.였다. Rules of React 위반으로 스킵된 컴포넌트는 없었다.
scope 경계와 무효화 범위
슬롯 수보다 더 중요한 것은 scope 경계다. 같은 값이라도 어떤 scope에 묶였는지에 따라 함께 무효화되는 범위가 달라진다. 인라인 map으로 만든 리스트에서 무관한 state를 5회 토글했을 때, 카드 컴포넌트는 100번 다시 커밋됐다. 리스트 부분을 별도 컴포넌트로 분리하자 같은 조건에서 카드 커밋은 0번이었다.
| 시나리오 | 카드 OFF | 카드 ON |
|---|---|---|
| 인라인 map · 무관한 state 5회 토글 | 100 | 100 |
| 리스트 분리 · 무관한 state 5회 토글 | 100 | 0 |
산출물에서는 map 호출이 무관한 state와 같은 scope에 묶여 있었다.
if ($[0] !== items || $[1] !== panelOpen || $[2] !== selected) {
const visible = items.filter(_temp4).sort(_temp5);
let t4; if ($[9] !== selected) { t4 = item => <Card key={item.id} item={item} selected={selected === item.id} />; $[9] = selected; $[10] = t4; } else { t4 = $[10]; }
t1 = visible.map(t4);
}
콜백은 selected 기준으로 캐시되지만, visible.map(t4) 호출은 panelOpen과 같은 scope 안에 있다. panelOpen이 바뀌면 element 배열이 다시 만들어지고, memo()가 없는 자식은 다시 렌더된다. 따라서 React Compiler를 볼 때 질문은 “메모가 됐는가”가 아니라 “어떤 값들이 같은 reactive scope에 묶였는가”에 가깝다.
정리
_c(n)의 n은 컴파일러가 해당 함수 안에서 유지해야 한다고 판단한 캐시 슬롯 수다.
| 패턴 | 슬롯 |
|---|---|
| dependency 없는 JSX 산출물 1개 | _c(1) |
| dependency 1개 + 산출물 1개 | _c(2) |
| 2칸짜리 scope 2개 | _c(4) |
| 4칸짜리 분기 3개 | _c(12) |
캐시 슬롯은 이전 입력을 비교하기 위한 자리와 재사용할 결과를 보관할 자리로 구성된다. 숫자는 소스 구조, 분기, scope 분리·병합에 따라 정적으로 정해진다. React Compiler는 useMemo를 자동 삽입하는 도구라기보다, reactive scope별 입력과 출력을 캐시 슬롯으로 펼치는 컴파일러다.
참고
- 요즘IT, <'memo'를 지울 결심: React 컴파일러가 제안하는 미래> (장용석, 2025-12-12)
- FEConf 2025 발표 영상