React을 활용하여 개발을 진행하다 보면 useCallback, useMemo를 통해 리렌더링을 방지하여 성능 최적화를 진행하는 경우가 많습니다.
하지만 객체나 배열을 의존성으로 사용할 때, 의도와는 다른 예상치 못한 리렌더링이 발생하여 성능 문제를 야기하기도 합니다.
이 문제의 핵심에는 바로 Javascript의
Object.is()의 비교 방식이 있습니다.
이번 글에서는 Object.is()가 React 훅의 의존성 배열 비교에 어떻게 사용되는지, 그리고 이로 인해 어떤 문제가 발생하며 어떻게 해결할 수 있는지 깊이 파고 들어 보겠습니다.
또한, 실제 케이스를 만들어 과연 실제 동작 방식을 톺아보도록 하겠습니다.
Javascript는 데이터를 원시형(Primitive type)과 참조형(Reference type)으로 나눕니다.
이 두 가지 타입의 가장 큰 차이점은 메모리에 저장되는 방식에 큰 차이가 있다는 점입니다.
스택은 크기가 정해진 데이터를 순서대로 쌓는 자료 구조입니다.
그렇기 때문에 데이터에 접근할 수 있는 접근 속도가 매우 빠릅니다.
종류로는 Number, String, Boolean, null, undefined, Symbol, BigInt 등이 있습니다.
힙은 크기가 동적으로 변하는 데이터를 저장하기 위한 넓은 메모리 공간입니다.
만약 변수에 힙에 저장되는 자료형의 값을 할당할 경우 데이터가 저장된 힙 메로리의 주소값(reference)만 스택에 저장됩니다.
종류로는 Object, Array, Function 등이 힙 메모리에 저장됩니다.

가장 큰 이유는 데이터 크기의 가변성 때문입니다.
원시형 데이터는 크기가 고정되어 있어 예측이 가능하지만, 객체나 배열과 같은 참조형 데이터는 프로퍼티가 추가되거나 요소가 늘어나면서 크기가 동적으로 변할 수 있습니다.
만약 참조형 데이터가 스택에 저장된다면, 크기가 변할 때마다 뒤에 쌓인 데이터들을 모두 이동시켜야 하는 비효율적인 상황이 발생합니다.

따라서 크기가 유동적인 데이터는 넓은 힙 공간에 자유롭게 저장하고, 스택에는 그 위치를 가리키는 주소값만 두어 효율적으로 메모리를 관리하는 것입니다.

두 값을 비교하는 과정은 개발에 있어서 굉장히 흔히 사용되는 로직입니다.
그러나 위에서 살펴보았던 저장 방식의 차이 때문에 뭔가 다른 문제가 야기될 수 있다는 것은 쉽게 추측이 가능합니다.
과연, 참조와 참조를 비교하면 어떻게 될까?
이러한 궁금점이Object.is()를 이해하는 핵심 열쇠가 됩니다.
Object.is()는 두 값이 같은 값인지 결정합니다.
다음 중 하나를 만족하면 두 값은 같습니다.
중요한 것은
Object.is()는== 연산자(동등 연산자) 와 같지 않고,
Object.is()는=== 연산자(일치 연산자)와도 같지 않다는 것입니다.
그렇다면 실제로 console.log()를 찍어보며 결과가 어떻게 나오는지 확인해보겠습니다.

여기서 주목할 코드는 다음과 같습니다.
console.log("Object.is({}, {}):", Object.is({}, {})); // false
단순히 생각해보면
{}와{}는 똑같이 생겼기 때문에true가 나온다고 생각할 수 있겠으나, 서로 다른 메모리 주소를 참조하므로 결과는false가 출력되게 됩니다.
이 점을 기억하면 이후 발생하는 불필요한 리렌더링 사례에 대해 더 잘 이해할 수 있게 됩니다.
React 소스 코드에서 의존성 배열을 비교하는 부분은 areHookInputsEqual 이라는 함수입니다. 이 함수는 이전 렌더링의 의존성 배열(prevDependencies)과 현재 렌더링의 의존성 배열(nextDependencies)을 받아와서 각 요소를 하나씩 비교합니다.
실제 React 코드는 다음과 같습니다.
// packages/react-reconciler/src/ReactFiberHooks.js
function areHookInputsEqual(
nextDeps: Array<mixed>,
prevDeps: Array<mixed> | null,
): boolean {
if (prevDeps === null) {
// This is the first render, so we should handle it separately.
return false;
}
// The length of the dependencies array should not change between renders.
// We'll leave this DEV-only warning here as a guardrail just in case.
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
// We use Object.is to compare the dependencies.
if (is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;
}
여기서 핵심은 if (is(nextDeps[i], prevDeps[i])) 부분입니다.
is 함수는 React 내부에서 사용하는 Object.is와 동일한 역할을 하는 유틸리티 함수입니다.
코드를 더 따라가 보면, 이 is 함수는 결국 Object.is를 사용하거나, Object.is가 없는 구형 브라우저 환경을 위해 동일한 로직을 직접 구현(폴리필, polyfill)해 놓은 것을 볼 수 있습니다.
is 함수의 정의:
// packages/shared/objectIs.js
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
/**
* inlined Object.is polyfill to avoid requiring consumers ship their own
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
*/
function is(x: any, y: any): boolean {
return (
(x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // SameValue algorithm
);
}
export default is;
결론적으로, 사용자가 작성한 코드에서 useCallback, useMemo, useEffect의 의존성 배열을 비교할 때 React는 내부적으로 Object.is와 동일한 로직을 사용하여 이전 값과 현재 값을 하나씩 비교합니다.
이 때문에 객체나 배열처럼 매번 새로운 메모리 주소로 생성되는 참조형 값을 의존성으로 넣으면, 실제 내용이 같더라도
Object.is비교 결과가false가 되어 메모이제이션이 깨지고 훅이 재실행되는 것입니다.
지금까지 살펴본 것처럼, useEffect, useCallback, useMemo의 동작 원리 중심에는 Object.is와 동일한 비교 로직이 자리 잡고 있습니다.
이 비교 방식이 특히 중요한 이유는 React의 렌더링 특성 때문입니다.
React는 상태(state)나 속성(props)이 변경되면 컴포넌트 함수 전체를 다시 호출하여 UI를 새로 그립니다.
이 과정에서 컴포넌트 내부에 선언된 모든 변수와 함수는 매번 새로 생성됩니다.
따라서 의존성 배열에 {}나 [] 같은 객체나 배열을 직접 넣으면, 이전 렌더링의 객체와 현재 렌더링의 객체는 내용이 완전히 같더라도 메모리 주소가 다른 별개의 존재가 됩니다.
결과적으로, Object.is({}, {})가 false를 반환하는 것과 같은 상황이 매 렌더링마다 발생합니다.
React는 이를 '의존성이 변경되었다'고 인식하고, 우리는 다음과 같은 의도치 않은 '비효율'을 마주하게 됩니다.
props를 useCallback의 의존성으로 사용하기 시작하면, 최적화의 연쇄 반응이 끊어지기 쉬운 복잡한 문제가 발생할 수 있습니다.
import React, { useCallback } from 'react';
import { SomeMemoizedComponent } from './SomeMemoizedComponent';
function OhNo({ onChange }) {
const handleChange = useCallback((e: React.ChangeEvent) => {
onChange?.(e);
}, [onChange]);
return <SomeMemoizedComponent onChange={handleChange} />;
}
위 코드의 의도는 명확합니다. SomeMemoizedComponent가 불필요하게 리렌더링되는 것을 막기 위해, onChange prop으로 전달될 handleChange 함수를 useCallback으로 감싸 항상 동일한 참조를 유지하려는 것입니다.
SomeMemoizedComponent는 React.memo로 감싸져 있으므로, onChange prop이 변경되지 않는 한 리렌더링되지 않을 것입니다.
[onChange]를 의존성으로 넣는 것은 올바른 코드입니다.
만약 그렇지 않다면 onChange값이 바뀌더라도 이전 onChange값만 기억하는 Stale Closure 버그가 발생할 수도 있습니다.
하지만 바로 이 지점에서 'OhNo'라는 이름처럼 문제가 발생할 수 있습니다.
이 때 발생할 수 있는 문제의 핵심은 OhNo 컴포넌트가 아닌, 이 컴포넌트를 사용하는 부모에게 있습니다.
만약 부모 컴포넌트가 onChange 함수를 아래와 같이 전달한다면 어떻게 될까요?
function ParentComponent() {
const [text, setText] = useState('');
// ParentComponent가 리렌더링될 때마다 매번 새로운 함수가 생성됨
const handleParentChange = (e) => {
console.log("Input changed:", e.target.value);
};
return (
<div>
<input type="text" onChange={(e) => setText(e.target.value)} />
<OhNo onChange={handleParentChange} />
</div>
);
}
위와 같은 경우, 다음과 같은 최적화 실패의 연쇄 반응이 일어납니다.
ParentComponent의 text 상태가 변경되어 리렌더링됩니다.
const handleParentChange = ... 코드가 다시 실행되면서 새로운 참조를 가진 함수가 생성됩니다.
이 새로운 함수가 OhNo 컴포넌트의 onChange prop으로 전달됩니다.
OhNo 컴포넌트의 useCallback은 의존성인 onChange가 이전 렌더링과 다른 참조를 가졌다고 판단합니다 (Object.is(oldOnChange, newOnChange) -> false).
결과적으로 useCallback은 handleChange 함수를 새롭게 생성합니다.
새로워진 handleChange 함수가 SomeMemoizedComponent에 전달됩니다.
SomeMemoizedComponent는 onChange prop이 변경되었다고 판단하여 결국 리렌더링됩니다.
결론적으로, useCallback과 React.memo를 통한 최적화 노력이 모두 수포로 돌아갑니다.
만약 위의 문제를 해결하기 위해서는 최적화의 연쇄 고리를 부모까지 확장해야 합니다.
즉, OhNo에게 전달하는 onChange prop의 참조 안정성(Referential Stability)을 부모 컴포넌트에서 보장해주어야 합니다.
function ParentComponent() {
const [text, setText] = useState('');
const handleParentChange = useCallback((e) => {
console.log("Input changed:", e.target.value);
}, []);
return (
<div>
<input type="text" onChange={(e) => setText(e.target.value)} />
<OhNo onChange={handleParentChange} />
</div>
);
}
위의 예제를 보여드렸듯이 메모이제이션은 너무 자주 깨집니다.
게다가 우리가 읽어야 할 코드에 쓸데없는 오버헤드와 복잡도만 늘리는 경우가 발생합니다.
그렇다면 어떻게 해야 메모리제이션을 효과적으로 쓸 수 있을까요?
Object.is
The Useless useCallback
[번역] React.memo 완벽 해부: 언제 쓸모 있고 언제 쓸모없는가