React 리렌더링 실험: useCallback, useMemo, React.memo, Context의 관계 정리

EricaJeong·2025년 5월 11일
3

생각해보기

목록 보기
2/5

문제 상황

처음에 아래처럼 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 }}처럼 객체를 직접 넘기면, 렌더링될 때마다 새로운 객체가 생성되고 참조값이 매번 달라진다.

이 구조에서 문제가 되는 건, ModalContextuseContext로 직접 구독하고 있는 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.HeaderuseContext(ModalContext)를 통해 Context를 구독하고 있기 때문에, value가 바뀌면 무조건 리렌더링되었다.

  • 반면 Modal.BodyModal.Footer는 Context를 구독하고 있진 않지만, 부모인 <Modal>이 리렌더링되면서 함수 컴포넌트로서 다시 호출되었기 때문에 함께 렌더링된 것이다.

즉, Modal.Header는 Context의 영향으로, Modal.Body, Modal.Footer는 부모 리렌더링의 영향으로 렌더링된 상황이었다.

해결 방법

1. 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 객체가 여전히 렌더링마다 새로 만들어지기 때문이다.

2. useMemo로 Context value 객체의 참조 고정하기

const contextValue = useMemo(() => ({ onHide }), [onHide]);

return (
	<ModalContext.Provider value={contextValue}>
        ...
    </ModalContext.Provider>
);

value={{ onHide }}처럼 객체 리터럴을 직접 넘기면 렌더링마다 매번 새로운 객체로 생성되어, Context를 구독 중인 컴포넌트는 매번 “값이 변경됐다”고 판단하고 리렌더링된다.

이를 방지하려면 useMemo로 감싸서 onHide가 바뀌지 않는 한 동일한 참조를 유지하도록 해야 한다.

하지만 이렇게 useMemo까지 적용했음에도, 여전히 모달의 자식 컴포넌트들은 리렌더링되고 있었다.

3. React.memo로 자식 컴포넌트의 리렌더링 차단

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를 구독하고 있는가?"이다.

  • Context를 구독하지 않는 자식 컴포넌트
    • React.memo만으로도 렌더링을 완벽하게 막을 수 있다
  • Context를 useContext로 직접 사용하는 자식은
    • Context의 value가 바뀌면 무조건 리렌더링된다 (memo 무시)
  • Modal.Body, Modal.Footer, Modal.Title은 memo만으로도 렌더링 차단 성공
  • Modal.Header는 Context를 사용하고 있어 계속 렌더링됨

그러면 결국 React.memo만 잘 쓰면 되는 거 아닐까?

실제로 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의 기본 동작이다.

리렌더링을 막기 위한 최적화 흐름 정리

1. useCallback

  • 함수가 렌더링마다 새로 만들어지지 않도록 한다
  • 함수의 참조값을 고정해주는 역할
  • 예시: const onHide = useCallback(() => setShow(false), [])

2. useMemo

  • 객체를 렌더링마다 새로 만들지 않도록 한다
  • 예시: const value = useMemo(() => ({ onHide }), [onHide])
  • 이렇게 하지 않으면 Context value가 매번 새로워져서 자식 컴포넌트가 리렌더링됨

3. React.memo

  • props가 같으면 컴포넌트를 렌더링하지 않게 막아줌
  • 단, props로 받은 값이 객체/함수일 경우 참조값이 바뀌면 → memo 실패
  • 그래서 위 두 개(useCallback, useMemo)가 선행 조건처럼 필요한 것

결론

이번 글을 쓰면서, 그리고 직접 실험해보면서 "자식 컴포넌트의 리렌더링이 어떻게 발생하는지", "React.memo나 useMemo가 언제 효과가 있는지" 이런 것들을 처음 이해하게 되었다.

처음엔 단순히 onHide 함수를 Context로 전달했기 때문에 모든 자식 컴포넌트가 함께 리렌더링되는 줄 알았다. 그런데 테스트를 계속해보면서, 실제로는 useContext로 Context를 구독한 자식 컴포넌트만 영향을 받는다는 걸 알게 되었다. 즉, 관련 없는 자식은 React.memo만으로도 렌더링을 충분히 차단할 수 있었다.

또 처음에는 그냥 React.memo만 쓰면 다 해결될 줄 알았고, useMemo도 단순히 최적화 도구 정도로만 알고 있었다.

하지만 실제로 써보고, 로그를 찍어보고, 렌더링이 계속 되는 이유를 하나씩 추적하면서 참조값과 렌더링의 관계, Context와 memo의 충돌 그리고 shallow compare가 갖는 한계를 직접 체감할 수 있었다.

그리고 무엇보다도 최적화를 하려면 구조까지 고려해야 한다는 것을 느꼈다.

예를 들어서 Context로 값을 넘기면 memo가 무력화될 수도 있다는 사실, props로 직접 넘기면 깔끔하게 해결되지만 그만큼 컴포넌트 API도 더 복잡해진다는 점도 실제로 경험할 수 있었다.

앞으로는 성능 최적화라는 단어에 휘둘리기보다,
"지금 이 상황에서 정말 필요한가?",
"어떤 구조가 유지보수와 성능을 동시에 만족시킬 수 있을까?"를 먼저 고민해보는 습관을 가져가고 싶다.

profile
Aspiring Frontend Developer

0개의 댓글