React 성능 최적화의 핵심: useCallback과 Props 비교 심층 분석
프론트엔드 개발에서 React의 성능 최적화는 떼려야 뗄 수 없는 주제입니다.
특히, 컴포넌트의 불필요한 리렌더링을 제어하는 useCallback과 React.memo는 핵심 도구입니다.
이 두 최적화 기법의 원리와 내부 동작 방식(특히 props 비교)을 깊이 있게 파헤쳐 보겠습니다.
1. useCallback 이해하기: 함수 메모이제이션의 필요성
React 컴포넌트가 리렌더링될 때마다, 그 컴포넌트 내부에 정의된 모든 함수는 메모리상에서 새로 생성됩니다.
자바스크립트에서 함수는 객체이므로, 새로운 함수는 이전 함수와 다른 메모리 주소(Reference)를 가지게 됩니다.
이 새로운 함수를 props로 자식 컴포넌트에 전달할 때 문제가 발생합니다.
💡 useCallback의 역할
useCallback Hook은 이 문제를 해결하기 위해 콜백 함수를 메모이제이션합니다.
const memoizedCallback = useCallback(
() => {
// 실행할 로직
},
[dependency1, dependency2], // 의존성 배열
);
메커니즘:
useCallback은 의존성 배열([dependency1, dependency2])의 값이 변경되지 않는 한, 최초에 생성했던 동일한 함수 인스턴스를 계속해서 반환합니다.효과: 동일한 함수 인스턴스를 자식에게 전달함으로써, 자식 컴포넌트가 함수 props 때문에 불필요하게 리렌더링되는 것을 방지합니다.
⚠️ 의존성 배열과 Stale Closure (오래된 클로저)
useCallback을 사용할 때 가장 주의해야 할 점은 의존성 배열입니다.
콜백 함수 내부에서 사용하는 모든 state, props, 혹은 다른 변수는 반드시 의존성 배열에 포함되어야 합니다.
- 문제점: 외부 변수를 빠뜨리면,
useCallback은 최초 렌더링 시점의 값(Stale Value)을 포함하는 함수를 계속 사용합니다. 이를 오래된 클로저(Stale Closure) 현상이라고 부르며, 런타임 버그의 주요 원인이 됩니다.
2. 최적화의 핵심 파트너: React.memo와 useCallback 시너지
useCallback이 함수 자체를 메모이제이션한다면, React.memo는 컴포넌트 자체의 리렌더링 여부를 결정합니다.
React.memo로 감싼 컴포넌트는 새로운 props가 이전 props와 다를 때만 리렌더링됩니다.
🔍 React.memo의 Props 비교 방식: 얕은 비교 (Shallow Comparison)
React.memo는 기본적으로 얕은 비교(Shallow Comparison)를 사용하여 props 변경 여부를 판단합니다.
타입 | 비교 기준 | 리렌더링 조건 (결과가 false일 때) |
원시 타입 | 값(Value)이 같은지 비교 |
|
객체 타입 | 메모리 주소(Reference)가 같은지 비교 |
가장 중요한 점: 객체 타입(객체, 배열, 함수)의 경우, 내용물이 같더라도 부모 컴포넌트가 리렌더링될 때 새로운 메모리 주소를 가진 객체가 생성되면 React.memo는 이를 '다른 props'로 판단하고 불필요하게 리렌더링을 합니다.
🎯 useCallback과 React.memo의 시너지 예시 코드
useCallback을 사용하여 함수 props의 메모리 주소를 고정해줘야만, React.memo의 얕은 비교가 성공적으로 true를 반환하며 리렌더링을 건너뛸 수 있습니다.
아래 예시는 의존성 배열의 두 가지 경우를 보여줍니다.
자식 컴포넌트 (OptimizedButton.jsx)
import React from 'react';
// 1. React.memo로 감싸서 props 변경 시에만 리렌더링하도록 최적화
const OptimizedButton = React.memo(({ onClick, label }) => {
// 이 로그는 onClick props가 변경될 때만 찍혀야 합니다.
console.log(`[OptimizedButton] ${label} 렌더링됨`);
return (
<button onClick={onClick}>
{label}
</button>
);
});
export default OptimizedButton;
부모 컴포넌트 (ParentComponent.jsx)
import React, { useState, useCallback } from 'react';
import OptimizedButton from './OptimizedButton';
const ParentComponent = () => {
const [count, setCount] = useState(0);
const [multiplier, setMultiplier] = useState(1);
// ----------------------------------------------------
// Case 1: 의존성 배열이 빈 배열 ([])인 경우 (함수 인스턴스 고정)
// ----------------------------------------------------
const handleEmptyDepClick = useCallback(() => {
// 경고: 빈 배열이지만 count의 최신 상태를 참조해야 하므로 함수형 업데이트 사용
setCount(prevCount => prevCount + 1);
}, []); // 의존성 배열이 비어있으므로, ParentComponent가 리렌더링되어도 이 함수는 절대 새로 생성되지 않음
// ----------------------------------------------------
// Case 2: 의존성 배열에 값이 있는 경우 ([multiplier])
// ----------------------------------------------------
const handleDepWithValueClick = useCallback(() => {
// multiplier의 현재 값을 참조하여 count를 증가시킴
setCount(prevCount => prevCount + multiplier);
}, [multiplier]); // multiplier 값이 변경될 때만 이 함수는 새로 생성됨
return (
<div>
<p>Count: {count} | Multiplier: {multiplier}</p>
{/* 부모 컴포넌트 리렌더링을 유발하는 버튼 */}
<button onClick={() => setMultiplier(m => m + 1)}>
Multiplier State 변경 (부모 리렌더링 유발)
</button>
{/* Case 1 버튼 */}
{/* multiplier가 변하여 부모가 리렌더링되어도 handleEmptyDepClick은 변하지 않아, */}
{/* OptimizedButton은 리렌더링되지 않음. (최적화 성공) */}
<OptimizedButton
onClick={handleEmptyDepClick}
label="Case 1: Empty Dep Call (최적화 성공)"
/>
{/* Case 2 버튼 */}
{/* multiplier가 변하면 handleDepWithValueClick이 새로 생성되어, */}
{/* OptimizedButton의 props가 변경된 것으로 간주되어 리렌더링됨. (의도된 리렌더링) */}
<OptimizedButton
onClick={handleDepWithValueClick}
label="Case 2: Value Dep Call (의존성 변경 시 렌더링)"
/>
</div>
);
};
export default ParentComponent;
시너지 설명
최적화 적용:
OptimizedButton은React.memo로 감싸져 있어,onClickprops가 변하지 않는 한 리렌더링을 건너뜁니다.Case 1 (Empty Dep):
handleEmptyDepClick은useCallback([], [])으로 정의되었습니다.ParentComponent가 리렌더링되어도 이 함수의 메모리 주소는 절대 변하지 않습니다.따라서
OptimizedButton은 리렌더링을 건너뛰어 최적화가 성공합니다.
Case 2 (Value Dep):
handleDepWithValueClick은useCallback([multiplier])로 정의되었습니다.multiplier값이 변경되면useCallback은 새로운 함수 인스턴스를 생성하여 반환합니다.OptimizedButton은 새로운onClick함수(다른 메모리 주소)를 받으므로,React.memo는 props가 변경되었다고 판단하여 의도된 리렌더링을 수행합니다.이를 통해 함수가 최신
multiplier값을 참조하도록 하면서도,multiplier가 변할 때만 리렌더링되도록 세밀하게 제어할 수 있습니다.
3. useCallback의 의존성 배열 비교 방식
useCallback이 의존성 배열의 변경을 감지하는 방식 역시 React.memo와 마찬가지로 얕은 비교를 사용합니다.
useCallback은 매 렌더링 시마다 의존성 배열 내의 모든 값에 대해 이전 값과 === 연산을 수행합니다.
$$\text{deps}[i]_{\text{prev}} === \text{deps}[i]_{\text{next}}$$
원시 타입: 값이 다를 때만 함수를 새로 생성합니다.
객체 타입: 메모리 주소가 다를 때만 함수를 새로 생성합니다.
💡 useCallback과 useMemo의 연계 사용
만약 useCallback의 의존성 배열 안에 객체나 배열이 포함되어 있다면, 해당 객체나 배열 역시 useMemo를 사용하여 메모이제이션해야 합니다.
// 매번 새로 생성되는 객체를 useMemo로 메모이제이션
const options = useMemo(() => ({ throttle: 300 }), []);
// options의 메모리 주소가 변할 때만 콜백 함수를 새로 생성
const handleScroll = useCallback((event) => { /* ... */ }, [options]);
useMemo를 통해 options 객체의 메모리 주소를 고정해주어야만, useCallback이 매 렌더링마다 options를 '다른 값'으로 인식하여 불필요하게 함수를 재생성하는 것을 막을 수 있습니다.
4. 최적화의 경계: 오버헤드(Overhead)
useCallback과 React.memo는 강력한 도구이지만, 무분별한 사용은 오히려 성능을 저해할 수 있습니다.
Hook 사용 비용:
useCallback과React.memo자체도 Hook을 호출하고, 의존성 배열을 비교하며, 메모리에 이전 값을 저장하는 작은 비용(Overhead)을 발생시킵니다.사용 지양 시점:
자식 컴포넌트가
React.memo로 최적화되지 않은 경우 (함수를 메모이제이션할 이유가 사라짐).컴포넌트의 리렌더링 비용이 Hook 사용 및 비교 비용보다 현저히 낮을 경우.
진정한 성능 최적화는 필요한 곳에만 적절히 이 도구들을 사용하는 것에서 시작됩니다.
성능 측정을 통해 리렌더링 문제가 발생하는 지점을 정확히 파악하고, 그 부분에만 useCallback과 React.memo를 적용하는 것이 중요합니다.
🧾 작성 참고
본 글은 Gemini를 활용하여 작성되었습니다. 기술 정보의 정확성을 위해 항상 노력하지만, 혹시 내용에 보완할 점이 있다면 언제든지 의견을 나눠주세요!