FEInterview Prep

Tistory

React.memo 완벽 해부: 언제 쓸모 있고 언제 쓸모없는가

React.memo·useMemo·useCallback 은 "리렌더 막는 부적"이 아니다. 자바스크립트 참조 비교라는 본질에서 시작해, props 스프레드/children/중첩 memo 같은 함정 3가지를 코드로 짚는다.

2025-08-14·7분 읽기
React성능
원문 보기 ↗

핵심 요약

리액트의 리렌더는 (1) 본인 상태 변경 또는 (2) 부모 리렌더에 의해 일어난다. 객체/배열/함수는 매 렌더마다 새 참조이기에, 자식이 React.memo 로 감싸져 있어도 부모가 새 참조의 props를 내리면 다시 렌더된다. useMemo 는 값의 참조를, useCallback 은 함수 참조를 안정화한다. 그러나 <Child {...props} /> 스프레드, <Memo>{<Inner/>}</Memo> 처럼 매 렌더 새로 만드는 children, <Outer><Inner/></Outer> 중첩 memo 패턴에서는 메모이제이션이 "조용히 깨진다". 진짜 해법은 컴포지션으로 상태를 더 작은 컨테이너에 가두는 것이며, memo는 프로파일링 후 마지막 카드로 쓴다.

메모이제이션은 "같은 입력 = 같은 참조" 를 강제하는 도구다. 리액트가 자식을 리렌더할지 결정할 때 쓰는 비교는 Object.is 기반 얕은 비교이므로, 매 렌더에 {...}/() => ... 를 새로 만들면 "값은 같지만 참조는 다른" 상황이 생긴다. memo 도구는 이 참조 안정성을 사주는 거지, 자식 렌더 자체를 막는 마법이 아니다.

면접에서 "React.memo 쓰면 빨라지나요?" 라는 질문은 언제 안 통하는지를 아는가를 본다. 글이 짚는 "props spread / children / 중첩 memo" 함정 3가지는 실무에서 거의 항상 마주치며, "왜 memo 했는데 여전히 리렌더되나요" 버그 리포트의 80% 가 이 셋 안에 있다.

학습 포인트

면접 답변으로 연결할 학습 포인트입니다.

왜 메모이제이션이 필요한가 — 참조 비교의 본질

원시값은 값으로 비교되지만 객체·배열·함수는 참조로 비교된다.

const a = { id: 1 };
const b = { id: 1 };
a === b; // false

매 렌더마다 새 객체/함수가 만들어지면 자식의 useEffect 의존성, React.memo 비교, useMemo 캐시가 모두 깨진다.

referential equalityObject.isshallow compare
자주 하는 오해

"값이 같으면 같다"고 가정. 리액트는 깊은 비교를 하지 않는다.

`useCallback`/`useMemo` 의 진짜 역할

두 훅은 "렌더 간 참조를 보존"하는 캐시다. 차이: useCallback 은 함수 자체를, useMemo 는 함수의 반환값을 캐싱.

도구캐시 대상주된 용도
useMemo계산 결과비싼 계산, 객체 참조 안정화
useCallback함수 자체메모된 자식의 콜백, useEffect 의존성
React.memo컴포넌트 렌더 결과같은 props 시 자식 재실행 스킵
useMemouseCallbackReact.memoshallow compare
자주 하는 오해

props 메모이제이션만 하면 "자식 렌더가 막힌다"고 가정하기. 실제로는 자식이 React.memo 로 감싸져 있거나 의존성 배열에서 그 props 를 쓰는 두 경우에만 효과가 있다.

함정 1 — props 스프레드

const Child = React.memo(({ data }) => /* ... */);
const Parent = (props) => <Child {...props} />;

Parent 의 호출자가 새 객체/함수를 매번 넘기면 Child 의 memo 가 즉시 깨진다. 메모이제이션 안정성은 호출 측까지 책임이 있는 셈.

spread propsmemoization break
자주 하는 오해

"props 그냥 넘긴다"는 편의를 위해 스프레드를 쓰는 것. 메모된 컴포넌트의 경계에서는 props 를 명시적으로 받아 정리해야 한다.

함정 2 — `children` 도 단지 또 하나의 prop

const Memo = React.memo(({ children }) => /* ... */);
const Parent = () => (
  <Memo><div>Some content</div></Memo>
);

JSX 의 children 은 매 렌더마다 새 React element. 해결책: useMemo 로 element 자체를 안정화하거나, 컴포지션을 바꿔 memo 가 children 을 받지 않도록 설계.

children propJSX element identity
자주 하는 오해

React.memo 만 붙이고 children 은 매번 새로 만드는 패턴. 가장 흔한 "memo 가 안 통한다" 사례.

함정 3 — 중첩 memo

const Inner = React.memo(() => <div>Inner</div>);
const Outer = React.memo(({ children }) => <div>{children}</div>);
const Parent = () => (
  <Outer><Inner/></Outer>
);

Inner, Outer 둘 다 memo 라도 Parent 가 매번 새 <Inner/> element 를 만들기 때문에 Outer 의 children prop 이 바뀐다. "내부도 memo면 안전"이라는 직관과 반대.

element identityJSX call as factory
자주 하는 오해

memo 를 더 깊게 두를수록 안전해진다고 믿는 것. 실제로는 호출 측에서 element 자체를 안정화해야 한다.

더 나은 대안 — 컴포지션으로 상태 격리

memo 가 필요해지는 상황 자체가 "상태가 너무 위에 있다"는 신호일 수 있다. 카운터를 CounterButton 같은 작은 컨테이너로 분리하면, 자주 바뀌는 상태가 ExpensiveComponent 의 부모를 더 이상 리렌더하지 않는다.

state colocationcompositionlift state down
자주 하는 오해

성능 문제를 도구(memo) 로만 풀려는 것. 컴포지션을 먼저 시도하면 코드가 더 단순해지고 memo 가 필요 없어지는 경우가 많다.

읽는 순서

  1. 1이론

    리액트 리렌더 트리거(상태 변경 / 부모 렌더)와 얕은 비교 규칙(Object.is)을 정리.

  2. 2구현

    React DevTools Profiler 로 "props 가 변경됐다"는 사유 표시를 직접 확인. 같은 컴포넌트를 (1) 컴포지션 분리, (2) memo + useCallback 으로 각각 최적화해 비용 차이를 측정.

  3. 3실무

    기존 코드의 memo 사용 부위를 감사: 호출 측 spread/children/중첩 memo 가 메모이제이션을 깨고 있지 않은지 점검 후 PR로 정리.

  4. 4설명

    "왜 memo 가 안 통하나요" 질문에 1) 참조 비교, 2) 함정 3가지, 3) 컴포지션 우선 원칙으로 답할 수 있게 시나리오별 답변 정리.

면접 연결 질문

medium`React.memo` 가 "자식 리렌더를 막아준다"는 표현이 부정확한 이유는?
힌트

[감점 답변] "props 가 같으면 안 렌더한다"만 답. [좋은 답변] (1) 비교는 얕은 참조 비교이며, (2) 객체/함수/JSX element 는 매 렌더 새 참조라서 부모에서 안정화하지 않으면 무력화된다. (3) 추가로 children 도 prop 이라 영향을 받는다. (4) 사실상 "같은 입력일 때만 효과"라는 조건부 도구.

medium`useCallback` 을 어떤 경우에 써야 하고, 어떤 경우엔 쓰지 말아야 하나요?
힌트

[감점 답변] "콜백 만들 때 항상 쓴다". [좋은 답변] 써야 할 때: (1) React.memo 로 감싼 자식에 콜백을 내릴 때, (2) useEffect 의존성에 들어갈 때, (3) 동일 참조가 의미 있는 외부 라이브러리 인자. 쓰지 말아야 할 때: 자식이 memo 가 아니거나, 의존성에 안 들어가는데 "습관적으로" 감쌀 때 — 오히려 메모이제이션 비용이 추가된다.

hard성능 최적화 전에 메모이제이션 대신 먼저 시도해볼 패턴은?
힌트

[감점 답변] "memo 부터 적용한다". [좋은 답변] (1) 컴포지션으로 상태 격리: 자주 바뀌는 상태를 더 작은 컴포넌트로 끌어내려 무거운 형제가 다시 렌더되지 않게. (2) 렌더 트리 분할: 비싼 트리를 children prop 으로 받기. (3) 상태를 위로 올리지 말기(필요한 경우에만 lift). (4) 이래도 안 되면 그제서야 memo + useCallback/useMemo.

자기 점검

`<Memo>{<Inner/>}</Memo>` 는 왜 매번 리렌더되는지 한 줄로 설명해보세요.
childrenelement identity새 참조
자주 하는 오해

JSX 안의 컴포넌트도 "같은 정의면 같다"고 생각하기. 실제로는 매 렌더 새 element 객체가 만들어진다.

`useCallback` 의 의존성 배열이 빈 `[]` 인 콜백이 항상 안전한가요?
stale closure최신 상태 캡처참조 보존
자주 하는 오해

"[] 면 무조건 안 바뀐다"는 안심. 대신 콜백 내부에서 옛 상태를 참조하는 stale closure 위험이 생긴다.