여러분은 React 애플리케이션이 느려질 때 가장 먼저 무엇을 생각하시나요? 아마도 React.memo
, useMemo
, useCallback
과 같은 메모이제이션 훅들일 것입니다. 불필요한 리렌더링을 막으면 성능이 향상될 것이라는 직관적인 생각 때문이죠. 하지만 React에서 메모이제이션은 생각보다 훨씬 복잡하고 미묘한 영역입니다.
오늘은 이러한 최적화 도구들이 실제로 어떻게 동작하는지, 그리고 예상치 못한 방식으로 실패할 수 있는 숨겨진 함정들을 살펴보겠습니다. 더 나아가 언제 이 도구들이 정말 도움이 되는지, 언제 단순히 불필요한 복잡성만 추가하는지에 대해서도 알아보겠습니다.
메모이제이션이 필요한 근본적 이유: 자바스크립트의 참조 비교
React에서 메모이제이션이 필요한 이유를 이해하려면, 먼저 자바스크립트가 값을 비교하는 방식을 알아야 합니다. 문자열, 숫자, 불리언과 같은 원시값은 실제 값으로 비교되지만, 객체, 배열, 함수는 참조로 비교됩니다.
// 원시값은 값으로 비교
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에서 문제가 되는 이유는 다음과 같습니다:
- 컴포넌트는 상태가 변경되거나 부모 컴포넌트가 리렌더링될 때 다시 렌더링됩니다
- 컴포넌트가 리렌더링되면 모든 지역 변수(객체와 함수 포함)가 새로운 참조로 재생성됩니다
- 이 새로운 참조들이 props나 훅의 의존성으로 전달되면 불필요한 리렌더링이나 이펙트 실행을 유발합니다
useMemo와 useCallback의 내부 동작 원리
React가 제공하는 메모이제이션 훅들은 렌더링 간의 참조를 보존해줍니다. 하지만 실제로 어떻게 동작할까요?
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
로 props를 메모이제이션하면 자식 컴포넌트의 리렌더링을 막을 수 있다고 생각하는 것입니다.
const Component = () => {
// 개발자들이 착각하는 부분: 이것이 자식 컴포넌트의 리렌더링을 막는다고 생각함
const onClick = useCallback(() => {
console.log("clicked");
}, []);
return <button onClick={onClick}>Click me</button>;
};
이는 완전히 잘못된 생각입니다. 부모 컴포넌트가 리렌더링되면, props가 변경되지 않았더라도 모든 자식 컴포넌트는 기본적으로 리렌더링됩니다. 프로퍼티 메모이제이션은 다음 두 가지 특정 상황에서만 의미가 있습니다:
- 해당 prop이 자식 컴포넌트의 훅 의존성으로 사용될 때
- 자식 컴포넌트가
React.memo
로 래핑되어 있을 때
React.memo의 실제 동작과 한계
React.memo
는 컴포넌트 렌더링 결과를 메모이제이션하는 고차 컴포넌트입니다. props의 얕은 비교를 통해 리렌더링 필요성을 판단합니다.
const ChildComponent = ({ data, onClick }) => {
// 컴포넌트 구현
};
const MemoizedChild = React.memo(ChildComponent);
const ParentComponent = () => {
// 메모이제이션 없이는 매 렌더링마다 새로운 참조 생성
const data = { value: 42 };
const onClick = () => console.log("clicked");
// MemoizedChild는 React.memo로 래핑되어 있지만
// props의 참조가 계속 변경되어 매번 리렌더링됨
return <MemoizedChild data={data} onClick={onClick} />;
};
이 문제를 해결하려면 useMemo
와 useCallback
이 필요합니다:
const ParentComponent = () => {
// 렌더링 간 안정적인 참조 유지
const data = useMemo(() => ({ value: 42 }), []);
const onClick = useCallback(() => console.log("clicked"), []);
// 이제 MemoizedChild는 props가 실제로 변경될 때만 리렌더링
return <MemoizedChild data={data} onClick={onClick} />;
};
React.memo의 숨겨진 함정들
React.memo
를 효과적으로 사용하는 것은 예상보다 훨씬 어렵습니다. 메모이제이션을 조용히 깨뜨릴 수 있는 몇 가지 함정들을 살펴보겠습니다.
1. 프로퍼티 스프레드 연산자의 위험
const Child = React.memo(({ data }) => {
// 컴포넌트 구현
});
// props가 변경될 수 있어 메모이제이션이 깨짐
const Parent = (props) => {
return <Child {...props} />;
};
이처럼 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의 메모이제이션 도구들을 사용해야 할까요?
React.memo를 사용해야 하는 경우
- 순수 함수형 컴포넌트가 동일한 props로 동일한 결과를 렌더링할 때
- 동일한 props로 자주 렌더링되는 경우
- 렌더링 비용이 높은 컴포넌트일 때
- 프로파일링을 통해 성능 병목점임을 확인했을 때
useMemo를 사용해야 하는 경우
- 매 렌더링마다 재계산할 필요가 없는 비용이 높은 계산이 있을 때
- 메모이제이션된 컴포넌트에 전달되는 객체나 배열의 안정적인 참조가 필요할 때
- 계산이 실제로 비싸다는 것을 측정으로 확인했을 때
useCallback을 사용해야 하는 경우
- 참조 동등성에 의존하는 최적화된 자식 컴포넌트에 콜백을 전달할 때
- 콜백이
useEffect
훅의 의존성으로 사용될 때 - 메모이제이션된 컴포넌트의 이벤트 핸들러에 안정적인 함수 참조가 필요할 때
컴포넌트 합성: 더 우아한 대안
메모이제이션을 적용하기 전에, 컴포넌트 구조를 합성을 통해 개선할 수 있는지 먼저 고려해보세요. 컴포넌트 합성은 종종 메모이제이션보다 더 우아하게 성능 문제를 해결합니다.
예를 들어, 비용이 높은 컴포넌트를 매번 리렌더링하는 대신:
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에서의 메모이제이션은 강력한 최적화 기법이지만, 숙련된 개발자조차도 실수할 수 있는 미묘한 부분이 많습니다. React.memo
, useMemo
, useCallback
을 코드 전체에 무분별하게 적용하기 전에 다음을 고려해보세요:
- 첫째, 프로파일링부터 시작하세요. React DevTools Profiler를 사용해 실제 성능 병목점을 식별하는 것이 우선입니다. 추측이 아닌 데이터에 기반해 최적화해야 합니다.
- 둘째, 컴포넌트 합성을 고려하세요. 컴포넌트 구조를 재구성하면 메모이제이션의 필요성을 완전히 제거할 수 있는 경우가 많습니다.
- 셋째, 함정을 주의하세요. 메모이제이션이 조용히 깨질 수 있는 다양한 방법들을 인지하고 있어야 합니다.
- 넷째, 다시 측정하세요. 최적화가 실제로 성능을 향상시켰는지 확인해야 합니다.
메모이제이션을 신중하고 올바르게 사용하면 React 애플리케이션의 성능을 크게 향상시킬 수 있습니다. 하지만 주의 깊게 적용하지 않으면 복잡성만 높아지고, 오히려 성능이 저하될 수도 있습니다.
섣부른 최적화는 소프트웨어 개발에서 많은 문제의 근원임을 기억하세요. 함수형 프로그래밍 원칙에 따라 깔끔하게 컴포넌트를 합성하는 것부터 시작하고, 성능을 측정한 뒤, 메모이제이션이 정말 필요하다는 명확한 근거가 있을 때만 적용하세요.
여러분은 React 메모이제이션 도구들과 어떤 경험을 하셨나요? 불필요한 리렌더링을 피하는 다른 패턴을 발견하셨다면 공유해주세요!
참고 자료: cekrem.github.io, “React.memo Demystified: When It Helps and When It Hurts”