🤔 왜 내 useEffect는 멈추지 않을까?
React 개발을 하다 보면, 분명히 코드를 맞게 짰는데도
useEffect가 무한히 실행되거나, 자식 컴포넌트가 이유 없이
리렌더링 되는 경험을 해보셨을 겁니다.
대부분의 경우, 범인은 로직의 오류가 아니라 자바스크립트의
참조 무결성(Referential Integrity)이 깨졌기
때문입니다.
React에서 객체나 함수는 매 렌더링마다
새로운 메모리 주소를 할당받는데, React는 이를 '데이터가
변경되었다'고 인식하기 때문입니다.
오늘은 React 성능 최적화의 양날의 검인 useMemo와
useCallback을 통해 이 참조 무결성을 어떻게 지켜내는지, 그리고
의존성 배열(Dependency Array)을 어떻게 관리해야 버그를 막을 수 있는지 깊이
파헤쳐 보겠습니다.
{} === {}는
false입니다. 모양이 같아도 메모리 주소가 다르기
때문입니다. 이것이 React 최적화의 시작점입니다.🔍 1. 참조 동등성(Referential Equality) 이해하기
Hook을 배우기 전에, React가 데이터를 비교하는 방식을 먼저 이해해야
합니다.
React는 렌더링 전후의 Props나 State를 비교할 때
얕은 비교(Shallow Compare)를 수행합니다.
| 데이터 타입 | 비교 방식 | 결과 예시 |
|---|---|---|
| 원시 타입 (Primitive) | 값(Value) 자체를 비교 |
1 === 1 (True)
|
| 참조 타입 (Object, Array, Function) | 메모리 주소(Reference)를 비교 |
[] === [] (False)
|
문제는 컴포넌트 함수가 재실행될 때마다, 함수 내부에 선언된
객체와 함수들도 매번 새로 생성된다는 점입니다.
이로 인해 자식 컴포넌트인 React.memo가 적용되어 있어도,
Props로 전달되는 객체나 함수의 주소가 바뀌어
불필요한 리렌더링이 발생합니다.
🛠️ 2. useMemo와 useCallback의 정확한 사용법
A. useMemo: 값(Value)과 객체의 주소 저장
useMemo는 계산 비용이 많이 드는 함수의 리턴값을 캐싱하거나,
객체/배열의 참조를 고정할 때 사용합니다.
✅ 올바른 사용 예시:
const heavyValue = useMemo(() => calculateHugeData(data),
[data]);
const stableObject = useMemo(() => ({ id: 1, text: 'Hello' }),
[]);
이처럼 객체를 감싸주면 컴포넌트가 리렌더링 되어도
stableObject는 동일한 메모리 주소를
유지합니다.
B. useCallback: 함수(Function)의 주소 저장
자바스크립트에서 함수도 객체입니다. 컴포넌트 내부의 함수는 렌더링마다 새로
만들어집니다.
useCallback은
함수 인스턴스 자체를 메모이제이션하여, 자식 컴포넌트에
Props로 전달할 때 참조 무결성을 유지합니다.
⚠️ 언제 사용해야 할까요?
-
1. 자식 컴포넌트가
React.memo로 최적화되어 있을 때. - 2. 함수나 객체가 다른 Hook(useEffect 등)의 의존성 배열(Dependency Array)에 포함될 때.
🛡️ 3. 디펜던시(Dependency) 관리와 Stale Closure
useMemo와 useCallback 사용 시 가장 주의해야 할
점은 의존성 배열(deps) 관리입니다.
의존성 배열을 잘못 설정하면 최신 상태를 참조하지 못하는
Stale Closure(오래된 클로저) 문제가 발생하거나,
메모이제이션이 무용지물이 됩니다.
🚨 흔한 실수: 의존성 생략
"함수가 너무 자주 바뀌어서 무한 루프가 돌아요"라며 의존성 배열에서 특정
변수를 고의로 빼는 경우가 있습니다.
이는 React에게 거짓말을 하는 행위이며, 심각한 버그를
유발합니다.
❌ 잘못된 코드 (Stale Closure 발생):
const handleClick = useCallback(() => {
console.log(count); // count가 바뀌어도 항상 0만 출력될
수 있음
}, []); // count를 의존성에 넣지 않음
✅ 해결 방법 (함수형 업데이트):
const handleClick = useCallback(() => {
setCount(prev => prev + 1); // 최신 상태를 보장받음
}, []); // 의존성 없이도 안전함
상태 값을 직접 참조하는 대신 함수형 업데이트(Functional Update)를 사용하면, 의존성 배열을 비우면서도 항상 최신 값을 안전하게 업데이트할 수 있습니다.
✅ 과도한 최적화는 금물
참조 무결성을 유지하는 것은 중요하지만, 모든 함수와 객체에
useMemo와 useCallback을 감싸는 것은 오히려
성능 오버헤드를 발생시킬 수 있습니다.
메모이제이션 자체도 메모리를 사용하고 비교 연산을 수행하는 비용이 들기
때문입니다.
🚀 요약 및 실천 가이드
- 1. 원리 파악: 객체와 함수는 렌더링마다 참조 주소가 바뀝니다.
- 2. 적절한 사용: 자식 컴포넌트에 Props로 넘기거나, 의존성 배열에 들어갈 때만 메모이제이션 하십시오.
- 3. 정직한 배열: 의존성 배열에는 사용하는 모든 외부 변수를 넣어야 합니다. (ESLint 규칙 준수 추천)
React Hooks의 참조 관리 능력을 마스터한다면, 성능 문제 없이 복잡하고 거대한 애플리케이션을 안정적으로 제어할 수 있는 진정한 React 전문가로 거듭날 것입니다.

댓글 쓰기