처음에 아래처럼 Modal
을 사용했다.
<Modal show={show} onHide={() => setShow(false)} position="bottom">
<Modal.Header closeButton>
<Modal.Title>Title</Modal.Title>
</Modal.Header>
<Modal.Body>Body</Modal.Body>
<Modal.Footer>Footer</Modal.Footer>
</Modal>
그냥 별 생각 없이 onHide를 그냥 익명 함수로 넘겼고, 이 함수는 Modal 내부에서 Context로 다시 하위 컴포넌트들에 전달된다.
<ModalContext.Provider value={{ onHide }}>
이로 인해 Context를 구독 중인 자식 컴포넌트들은 props가 바뀌지 않았더라도 리렌더링될 수 있는 구조가 되어버렸다.
value={{ onHide }}
처럼 객체를 직접 넘기면, 렌더링될 때마다 새로운 객체가 생성되고 참조값이 매번 달라진다.
이 구조에서 문제가 되는 건, ModalContext
를 useContext
로 직접 구독하고 있는 Modal.Header
다.
Context의 value가 바뀌면, React.memo
와 상관없이 이 구독 컴포넌트는 무조건 리렌더링된다.
반면, Modal.Body
, Modal.Footer
, Modal.Title
등은 Context를 구독하지 않기 때문에 영향을 받지 않는다.
즉, children도 같고 props도 바뀐 게 없는데,
onHide
하나의 참조가 바뀌면서 Context를 구독 중인Modal.Header
가 불필요하게 렌더링되는 상황이다.
이 테스트에서는 모달을 열고 닫을 때마다 Modal.Header
, Modal.Body
, Modal.Footer
등 자식 컴포넌트들이 매번 다시 렌더링되는 현상을 확인할 수 있었다.
props나 children이 바뀐 것도 아닌데 렌더링이 반복된 이유는 Modal
컴포넌트가 리렌더링되었기 때문이다.
그리고 그 리렌더링의 시작점은 Context의 value={{ onHide }}
였다.
이 객체는 렌더링마다 새로운 참조값으로 생성되기 때문에, React는 Context의 value가 매번 "바뀌었다"고 인식하게 된다.
그 결과:
Modal.Header
는 useContext(ModalContext)
를 통해 Context를 구독하고 있기 때문에, value가 바뀌면 무조건 리렌더링되었다.
반면 Modal.Body
나 Modal.Footer
는 Context를 구독하고 있진 않지만, 부모인 <Modal>
이 리렌더링되면서 함수 컴포넌트로서 다시 호출되었기 때문에 함께 렌더링된 것이다.
즉, Modal.Header
는 Context의 영향으로, Modal.Body
, Modal.Footer
는 부모 리렌더링의 영향으로 렌더링된 상황이었다.
useCallback
으로 onHide
함수의 참조 유지하기const onHide = useCallback(() => setModalOpen(true), []);
return (
<Modal show={modalOpen} onHide={onHide}>
...
</Modal>
);
이전에는 onHide={() => setModalOpen(true)}처럼 익명 함수로 넘겨서 렌더링마다 onHide 함수가 새로 생성되었고, 그로 인해 Context의 value={{ onHide }}도 매번 새 객체가 되어 Context를 구독하는 자식 컴포넌트(Modal.Header)가 리렌더링되었다.
위 코드처럼 useCallback을 사용하면 onHide의 참조를 고정할 수 있다.
하지만 아직도 자식 컴포넌트들이 리렌더링되고 있다.
이것은 ModalContext.Provider의 value 객체가 여전히 렌더링마다 새로 만들어지기 때문이다.
const contextValue = useMemo(() => ({ onHide }), [onHide]);
return (
<ModalContext.Provider value={contextValue}>
...
</ModalContext.Provider>
);
value={{ onHide }}처럼 객체 리터럴을 직접 넘기면 렌더링마다 매번 새로운 객체로 생성되어, Context를 구독 중인 컴포넌트는 매번 “값이 변경됐다”고 판단하고 리렌더링된다.
이를 방지하려면 useMemo로 감싸서 onHide가 바뀌지 않는 한 동일한 참조를 유지하도록 해야 한다.
하지만 이렇게 useMemo까지 적용했음에도, 여전히 모달의 자식 컴포넌트들은 리렌더링되고 있었다.
useCallback과 useMemo로 참조값을 고정했더라도, Modal 컴포넌트가 리렌더링되면 그 안에 선언된 JSX(Modal.Body, Modal.Footer, 등)는 기본적으로 다시 함수 호출이 일어난다.
이때 React.memo로 자식 컴포넌트를 감싸면, props가 이전과 동일할 경우 함수 호출 자체를 스킵하여 불필요한 렌더링을 방지할 수 있다.
Modal.Body = React.memo(({ children }: ChildrenProps) => {
console.log("🔁 Modal.Body 렌더링");
return <div>{children}</div>;
});
Modal.Body
, Modal.Footer
, Modal.Title
등 자식 컴포넌트에 React.memo
를 적용한 후, 렌더링 여부를 console.log
로 확인해봤다.
그 결과, 다른 자식 컴포넌트에서는 로그가 더 이상 찍히지 않았다. 즉, memo가 정상적으로 작동해 리렌더링이 차단된 것이다.
하지만 Modal.Header
만은 여전히 모달을 열 때마다 "🔁 Modal.Header 렌더링"
로그가 계속 찍혔다.
즉, 다른 자식 컴포넌트는 React.memo
로 렌더링을 막을 수 있었지만, Modal.Header
는 여전히 리렌더링되고 있었다.
Modal.Header
안에서는 다음과 같이 Context를 구독하고 있다
const { onHide } = useContext(ModalContext);
React에서는 Context를 사용하는 컴포넌트는 value가 바뀌었다고 판단되면 React.memo로 감싸더라도 무조건 리렌더링된다.
즉, ModalContext.Provider에서 넘겨주는 value={{ onHide }}는
비록 useMemo로 감싸서 참조를 고정해도, onHide가 바뀌는 순간 value 객체 전체가 바뀐 것처럼 인식된다.
그리고 이를 useContext로 구독하고 있는 Modal.Header는 value가 바뀌는 즉시 다시 렌더링된다.
그리고 이 동작은 React.memo보다 우선 순위가 높다!
즉, Context 구독은 memo보다 먼저 반응한다. 그래서 Context의 value 변경은 무조건 리렌더링을 발생시킬 수밖에 없다.
결국 중요한 건 "자식 컴포넌트가 Context를 구독하고 있는가?"이다.
React.memo
만으로도 렌더링을 완벽하게 막을 수 있다useContext
로 직접 사용하는 자식은
Modal.Body
,Modal.Footer
,Modal.Title
은 memo만으로도 렌더링 차단 성공Modal.Header
는 Context를 사용하고 있어 계속 렌더링됨
실제로 Context를 사용하지 않고, props도 참조가 변하지 않는 값(문자열, 숫자 등)만 넘긴다면React.memo
만으로도 리렌더링을 충분히 막을 수 있다.
하지만 실제로는 props로 함수를 넘기거나 객체를 전달하는 경우가 많다.
이런 경우 React.memo는 단순히 얕은 비교(shallow compare)만 수행하므로, 참조값이 바뀌었을 경우 내용이 같아도 다른 값으로 판단하고 렌더링이 발생한다.
그렇게 되면 React.memo
가 제대로 작동하지 않는다.
예를 들어 아래처럼 익명 함수를 넘기면:
<Modal onHide={() => setOpen(false)} />
렌더링마다 함수가 새로 만들어져서
→ React.memo는 매번 “다른 props”라고 판단
→ 리렌더링된다.
이럴 때는 useCallback으로 함수 참조를 고정해줘야 한다. 객체나 배열을 넘기는 경우도 마찬가지로 useMemo가 필요하다.
즉, React.memo, useCallback, useMemo는 같이 쓸 때 진짜 최적화 효과를 발휘한다.
그리고 한 가지 예외는
useContext로 Context를 구독하고 있는 컴포넌트는 value가 바뀌면 React.memo를 써도 무조건 리렌더링된다.
이건 React의 기본 동작이다.
const onHide = useCallback(() => setShow(false), [])
const value = useMemo(() => ({ onHide }), [onHide])
useCallback
, useMemo
)가 선행 조건처럼 필요한 것이번 글을 쓰면서, 그리고 직접 실험해보면서 "자식 컴포넌트의 리렌더링이 어떻게 발생하는지", "React.memo나 useMemo가 언제 효과가 있는지" 이런 것들을 처음 이해하게 되었다.
처음엔 단순히 onHide
함수를 Context로 전달했기 때문에 모든 자식 컴포넌트가 함께 리렌더링되는 줄 알았다. 그런데 테스트를 계속해보면서, 실제로는 useContext
로 Context를 구독한 자식 컴포넌트만 영향을 받는다는 걸 알게 되었다. 즉, 관련 없는 자식은 React.memo
만으로도 렌더링을 충분히 차단할 수 있었다.
또 처음에는 그냥 React.memo
만 쓰면 다 해결될 줄 알았고, useMemo
도 단순히 최적화 도구 정도로만 알고 있었다.
하지만 실제로 써보고, 로그를 찍어보고, 렌더링이 계속 되는 이유를 하나씩 추적하면서 참조값과 렌더링의 관계, Context와 memo의 충돌 그리고 shallow compare가 갖는 한계를 직접 체감할 수 있었다.
그리고 무엇보다도 최적화를 하려면 구조까지 고려해야 한다는 것을 느꼈다.
예를 들어서 Context로 값을 넘기면 memo가 무력화될 수도 있다는 사실, props로 직접 넘기면 깔끔하게 해결되지만 그만큼 컴포넌트 API도 더 복잡해진다는 점도 실제로 경험할 수 있었다.
앞으로는 성능 최적화라는 단어에 휘둘리기보다,
"지금 이 상황에서 정말 필요한가?",
"어떤 구조가 유지보수와 성능을 동시에 만족시킬 수 있을까?"를 먼저 고민해보는 습관을 가져가고 싶다.