메모이제이션의 약속
리액트 애플리케이션의 속도가 느려지기 시작하면, 많은 개발자가 가장 먼저 React.memo, useMemo, useCallback과 같은 도구를 떠올립니다. 불필요한 리렌더링을 막는 것이 성능을 높이는 가장 직관적인 방법처럼 보이기 때문입니다. 하지만 리액트 생태계에서 메모이제이션은 생각보다 훨씬 더 복잡합니다.
이 글에서는 이러한 도구가 실제로 내부에서 어떻게 동작하고 예상치 못한 방식으로 실패할 수 있는 미묘한 지점들을 알아보고, 언제 이 도구들이 정말 도움이 되는지와 단순히 불필요한 복잡성만 추가하는지에 대해 살펴보겠습니다.
아직 읽어보지 않으셨다면, 메모이제이션을 사용하지 않고 최적화하는 방법에 대한 제 이전 글들도 꼭 확인해 보시기 바랍니다.
문제 이해하기: 자바스크립트의 참조 비교
본질적으로 리액트에서 메모이제이션이 필요한 이유는 자바스크립트가 객체, 배열, 함수 등을 비교하는 방식에 있습니다. 문자열, 숫자, 불리언과 같은 원시값은 실제 값으로 비교되지만, 객체는 참조로 비교됩니다.
// 값으로 비교되는 원시값
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
이러한 점이 리액트에서 문제가 되는 이유는 다음과 같습니다.
- 컴포넌트는 자신의 상태가 변경되거나 부모 컴포넌트가 리렌더링 될 때 다시 렌더링 됩니다.
- 컴포넌트가 리렌더링 되면, 모든 지역 변수(객체와 함수 포함)는 새로운 참조로 다시 생성됩니다.
- 이러한 새로운 참조가 프로퍼티로 전달되거나 훅의 의존성 배열에 사용되면, 불필요한 리렌더링이나 이펙트 실행이 발생할 수 있습니다.
useMemo와 useCallback의 내부 동작 원리
이 문제를 해결하기 위해 리액트는 렌더링 간의 참조를 보존해 주는 메모이제이션 훅을 제공합니다. 그렇다면 이 훅들은 실제로 어떻게 동작할까요?
useMemo와 useCallback은 주로 리렌더링이 반복될 때에도 참조의 안정성을 유지하기 위해 존재합니다. 이 훅들은 값을 캐싱하고, 지정한 의존성이 변경될 때만 해당 값을 다시 계산합니다.
이 훅들이 내부적으로 수행하는 동작은 다음과 같습니다.
// 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는 전달받은 함수의 반환값을 캐싱한다는 점입니다.
가장 흔한 오해: 프로퍼티를 메모이제이션하기
가장 널리 퍼진 오해 중 하나는 useCallback이나 useMemo로 프로퍼티를 메모이제이션 하면 자식 컴포넌트의 리렌더링을 막을 수 있다고 생각하는 것입니다.
const Component = () => {
// 사람들은 이 코드가 자식 컴포넌트의 리렌더링을 막는다고 생각합니다
const onClick = useCallback(() => {
console.log("clicked");
}, []);
return <button onClick={onClick}>Click me</button>;
};
이것은 사실이 아닙니다. 부모 컴포넌트가 리렌더링 되면, 자식 컴포넌트는 프로퍼티가 변경되지 않았더라도 기본적으로 모두 리렌더링 됩니다. 프로퍼티를 메모이제이션 하는 것은 다음 두 가지 특정 상황에서만 도움이 됩니다.
- 해당 프로퍼티가 자식 컴포넌트의 훅 의존성 배열에 사용될 때
- 자식 컴포넌트가
React.memo로 감싸져 있을 때
React.memo의 실제 동작
React.memo는 컴포넌트 렌더링 결과를 메모이제이션 하는 고차 컴포넌트입니다. 프로퍼티의 얕은 비교를 통해 리렌더링이 필요한지 판단합니다.
const ChildComponent = ({ data, onClick }) => {
// 컴포넌트 구현
};
const MemoizedChild = React.memo(ChildComponent);
const ParentComponent = () => {
// 메모이제이션이 없다면, 매 렌더링마다 새로운 참조를 가짐
const data = { value: 42 };
const onClick = () => console.log("clicked");
// MemoizedChild 컴포넌트는 ParentComponent가 렌더 될 때 마다 리렌더링 됨
// React.memo로 래핑되어 있지만, 프로퍼티의 참조가 변경되기 때문
return <MemoizedChild data={data} onClick={onClick} />;
};
이 예시에서 React.memo는 프로퍼티의 참조가 계속 변경되기 때문에 리렌더링을 막지 못합니다. 이런 상황에서 useMemo와 useCallback이 유용하게 쓰입니다.
const ParentComponent = () => {
// 렌더링간의 동일한 참조를 가짐
const data = useMemo(() => ({ value: 42 }), []);
const onClick = useCallback(() => console.log("clicked"), []);
// 이제 MemoizedChild 컴포넌트는 프로퍼티의 참조가 실제로 변경되었을 때만 리렌더링 됨
return <MemoizedChild data={data} onClick={onClick} />;
};
React.memo의 숨겨진 함정
React.memo를 효과적으로 사용하는 것은 생각보다 어렵습니다. 메모이제이션을 조용히 깨뜨릴 수 있는 몇 가지 흔한 함정들을 살펴보겠습니다.
1. 프로퍼티 스프레드 연산자 문제
const Child = React.memo(({ data }) => {
// 컴포넌트 구현
});
// 프로퍼티가 변경되기 때문에 메모이제이션이 깨짐
const Parent = (props) => {
return <Child {...props} />;
};
이처럼 프로퍼티를 펼쳐서 전달하면, Child 컴포넌트가 프로퍼티로 받는 속성들이 안정적인 참조를 유지하는지 제어할 수 없습니다. 누군가 Parent 컴포넌트를 사용할 때 의도치 않게 메모이제이션을 깨뜨릴 수도 있습니다.
2. children 프로퍼티 문제
아마도 가장 놀라운 함정은 JSX의 children도 단순히 또 다른 프로퍼티일 뿐이며, 이 역시 메모이제이션이 필요하다는 점입니다.
const MemoComponent = React.memo(({ children }) => {
// 컴포넌트 구현
});
const Parent = () => {
// 이것은 메모이제이션을 깨뜨립니다! children은 매 렌더마다 새로 생성
return (
<MemoComponent>
<div>Some content</div>
</MemoComponent>
);
};
이 문제를 해결하려면, children도 메모이제이션 해야 합니다.
const Parent = () => {
const content = useMemo(() => <div>Some content</div>, []);
return <MemoComponent>{content}</MemoComponent>;
};
3. 중첩된 Memo 컴포넌트 문제
const InnerChild = React.memo(() => <div>Inner</div>);
const OuterChild = React.memo(({ children }) => <div>{children}</div>);
const Parent = () => {
// OuterChild의 메모이제이션은 깨집니다!
return (
<OuterChild>
<InnerChild />
</OuterChild>
);
};
두 컴포넌트 모두 메모이제이션되어 있더라도, InnerChild JSX 엘리먼트가 매 렌더마다 새로운 객체 참조를 생성하기 때문에 OuterChild는 여전히 리렌더링 됩니다. 해결 방법은 자식 엘리먼트도 메모이제이션 하는 것입니다.
const Parent = () => {
const innerChild = useMemo(() => <InnerChild />, []);
return <OuterChild>{innerChild}</OuterChild>;
};
실제로 언제 메모이제이션을 사용해야 할까?
이처럼 다양한 복잡함을 고려할 때, 실제로 언제 리액트의 메모이제이션 도구들을 사용해야 할까요?
React.memo를 사용해야 하는 경우.
- 동일한 프로퍼티가 주어졌을 때 항상 같은 결과를 렌더링하는 순수한 함수 컴포넌트일 때
- 동일한 프로퍼티로 자주 렌더링되는 경우
- 렌더링 자체가 계산 비용이 많이 드는 경우
- 프로파일링을 통해 실제로 성능 병목임을 확인한 경우
useMemo를 사용해야 하는 경우.
- 매 렌더마다 다시 계산할 필요가 없는 비용이 큰 연산이 있을 때
- 메모이제이션 된 컴포넌트에 전달되는 객체나 배열의 참조를 안정적으로 유지해야 할 때
- 실제로 해당 연산이 비용이 크다는 것을 측정하고 확인한 경우
useCallback을 사용해야 하는 경우.
- 참조 동일성(reference equality)에 의존하는 최적화된 자식 컴포넌트에 콜백을 전달할 때
- 해당 콜백이 useEffect 훅의 의존성 배열에 포함되어 있을 때
- 메모이제이션 된 컴포넌트에서 이벤트 핸들러의 함수 참조를 안정적으로 유지해야 할 때
합성(Composition)이라는 대안
메모이제이션을 적용하기 전에, 컴포넌트 구조를 합성을 통해 개선할 수 있는지 먼저 고려해 보세요. 컴포넌트 합성은 종종 메모이제이션보다 더 우아하게 성능 문제를 해결해 줍니다.
예를 들어, 아래와 같이 비용이 많이 드는 컴포넌트를 매번 리렌더링하는 것은 좋지 않습니다.
const ParentWithState = () => {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<ExpensiveComponent /> {/* count가 변경될 때마다 리렌더링 됨 */}
</div>
);
};
대신 상태를 분리하여 더 구체적인 컨테이너에 넣는 방식으로 개선할 수 있습니다.
const CounterButton = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
};
const Parent = () => {
return (
<div>
<CounterButton />
<ExpensiveComponent /> {/* count가 변경될 때 더 이상 리렌더링 되지 않음 */}
</div>
);
};
결론
리액트에서의 메모이제이션은 강력한 최적화 기법이지만, 숙련된 개발자조차도 실수할 수 있는 미묘한 부분이 많습니다. React.memo, useMemo, useCallback을 코드 전체에 무분별하게 적용하기 전에 다음을 꼭 고려해 보세요.
- 먼저 프로파일링하세요: 실제 성능 병목이 어디인지 React DevTools Profiler로 확인하세요.
- 합성을 고려하세요: 컴포넌트 구조를 재설계하면 메모이제이션이 필요 없을 수도 있습니다.
- 함정에 유의하세요: 메모이제이션이 조용히 무너질 수 있는 다양한 경우를 인지하세요.
- 다시 측정하세요: 최적화가 실제로 성능을 개선하는지 반드시 검증하세요.
메모이제이션을 신중하고 올바르게 사용하면 리액트 애플리케이션의 성능을 크게 향상시킬 수 있습니다. 하지만 주의 깊게 적용하지 않으면 복잡성만 높아지고, 오히려 성능이 저하될 수도 있습니다.
섣부른 최적화(premature optimization)는 소프트웨어 개발에서 많은 문제의 근원임을 기억하세요. 함수형 프로그래밍 원칙에 따라 깔끔하게 컴포넌트를 합성하는 것부터 시작하고, 성능을 측정한 뒤, 메모이제이션이 정말 필요하다는 명확한 근거가 있을 때만 적용하세요.
여러분은 리액트의 메모이제이션 도구들을 사용하면서 어떤 경험을 하셨나요? 불필요한 리렌더링을 방지하는 데 도움이 되었던 다른 패턴이 있다면 공유해 주세요. (오른쪽의 피드백 위젯을 통해 의견을 남겨주시면 감사하겠습니다.)
🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article을 구독해주세요!
'개발 > 번역' 카테고리의 다른 글
| [번역] 상태 기반 렌더링 vs 시그널 기반 렌더링 (2) | 2025.11.06 |
|---|---|
| [번역] CSS 길이 단위 이해하기 (1) | 2025.09.02 |
| [번역] 리액트의 개방-폐쇄 원칙: 확장 가능한 컴포넌트 만들기 (3) | 2025.06.29 |
| [번역] 클린 코드의 심리학: 우리가 지저분한 리액트 컴포넌트를 작성하는 이유 (0) | 2025.06.21 |
| [번역] 리액트에서의 의존성 역전: 테스트하기 쉬운 컴포넌트 만들기 (0) | 2025.05.25 |