
부모가 렌더링되면 자식도 렌더링된다? React 렌더링의 진실과 오해
React 성능 최적화의 첫걸음은 렌더링 규칙을 정확히 이해하는 것입니다. 부모 컴포넌트가 업데이트될 때 자식 컴포넌트의 불필요한 렌더링을 막는 React.memo, useMemo, Context API 최적화, 그리고 React 19 Compiler의 미래를 정리합니다.

React 성능 최적화의 첫걸음은 렌더링 규칙을 정확히 이해하는 것입니다. 부모 컴포넌트가 업데이트될 때 자식 컴포넌트의 불필요한 렌더링을 막는 React.memo, useMemo, Context API 최적화, 그리고 React 19 Compiler의 미래를 정리합니다.
클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

시각 장애인, 마우스가 고장 난 사용자, 그리고 미래의 나를 위한 배려. `alt` 태그 하나가 만드는 큰 차이.

버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

React를 처음 배울 때 가장 많이 하는 오해가 있습니다. "DOM이 변경되지 않으면 렌더링도 안 하겠지?" "Props가 똑같으면 자식 컴포넌트는 다시 안 그려지겠지?"
정답은 NO입니다. React의 기본 렌더링 규칙은 매우 단순하고, 어떤 의미에서는 '무식'합니다.
"부모 컴포넌트가 렌더링(함수 재실행)되면, 그 안에 있는 모든 자식 컴포넌트도 무조건 렌더링(재귀적 호출)된다."자식 컴포넌트가 받는 props가 변했든 안 변했든 상관없습니다.
부모 컴포넌트라는 '함수'가 다시 호출되었고, 그 내부에서 자식 컴포넌트 '함수'를 호출(React.createElement)했으니, 당연히 다시 실행되는 것입니다.
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child name="철수" />
</div>
);
}
function Child({ name }) {
console.log("Child Rendered!"); // 버튼 누를 때마다 무조건 찍힘
return <div>안녕하세요 {name}님</div>;
}
위 코드에서 count가 바뀌면 Parent가 다시 실행됩니다.
그러면 Child도 다시 실행됩니다. name은 여전히 "철수"로 똑같지만, React는 기본적으로 이를 신경 쓰지 않습니다.
React 팀의 철학은 "변경을 놓치는 것보다는 차라리 과하게 그리는 것이 안전하다"입니다.
만약 React가 맘대로 "어? props 안 변했네? 렌더링 안 해야지"라고 했다가, Context나 Zustand 같은 외부 저장소의 변화를 놓치면 사용자는 갱신되지 않은 옛날 데이터(Stale UI)를 보게 됩니다. 이는 심각한 버그입니다.
또한, Virtual DOM 덕분에 함수가 재실행되어도 실제 브라우저 DOM은 변경된 부분만 업데이트되므로, 웬만하면 성능 문제가 없습니다.
하지만 Child가 엄청나게 무거운 연산(소수 계산, 복잡한 차트)을 한다면 이야기가 달라집니다. 이때는 최적화가 필요합니다.
불필요한 렌더링을 막기 위한 공식적인 방법은 React.memo (High Order Component)입니다.
이걸 씌우면 React에게 이렇게 말하는 것과 같습니다.
"이 컴포넌트는 Props가 진짜로 바뀌었을 때만 렌더링해줘. 부모가 렌더링되든 말든 신경 쓰지 말고."
const Child = React.memo(function Child({ name }) {
console.log("Child Rendered!");
return <div>안녕하세요 {name}님</div>;
});
이제 Parent의 버튼을 눌러도 Child는 조용합니다. Props(name="철수")가 그대로이기 때문입니다.
React.memo는 이전 Props와 새 Props를 얕은 비교(Shallow Compare)하여 같다면 렌더링을 건너뜁니다.
'얕은 비교'란 객체의 주소값만 비교한다는 뜻입니다. propA === propB가 참이면 렌더링을 안 합니다.
하지만 React.memo를 썼는데도 렌더링이 되는 경우가 있습니다. 이것이 자주 마주치는 참조 동일성 문제입니다.
바로 객체(Object), 배열(Array), 함수(Function)를 Props로 넘길 때 발생합니다.
function Parent() {
const [count, setCount] = useState(0);
// 이 함수는 Parent가 렌더링될 때마다 '새로' 만들어집니다.
const handleClick = () => console.log("Clicked");
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Plus</button>
{/* React.memo를 썼어도 리렌더링됨! */}
<Child onClick={handleClick} />
</div>
);
}
자바스크립트에서 { a: 1 } === { a: 1 }은 false입니다. 매번 새로운 메모리 주소를 가진 객체가 생성되기 때문입니다.
함수도 일종의 객체입니다. Parent가 다시 실행되면 handleClick도 새로운 함수(새 주소)로 다시 태어납니다.
Child 입장에서는 "어? 내 onClick Props의 주소가 바뀌었네? 내용물이 바뀌었구나!" 하고 렌더링을 합니다.
그래서 useCallback과 useMemo가 필요합니다. 이들은 의존성 배열(deps)이 바뀌지 않는 한, 이전에 만든 값을 재사용(Memoization)합니다.
// 메모이제이션된 함수. 의존성 배열([])이 비었으므로 영원히 같은 주소를 유지함.
const handleClick = useCallback(() => console.log("Clicked"), []);
return <Child onClick={handleClick} />;
모든 곳에 memo를 바르는 것은 귀찮고 실수하기 쉽습니다.
렌더링을 막는 더 우아하고 구조적인 패턴, 컴포넌트 합성을 사용하세요.
핵심은 Children Prop입니다.
// 1. 상태를 가진 껍데기 컴포넌트 (Wrapper)
function ColorWrapper({ children }) {
const [color, setColor] = useState("red");
return (
<div style={{ color }}>
<input onChange={e => setColor(e.target.value)} />
{children} {/* 이미 만들어진 Element를 배치만 함 */}
</div>
);
}
// 2. 사용하는 곳
function App() {
return (
<ColorWrapper>
<ExpensiveChild /> {/* App이 리렌더링되지 않는 한, 얘도 리렌더링 안 됨 */}
</ColorWrapper>
);
}
여기서 ColorWrapper의 state가 바뀌어 리렌더링되어도, children으로 들어온 ExpensiveChild는 영향받지 않습니다.
왜냐하면 ExpensiveChild는 ColorWrapper 내부가 아니라, 그 상위인 App에서 생성되어 전달되었기 때문입니다.
React는 "어? children으로 들어온 Element 객체(ExpensiveChild)가 이전과 똑같네?"라고 판단하고 렌더링을 건너뜁니다.
렌더링 성능을 갉아먹는 또 다른 주범은 Context API입니다.
Context의 Provider value가 바뀌면, 그 Context를 구독(useContext)하는 모든 하위 컴포넌트가 강제로 리렌더링됩니다.
// State와 Dispatch 함수를 한 객체에 담아서 전달
<MyContext.Provider value={{ state, dispatch }}>
<ComponentA /> {/* state만 씀 */}
<ComponentB /> {/* dispatch만 씀 */}
</MyContext.Provider>
여기서 state가 바뀌면 value 객체가 새로 만들어집니다.
그러면 state를 안 쓰고 dispatch만 쓰는 ComponentB도 억울하게 리렌더링됩니다.
변하는 값(State)과 변하지 않는 값(Dispatch)을 다른 Context로 분리하세요.
<DispatchContext.Provider value={dispatch}>
<StateContext.Provider value={state}>
<ComponentA />
<ComponentB />
</StateContext.Provider>
</DispatchContext.Provider>
이제 state가 변해도 StateContext만 업데이트되므로, DispatchContext만 바라보는 ComponentB는 조용히 있습니다.
Recoil이나 Zustand 같은 라이브러리는 내부적으로 이런 최적화(Selector)가 되어 있어 Context보다 편합니다.
최근 React 팀은 "React Compiler"라는 프로젝트를 발표했습니다.
이것이 상용화되면, 위에서 말한 useMemo, useCallback, React.memo를 개발자가 직접 쓸 필요가 없어집니다.
컴파일러가 빌드 타임에 코드를 분석해서 "아, 이 값은 안 변했네? 그럼 메모이제이션 코드를 심어야지"라고 자동으로 최적화해 주기 때문입니다.
하지만 아직은 React 18이 주류이므로, 수동 최적화 능력을 갖추는 것이 필수입니다.
최적화는 "감"으로 하는 게 아닙니다. "측정"으로 하는 것입니다. Premature Optimization(조기 최적화)은 만악의 근원입니다.
범인을 찾은 뒤에 React.memo나 Composition을 적용하세요.
무턱대고 모든 컴포넌트에 memo를 감싸면, 오히려 메모리 사용량만 늘어나고 초기 렌더링 속도만 느려질 수 있습니다.