[React] React.memo: 많을수록 좋은 거 아닌가요?

TL;DR

면접에서 React.memo를 어필했다가 "더 잘 쓰려면 어떻게 할 수 있을까요?"라는 질문에 막혔다. 답을 찾다 보니 memo 하나만의 문제가 아니었다. React가 리렌더링을 결정하는 방식, Fiber 재조정, Object.is 비교까지 이해해야 memo가 왜 그렇게 동작하는지 납득이 됐다.


React.memo, 많을수록 좋은 거 아닌가요?

지난 여름, 서비스 기업 면접에서 React.memo를 적극적으로 어필했다.

컴포넌트 최적화를 위해 memo를 적용했고 불필요한 리렌더링을 줄였다는 내용이었기에 꽤 자신 있었다.

"그러면 작성하신 코드에서 React.memo를 더 잘 쓰려면 어떻게 할 수 있을까요?"

머릿속이 하얘졌다. memo를 쓰는 것 자체가 최적화라고 생각했지 "더 잘 쓴다"는 개념을 생각해본 적이 없었다.

결국 제대로 답하지 못했다.

나중에 얻은 답은 "일부 요소만 넘기면 된다." 였다.

그때서야 내가 memo를 제대로 이해하지 못하고 사용하고 있었다는 걸 알았다.


React는 언제 리렌더링을 결정하는가

memo가 왜 그렇게 동작하는지 이해하려면 React가 리렌더링을 결정하는 방식부터 알아야 한다.

재조정(Reconciliation)

React는 상태나 Props가 바뀌면 컴포넌트를 다시 호출한다.
그 결과로 새로운 React Element 트리가 만들어지고 React는 이전 트리와 새 트리를 비교해서 실제 DOM에 반영할 변경사항만 추려낸다. 이 과정을 재조정(Reconciliation)이라고 한다.

재조정(Reconciliation) 흐름

Fiber 노드 구조

React 16 이후로 이 재조정 작업은 Fiber 아키텍처 위에서 돌아간다.

Fiber는 각 컴포넌트를 하나의 작업 단위(unit of work)로 쪼개서 관리하는 내부 자료구조다.

Fiber 노드 구조

덕분에 작업을 중단했다가 재개하거나 우선순위에 따라 순서를 바꾸는 것(Concurrent Mode)이 가능해졌다.

여기서 중요한 건 Fiber 노드가 이전 렌더링 결과를 기억하고 있다는 점이다.
React는 부모가 리렌더링되면 자식 Fiber 노드들을 순서대로 다시 실행한다.
memo가 없다면 Props가 같아도 무조건 다시 호출된다.


memo가 하는 일: Object.is 비교

React.memo는 이 재조정 과정에 끼어들어서 Props 비교를 먼저 수행한다.

비교 방식은 Object.is다.
얕은 비교(Shallow Comparison)로 각 Props 값을 Object.is로 하나씩 비교한다.

Object.is(1, 1)        // true → 리렌더링 스킵
Object.is('a', 'a')    // true → 리렌더링 스킵
Object.is({}, {})      // false → 리렌더링 발생
Object.is([], [])      // false → 리렌더링 발생

원시값(숫자, 문자열, boolean)은 값 자체를 비교하지만 객체와 배열은 참조값을 비교한다.
같은 내용이어도 매번 새로 생성되면 다른 참조값을 가지므로 memo가 있어도 리렌더링이 일어난다.

"일부 요소만 넘기면 된다"는 그 면접 답변이 여기서 나온다.
객체 전체를 Props로 넘기는 대신 필요한 원시값만 넘기면 Object.is 비교가 정확하게 동작한다.

// 매 렌더링마다 새 객체 생성 → Object.is 비교 실패
<HeavyList config={{ sort: 'asc', limit: 10 }} />

// 원시값만 넘기면 Object.is가 정확히 동작
<HeavyList sort="asc" limit={10} />

useMemo·useCallback이 세트인 이유

객체나 함수를 Props로 넘겨야 하는 상황이라면 useMemo와 useCallback으로 참조값을 고정해야 한다.

memo 유무 비교

흐름의 오른쪽 끝 두 갈래가 핵심이다.
memo가 제대로 작동하려면 Object.is 비교가 같음으로 끝나야 한다.
그러려면 Props로 넘기는 값의 참조값이 유지돼야 하고 객체·함수라면 useMemo·useCallback으로 고정해야 한다.
memo는 혼자 쓰는 도구가 아니다.

함수는 특히 놓치기 쉽다. 부모가 리렌더링될 때마다 함수가 새로 생성되고 참조값이 바뀐다.

function Parent() {
  // 렌더링마다 새 함수 생성 → 자식 memo 무력화
  const handleClick = () => console.log('click');

  return <MemoChild onClick={handleClick} />;
}

useCallback으로 감싸면 의존성 배열이 바뀌지 않는 한 같은 참조값을 유지한다.

function Parent() {
  const handleClick = useCallback(() => {
    console.log('click');
  }, []); // 의존성 없으면 최초 1회만 생성

  return <MemoChild onClick={handleClick} />;
}

객체도 마찬가지다.

// 렌더링마다 새 객체 생성
const config = { sort: 'asc', limit: 10 };

// useMemo로 참조값 고정
const config = useMemo(() => ({ sort: 'asc', limit: 10 }), []);

결국 React.memo는 useMemo, useCallback과 세트로 써야 제대로 작동한다.
memo만 씌우고 Props 관리를 안 하면 비교 연산 비용만 추가되고 실제 최적화는 일어나지 않는다.


그래서 memo는 언제 써야 하는가

Fiber 재조정과 Object.is 비교를 이해하고 나면 판단 기준이 명확해진다.

memo가 의미 있으려면 두 조건이 동시에 충족돼야 한다.

첫째, 부모가 자주 리렌더링된다.
타이머, 실시간 데이터, 폼 입력처럼 부모 상태가 자주 바뀌는 구조
둘째, 자식이 무겁다.
수십 개의 리스트 아이템, 복잡한 차트, 연산량이 많은 컴포넌트

그리고 이 두 조건을 충족하더라도 Props에 객체나 함수가 있고 useMemo/useCallback으로 참조값을 고정하지 않으면 memo는 무용지물이다.

아래는 memo와 관련하여 자주 겪을 수 있는 상황 해결책이다.

전역 상태 중 관련 없는 값이 바뀌어서 리렌더링됨 Zustand 셀렉터
부모가 자주 리렌더링되고, 자식이 무거움 React.memo + useMemo/useCallback
부모가 자주 리렌더링되지만, 자식이 단순함 그냥 두기
Props 자체가 바뀌어서 리렌더링됨 정상 동작

다시 면접으로

"더 잘 쓰려면 어떻게 할 수 있을까요?"
memo가 내부적으로 Object.is 비교를 한다는 걸 이해하고 그 비교가 정확하게 작동하도록 Props를 설계하는 것이 필요했고
필요하다면 useMemo/useCallback으로 참조값을 고정하는 것이 답이었다.

React.memo는 느려졌을 때 꺼내는 도구다.
꺼낼 때는 Fiber가 어떻게 재조정하는지 Object.is가 무엇을 비교하는지 알고 꺼내야 제대로 쓸 수 있다.