본문 바로가기
기술의기록

React 메모이제이션 함정과 해결책 완벽 가이드

by Jeremy Winchester 2025. 8. 14.
반응형

리액트 앱이 느려질 때마다 가장 먼저 떠오르는 해결책이 있나요? 바로 React.memo, useMemo, useCallback 같은 메모이제이션 도구들이죠. 하지만 이런 최적화 기법들이 때로는 독이 될 수도 있다는 사실, 알고 계셨나요?

오늘은 React 메모이제이션의 숨겨진 함정들과 올바른 사용법에 대해 깊이 있게 알아보겠습니다. 단순히 성능을 위해 무작정 적용했다가 오히려 앱이 더 복잡해진 경험이 있으시다면, 이 글이 큰 도움이 될 거예요!

🎯 React 메모이제이션이 필요한 이유

JavaScript 참조 비교의 함정

React에서 메모이제이션이 필요한 근본적인 이유는 JavaScript의 참조 비교 방식 때문입니다.

 
 
javascript
// 원시값은 값으로 비교
const a = 1;
const b = 1;
a === b; // true

// 객체는 참조로 비교
const objA = { id: 1 };
const objB = { id: 1 };
objA === objB; // false - 서로 다른 참조!

// 같은 참조를 가져야 true
const objC = objA;
objA === objC; // true

이것이 React에서 문제가 되는 이유:

  • 컴포넌트가 리렌더링될 때마다 모든 지역 변수가 새로 생성됨
  • 객체나 함수들이 새로운 참조를 가지게 됨
  • 이로 인해 불필요한 리렌더링이나 useEffect 실행이 발생

⚡ useMemo와 useCallback 동작 원리

내부 구조 이해하기

두 훅의 개념적 구현을 살펴보면:

 
 
javascript
// useCallback 개념적 구현
let cachedCallback;
const useCallback = (callback, dependencies) => {
  if (dependenciesHaventChanged(dependencies)) {
    return cachedCallback;
  }
  cachedCallback = callback;
  return callback;
};

// useMemo 개념적 구현  
let cachedResult;
const useMemo = (factory, dependencies) => {
  if (dependenciesHaventChanged(dependencies)) {
    return cachedResult;
  }
  cachedResult = factory();
  return cachedResult;
};

핵심 차이점:

  • useCallback: 함수 자체를 캐싱
  • useMemo: 함수의 실행 결과를 캐싱

🚨 가장 흔한 오해: Props 메모이제이션

많은 개발자들이 이렇게 생각합니다:

 
 
javascript
const Component = () => {
  // "이렇게 하면 자식 컴포넌트 리렌더링이 방지될 거야!"
  const onClick = useCallback(() => {
    console.log("clicked");
  }, []);

  return <button onClick={onClick}>클릭하세요</button>;
};

하지만 이는 완전히 잘못된 생각입니다!

부모 컴포넌트가 리렌더링되면, 기본적으로 모든 자식 컴포넌트가 함께 리렌더링됩니다. Props가 변했든 안 변했든 상관없이요.

Props 메모이제이션이 도움이 되는 경우는 딱 두 가지뿐:

  1. 자식 컴포넌트에서 해당 prop을 훅의 의존성으로 사용할 때
  2. 자식 컴포넌트가 React.memo로 감싸져 있을 때

🛡️ React.memo의 올바른 이해

React.memo 작동 방식

 
 
javascript
const ChildComponent = ({ data, onClick }) => {
  // 컴포넌트 구현
};

const MemoizedChild = React.memo(ChildComponent);

const ParentComponent = () => {
  // 매번 새로운 참조 생성 - 메모이제이션 무효화!
  const data = { value: 42 };
  const onClick = () => console.log("clicked");

  // MemoizedChild는 React.memo에도 불구하고 매번 리렌더링됨
  return <MemoizedChild data={data} onClick={onClick} />;
};

올바른 사용법

 
 
javascript
const ParentComponent = () => {
  // 안정적인 참조 유지
  const data = useMemo(() => ({ value: 42 }), []);
  const onClick = useCallback(() => console.log("clicked"), []);

  // 이제 props가 실제로 변경될 때만 리렌더링
  return <MemoizedChild data={data} onClick={onClick} />;
};

💥 React.memo 숨겨진 함정들

1. Props Spreading 문제

 
 
javascript
const Child = React.memo(({ data }) => {
  // 구현 내용
});

// 이렇게 하면 메모이제이션이 깨집니다!
const Parent = (props) => {
  return <Child {...props} />;
};

Props를 스프레드하면 어떤 속성들이 들어올지 제어할 수 없어 메모이제이션이 무력화됩니다.

2. Children Prop 문제

가장 놓치기 쉬운 함정 중 하나입니다:

 
 
javascript
const MemoComponent = React.memo(({ children }) => {
  // 구현 내용
});

const Parent = () => {
  // children이 매번 새로 생성되어 메모이제이션 무효화!
  return (
    <MemoComponent>
      <div>어떤 콘텐츠</div>
    </MemoComponent>
  );
};

해결책:

 
 
javascript
const Parent = () => {
  const content = useMemo(() => <div>어떤 콘텐츠</div>, []);

  return <MemoComponent>{content}</MemoComponent>;
};

3. 중첩된 메모 컴포넌트 문제

 
 
javascript
const InnerChild = React.memo(() => <div>내부</div>);
const OuterChild = React.memo(({ children }) => <div>{children}</div>);

const Parent = () => {
  // OuterChild의 메모이제이션이 깨집니다!
  return (
    <OuterChild>
      <InnerChild />
    </OuterChild>
  );
};

InnerChild JSX 요소가 매번 새로운 객체 참조를 생성하기 때문입니다.

📋 언제 메모이제이션을 사용해야 할까?

React.memo 사용 시점

사용해야 할 때:

  • 같은 props로 항상 같은 결과를 렌더링하는 순수 함수형 컴포넌트
  • 같은 props로 자주 렌더링되는 컴포넌트
  • 렌더링 비용이 높은 컴포넌트
  • 프로파일링을 통해 성능 병목이 확인된 컴포넌트

useMemo 사용 시점

사용해야 할 때:

  • 매 렌더링마다 재계산할 필요가 없는 비용이 큰 연산
  • 메모이제이션된 컴포넌트에 전달되는 객체/배열의 안정적 참조 유지
  • 실제로 비용이 큰 계산임을 측정으로 확인한 경우

useCallback 사용 시점

사용해야 할 때:

  • 참조 동등성에 의존하는 최적화된 자식 컴포넌트에 콜백 전달
  • useEffect 훅의 의존성으로 사용되는 콜백
  • 메모이제이션된 컴포넌트의 이벤트 핸들러

🏗️ 컴포지션을 통한 대안

메모이제이션보다 컴포넌트 컴포지션이 더 우아한 해결책인 경우가 많습니다.

Before: 메모이제이션 사용

 
 
javascript
const ParentWithState = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>증가</button>
      <ExpensiveComponent /> {/* count 변경 시마다 리렌더링 */}
    </div>
  );
};

After: 컴포지션 활용

 
 
javascript
const CounterButton = () => {
  const [count, setCount] = useState(0);

  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
};

const Parent = () => {
  return (
    <div>
      <CounterButton />
      <ExpensiveComponent /> {/* 더 이상 불필요한 리렌더링 없음 */}
    </div>
  );
};

🎯 메모이제이션 체크리스트

메모이제이션을 적용하기 전 다음 단계를 따라주세요:

1단계: 프로파일링 먼저

  • React DevTools Profiler로 실제 성능 병목 식별
  • 추측이 아닌 데이터 기반 최적화

2단계: 컴포지션 고려

  • 컴포넌트 구조 재설계로 문제 해결 가능한지 검토
  • 상태를 더 구체적인 컨테이너로 이동

3단계: 함정 주의

  • Props spreading, children prop, 중첩 컴포넌트 문제 확인
  • 메모이제이션이 올바르게 작동하는지 검증

4단계: 재측정

  • 최적화가 실제로 성능을 개선했는지 확인
  • 복잡성 증가 대비 성능 개선 효과 평가

🚀 성능 최적화 실전 팁

효과적인 최적화 전략

  1. 측정 → 분석 → 최적화 → 재측정 사이클 반복
  2. 함수형 프로그래밍 원칙 기반의 깔끔한 컴포넌트 구성
  3. 조기 최적화는 만악의 근원 - 필요성이 증명된 후 적용
  4. 복잡성 증가 vs 성능 개선 트레이드오프 고려

메모이제이션 안티패턴

피해야 할 것들:

  • 모든 컴포넌트에 무작정 React.memo 적용
  • 의존성 배열 없이 useMemo/useCallback 사용
  • 간단한 연산에 useMemo 남용
  • 프로파일링 없는 추측 기반 최적화

💡 마무리

React 메모이제이션은 강력한 최적화 도구이지만, 올바른 이해와 신중한 적용이 필요합니다. 무분별한 사용은 오히려 코드 복잡성만 증가시킬 수 있어요.

핵심은 실제 성능 문제가 있을 때만 사용하고, 컴포지션 패턴을 우선 고려하는 것입니다. 그리고 적용 후에는 반드시 실제 성능 개선 효과를 측정해보세요!

여러분의 React 프로젝트에서 메모이제이션을 어떻게 활용하고 계시나요? 댓글로 경험담을 공유해주시면 다른 개발자들에게도 큰 도움이 될 것 같아요! 💬

반응형