React Element 참조로 이해하는 리렌더링
React.memo를 파고 들어가니 Element 참조라는 개념이 나왔습니다.
사실 저는 React.memo 같은 메모이제이션을 쓰는 데 조심스러운 편입니다.
어설프게 알고 써서 비용이 더 드느니, 안 쓰는 게 낫다고 생각했습니다.
그러다가 작업 중에 memo를 붙이면 확실히 효과가 있겠다 싶어서 적용해봤습니다. 꼼꼼하게 검증까지 했는데, 리렌더링이 기대만큼 줄지 않았어요. 어디에 붙여야 하고, 왜 어떤 경우에는 효과가 없는지 알 수가 없었습니다.
파고 들어가 보니 memo보다 먼저 알아야 할 개념이 있었습니다.
React가 리렌더링 여부를 판단하는 기준,Element 참조 입니다.
React Element는 그냥 객체입니다.
JSX를 쓰면 React는 내부적으로 평범한 JavaScript 객체를 만듭니다.
<Page title="home" />
// 실제로는 이렇게 변환됩니다.
React.createElement(Page, { title: "home" })
// 결과는 JS 객체
{
type: Page,
props: { title: "home" },
key: null,
// ...
}컴포넌트가 리렌더링된다 는 것은 곧 이 함수가 다시 호출되어 새 객체가 만들어진다 는 뜻입니다.
그리고 React는 이전 Element와 새 Element의 참조 를 비교합니다.
이것은 React 재조정 과정의 핵심인데, 판단 기준은 단순합니다.
이전 Element === 새 Element (같은 참조) → 서브트리 스킵
이전 Element !== 새 Element (다른 참조) → 리렌더링참조가 같으면 React는 해당 컴포넌트뿐 아니라 서브트리 전체의 렌더링을 건너뜁니다.
단, 서브트리 내부에서 자체적인 state 변경(useState setter 호출)이 있다면, 해당 컴포넌트는 여전히 리렌더링됩니다.
참조 유지로 스킵되는 것은 부모의 리렌더링으로 인한 전파입니다.
children 패턴 vs 직접 렌더링
이 참조 비교가 실제로 어떤 차이를 만드는지, 두 가지 구조를 비교해보겠습니다.
children 패턴
function App() {
return (
<ThemeProvider>
<Page />
</ThemeProvider>
);
}
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return <ThemeContext value={theme}>{children}</ThemeContext>;
}<Page />를 만든 건 App입니다. App은 리렌더링되지 않으니 그 참조가 그대로 유지됩니다.
ThemeProvider 안에서 theme이 바뀌어도, children으로 받은 <Page />는 이전과 같은 객체입니다.
React는 “이전이랑 같네?” 하고 서브트리를 통째로 건너뜁니다.
직접 렌더링
function App() {
return <ThemeProvider />;
}
function ThemeProvider() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext value={theme}>
<Page /> {/* ThemeProvider가 직접 만듦 */}
</ThemeContext>
);
}이번에는 <Page />를 ThemeProvider가 직접 만들고 있습니다.
theme이 바뀌면 ThemeProvider가 리렌더링되고, 그때마다 <Page />가 새로 생성됩니다.
같은 컴포넌트지만 매번 새 객체이기 때문에 React는 “다르네?” 하고 리렌더링합니다.
차이는 하나입니다
핵심은 <Page />를 누가 만들었느냐 입니다.
만든 쪽이 리렌더링되지 않으면 참조가 유지되고, 만든 쪽이 리렌더링되면 참조가 바뀝니다.
그러면 memo를 붙이면 되지 않나요?
children 패턴을 모르는 상태에서 가장 먼저 떠오르는 해결책이 React.memo입니다.
memo는 리렌더링 판단 과정에 한 단계를 추가합니다.
일반: 참조가 다름 → 바로 리렌더링
memo: 참조가 다름 → props를 얕은 비교 → 같으면 스킵, 다르면 리렌더링const Page = React.memo(({ title }) => {
return <div>{title}</div>;
});직접 렌더링 구조에서도 props가 안 바뀌면 memo가 리렌더링을 막아줄 수 있습니다.
하지만 한 가지 예외가 있습니다.
Context 구독은 다른 경로를 탑니다.
useContext로 Context를 구독 중인 컴포넌트는 memo를 붙여도 value가 변경되면 리렌더링됩니다.
const MemoizedChild = React.memo(function Child() {
const theme = useContext(ThemeContext); // 구독 중
return <div>{theme}</div>;
});
// theme이 바뀌면 memo에도 불구하고 리렌더링됨일반적인 리렌더링은 부모 → 자식으로 전파되기 때문에, 그 경로 위에 있는 memo가 방어할 수 있습니다.
하지만 Context는 이 경로를 타지 않습니다.
Context value가 변경되면 React는 구독 중인 컴포넌트를 직접 찾아서 리렌더링 대상으로 등록합니다. 부모 → 자식 경로를 거치지 않고, 구독자에게 바로 도달하는 방식입니다. memo는 부모로부터의 리렌더링 전파를 막는 장치이므로, 이 경로에서는 개입할 수 없습니다.
일반 리렌더링: 부모 리렌더링 → 자식 Element 재생성 → memo로 방어 가능
Context 리렌더링: value 변경 → 구독자를 직접 리렌더링 대상 등록 → memo 무시그리고 이 value 변경 여부를 판단할 때 React는 Object.is를 사용합니다.
매 렌더링마다 새 객체를 value로 넘기면 Object.is가 false를 반환하고, 모든 구독자가 리렌더링됩니다. 이것이 뒤에서 다룰 useMemo로 참조를 안정시켜야 하는 이유입니다.
정리
| 상황 | 리렌더링? | memo 효과? |
|---|---|---|
| children 패턴으로 전달 | ❌ 안 됨 | 필요 없음 |
| Provider 내부에서 직접 렌더링 (context 미사용) | ✅ 됨 | ✅ 막을 수 있음 |
| useContext로 구독 중 + value 변경 | ✅ 됨 | ❌ 못 막음 |
직접 체험해보기
위 세 가지 시나리오의 차이를 직접 확인해보세요.
각 패널의 테마 변경 버튼을 누르면 Provider와 Page의 렌더 횟수가 어떻게 달라지는지 관찰할 수 있습니다.
Context 리렌더링 인터랙티브 데모
각 시나리오의 테마 변경 버튼을 눌러 렌더 횟수를 비교해보세요.
Page 리렌더링 안 됨
memomemo가 리렌더링 방어
memo + useContexttheme: light
memo로 못 막음
관찰 포인트
- children 패턴
Provider가 리렌더링돼도 Page의 Element 참조가 유지되어 스킵 - 직접 렌더링 + memo
새 Element가 생성되지만 props가 같으므로 memo가 방어 - memo + useContext
Context value 변경 시 Fiber를 직접 dirty 마킹하므로 memo 우회
프로젝트에서의 Context 성능 최적화
원리를 이해했으니, 프로젝트에 적용할 수 있는 전략을 정리해보겠습니다.
1. Provider는 항상 children 패턴으로.
// ✅
function App() {
return (
<ThemeProvider>
<Page />
</ThemeProvider>
);
}
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return <ThemeContext value={theme}>{children}</ThemeContext>;
}2. 변경 빈도가 다른 값은 Context를 분리
// ❌ theme이 바뀌면 setTheme만 쓰는 컴포넌트도 리렌더링
const ThemeContext = createContext({ theme: 'light', setTheme: () => {} });
// ✅ 분리하면 setTheme만 쓰는 컴포넌트는 리렌더링 안 됨
const ThemeValueContext = createContext('light');
const ThemeSetterContext = createContext(() => {});값(state)과 갱신 함수(setter)를 분리하는 것이 가장 흔한 패턴입니다.
setter는 useState가 반환하는 함수이므로 참조가 안정적이고, setter만 구독하는 컴포넌트는 value가 바뀌어도 리렌더링되지 않습니다.
3. Context 분리가 어렵다면, useMemo로 참조를 안정시킨다.
전략 2처럼 Context를 분리하는 게 가장 깔끔하지만, 이미 하나의 Context에 여러 값을 넘기고 있는 상황이라면 useMemo로 참조를 안정시킬 수 있습니다.
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// ❌ 매 렌더링마다 새 객체 → Object.is가 false → 모든 구독자 리렌더링
// return <ThemeContext value={{ theme, setTheme }}>{children}</ThemeContext>;
// ✅ theme이 바뀔 때만 새 객체
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext value={value}>{children}</ThemeContext>;
}마치며
memo 하나 알려고 시작했는데, 결국 Element 참조, children 패턴, Context 구독 경로까지 오게 됐습니다.
돌아보면 결국 하나입니다. React는 참조가 같으면 건너뛰고, 다르면 다시 그립니다.
memo도, children 패턴도, Context 최적화도 전부 이 규칙 위에 있습니다.
“memo를 어디에 붙이지?”보다 “이 Element의 참조가 유지되고 있는가?”를 먼저 생각하게 된 계기가 되었습니다.