왜 React는 객체 비교에 민감할까

2025-08-24

왜 필요한가?

React 개발을 하다 보면 다음과 같은 당황스러운 상황들을 만날 수 있다.

  • "분명 같은 객체인데 왜 useEffect가 무한 루프에 빠지지?"
  • "React.memo를 썼는데도 왜 계속 리렌더링이 일어나지?"
  • "객체를 의존성 배열에 넣으니까 이상하게 동작해..."

이 모든 문제의 원인은 React가 객체를 어떻게 비교하는지의 메커니즘에 이유가 숨어있을 수 있다. React의 모든 최적화(가상 DOM 비교, 컴포넌트 리렌더링 판단, 메모이제이션)가 이 객체 비교 방식에 의존하기 때문이다.

문제 상황

실제로 겪을 수 있을 법한 예제를 살펴보자.

javascript
function DataFetcher() {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(false)

  const fetchOptions = {
    method: 'GET',
    headers: { 'Authorization': 'Bearer token' }
  }

  useEffect(() => {
    setLoading(true)
    fetch('/api/data', fetchOptions)
      .then(res => res.json())
      .then(result => {
        setData(result)
        setLoading(false)
      })
  }, [fetchOptions])

  return loading ? <div>Loading...</div> : <div>{data}</div>
}

위 컴포넌트는 무한 루프에 빠진다. 왜 그럴까? 단계별로 분석해보자.

무한 루프가 발생하는 과정

1단계: 첫 렌더링

plain
컴포넌트 실행 → fetchOptions 객체 생성 (메모리 주소: 0xA001)
→ useEffect 실행 (첫 렌더링이므로)
→ API 호출 → setData 실행

2단계: 상태 업데이트로 리렌더링

plain
setData로 인한 리렌더링 → 컴포넌트 함수 다시 실행
→ fetchOptions 객체 다시 생성 (새로운 메모리 주소: 0xA002)

여기가 핵심이다. 똑같은 { method: 'GET', headers: {...} } 내용이지만, 자바스크립트에서는 새로운 객체로 취급된다.

3단계: React의 의존성 비교

plain
React: "이전 fetchOptions(0xA001)와 현재 fetchOptions(0xA002)를 비교해보자"
→ Object.is(0xA001, 0xA002) === false
→ "의존성이 변경되었네! useEffect를 다시 실행하자"

4단계: 무한 반복

plain
useEffect 재실행 → API 호출 → setData → 리렌더링
→ 새 fetchOptions 생성 → 의존성 변경 감지 → useEffect 재실행 → ...

이것이 바로 React가 객체 비교에 민감한 이유다. 개발자 눈에는 같은 객체지만, React는 참조(메모리 주소)로 비교하기 때문에 "다른 객체"로 인식한다.

자바스크립트의 객체 비교 방식 이해

원시 타입과 객체 타입의 차이점

자바스크립트에서 값을 비교하는 방식을 이해해보자.

javascript
let name1 = 'React'
let name2 = 'React'
console.log(name1 === name2)  // true

const config1 = { theme: 'dark' }
const config2 = { theme: 'dark' }
console.log(config1 === config2)  // false

원시 타입인 문자열은 값 자체를 비교해서 true가 나오지만, 객체는 참조를 비교해서 false가 나온다.

이것이 중요한 이유는 메모리에 저장되는 방식이 다르기 때문이다. 원시 타입은 값 자체가 변수에 저장되지만, 객체는 힙 메모리의 주소(참조)만 변수에 저장된다.

javascript
// 메모리 관점에서 보면
const obj1 = { name: 'Kim' }  // 변수: 0x001 → 힙: 0xA001번지에 실제 객체
const obj2 = { name: 'Kim' }  // 변수: 0x002 → 힙: 0xA002번지에 실제 객체

// 비교할 때는 주소를 비교
console.log(obj1 === obj2)  // 0xA001 === 0xA002 → false

여기서 핵심은 내용이 완전히 같아도 서로 다른 메모리 위치에 있으면 다른 객체로 인식된다는 점이다.

React의 비교 메커니즘 분석

Object.is()의 동작 원리

React는 기본적으로 Object.is()를 사용한다. packages/shared/objectIs.js에서 Object.is의 폴리필을 구현하고 있다.

javascript
function is(x, y) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) ||
    (x !== x && y !== y)
  );
}

const objectIs = typeof Object.is === 'function' ? Object.is : is;

네이티브 Object.is가 있으면 사용하고, 없으면 자체 구현한 is 함수를 사용한다.

Object.is의 실제 동작 예시

javascript
Object.is(5, 5)           // true
Object.is('hello', 'hello') // true
Object.is({}, {})         // false
Object.is(+0, -0)         // false (=== 는 true)
Object.is(NaN, NaN)       // true (=== 는 false)

이 함수가 하는 일은 기본적으로 === 비교이지만, +0-0, 그리고 NaN 케이스를 올바르게 처리한다는 점이다. 공식 문서에서도 useEffect는 Object.is를 사용해 의존성을 비교한다고 명시되어 있다. 이로 인해 React에서는 개발자가 기대하는 방식으로 특수한 값들이 비교된다.

ShallowEqual

React는 Object.is()만으로는 부족한 객체 비교를 위해 shallowEqual 함수를 사용한다.

javascript
// packages/shared/shallowEqual.js
function shallowEqual(objA, objB) {
  if (Object.is(objA, objB)) {
    return true
  }

  if (typeof objA !== 'object' || objA === null ||
      typeof objB !== 'object' || objB === null) {
    return false
  }

  const keysA = Object.keys(objA)
  const keysB = Object.keys(objB)

  if (keysA.length !== keysB.length) {
    return false
  }

  for (let i = 0; i < keysA.length; i++) {
    const currentKey = keysA[i]
    if (!objB.hasOwnProperty(currentKey) ||
        !Object.is(objA[currentKey], objB[currentKey])) {
      return false
    }
  }

  return true
}

위 함수의 실행 과정을 단계별로 분석해보면,

  1. 참조가 동일한지 검사해 같은 객체를 가리키는지 확인한다.
  2. 타입을 비교해 둘 다 객체인지 확인한다.
  3. 키 개수를 비교해 속성 개수가 다르면 즉시 false한다.
  4. 각 속성값을 비교해 1 depth 까지만 Object.is()로 비교한다.

이것이 "얕은 비교"라고 불리는 이유는 객체의 첫 번째 깊이만 검사하기 때문이다.

얕은 비교의 동작

얕은 비교가 어떻게 동작하는지 자바스크립트로 확인해보자.

javascript
const user1 = { name: 'Kim', age: 30 }
const user2 = { name: 'Kim', age: 30 }
shallowEqual(user1, user2)  // true
  1. Object.is(user1, user2) → false (다른 참조)
  2. 둘 다 객체임 → 계속 진행
  3. 키 개수 동일 (2개) → 계속 진행
  4. user1.name === user2.name → 'Kim' === 'Kim' → true
  5. user1.age === user2.age → 30 === 30 → true
  6. 모든 속성이 일치 → true 반환

하지만 중첩된 객체에서는 실패한다.

javascript
const profile1 = { user: { name: 'Kim', age: 30 } }
const profile2 = { user: { name: 'Kim', age: 30 } }
shallowEqual(profile1, profile2)  // false
  1. Object.is(profile1, profile2) → false
  2. 둘 다 객체임 → 계속 진행
  3. 키 개수 동일 (1개) → 계속 진행
  4. profile1.user === profile2.user 비교
    • {name:'Kim', age:30} === {name:'Kim', age:30}
    • 서로 다른 객체 참조 → false
  5. false 반환

이것이 React에서 문제가 되는 이유

의존성 배열에서의 함정

javascript
function WeatherWidget({ city }) {
  const [weather, setWeather] = useState(null)

  // 매 렌더링마다 새로운 객체 생성
  const apiConfig = {
    method: 'GET',
    headers: { 'API-Key': process.env.WEATHER_API_KEY }
  }

  useEffect(() => {
    fetch(`/api/weather/${city}`, apiConfig)
      .then(res => res.json())
      .then(setWeather)
  }, [city, apiConfig])  // apiConfig가 의존성 배열에 포함됨

  return <div>{weather?.temperature}°C</div>
}

위 코드는 무한 루프에 빠진다. 실행 과정을 자세히 살펴보자.

무한 루프 발생 과정

plain
1) 첫 렌더링
   컴포넌트 실행 → apiConfig 생성 (메모리 주소: 0xA001)
   → useEffect 실행 (첫 렌더링이므로)
   → fetch 호출 → 응답 받음 → setWeather(data) 실행

2) state 변경으로 리렌더링
   setWeather로 인한 리렌더링 → 컴포넌트 함수 다시 실행
   → 새로운 apiConfig 객체 생성 (메모리 주소: 0xA002)

3) React의 의존성 비교
   React: 이전 apiConfig(0xA001)와 현재 apiConfig(0xA002) 비교
   → Object.is(0xA001, 0xA002) === false
   → '의존성이 변경되었군.. useEffect를 다시 실행해야지'

4) 무한 반복
   useEffect 재실행 → fetch 호출 → setWeather
   → 리렌더링 → 새 apiConfig 생성 → useEffect 재실행...

실제로 확인해보기

2025-08-2411.36.34-ezgif.com-video-to-gif-converter.gif 내용은 완전히 동일하지만 React는 참조(메모리 주소)로 비교하기 때문에 다른 객체로 인식한다.

해결 방법

javascript
// 1: useMemo
function WeatherWidget({ city }) {
  const [weather, setWeather] = useState(null)

  const apiConfig = useMemo(() => ({
    method: 'GET',
    headers: { 'API-Key': process.env.WEATHER_API_KEY }
  }), [])

  useEffect(() => {
    fetch(`/api/weather/${city}`, apiConfig)
      .then(res => res.json())
      .then(setWeather)
  }, [city, apiConfig])

  return <div>{weather?.temperature}°C</div>
}

useMemo는 가장 일반적인 해결 방법이다. 의존성 배열이 빈 배열 []이므로 컴포넌트가 처음 마운트될 때 한 번만 객체를 생성하고, 그 이후로는 항상 같은 참조를 반환한다. 따라서 useEffect의 의존성 배열에 apiConfig를 포함해도 참조가 변하지 않으므로 무한 루프가 발생하지 않는다.

이 방법은 코드가 명확해 예측하기 쉽다. 코드를 보면 "이 객체는 한 번만 생성되고 재사용된다"고 바로 알 수 있다. 또한 나중에 의존성이 추가되어야 할 상황에도 쉽게 추가할 수 있다. 의존성 배열에 값을 추가하기만 하면 된다.

단점이라면 약간의 메모리 오버헤드와 초기 계산 비용이 있다는 점이다. 복잡한 객체를 생성하는 경우에는 오히려 성능상 이득이 있다.

javascript
// 2: useEffect 내부로 이동
function WeatherWidget({ city }) {
  const [weather, setWeather] = useState(null)

  useEffect(() => {
    const apiConfig = {
      method: 'GET',
      headers: { 'API-Key': process.env.WEATHER_API_KEY }
    }

    fetch(`/api/weather/${city}`, apiConfig)
      .then(res => res.json())
      .then(setWeather)
  }, [city])

  return <div>{weather?.temperature}°C</div>
}

이 방법이 동작하는 이유는 React가 의존성 배열에 있는 값들만 비교하기 때문이다. useEffect 내부에서 생성되는 객체는 의존성 배열에 포함시킬 필요가 없다. apiConfig 객체가 매번 새로 생성되지만 의존성 배열에 없으므로 무한 루프가 발생하지 않는다. city가 변경될 때만 useEffect가 실행되고, 그때마다 내부에서 새로운 apiConfig를 생성해서 사용한다. ESLint의 exhaustive-deps 규칙도 이 패턴을 허용한다.

다만 주의할 점이 있다. 만약 객체 내부에서 외부 변수(props나 state)를 참조한다면 반드시 해당 변수들을 의존성 배열에 포함해야 한다. 예를 들어 headers: { 'API-Key': apiKey }처럼 props의 apiKey를 사용한다면 [city, apiKey]로 의존성을 설정해야 한다.

javascript
// 3: 컴포넌트 외부에 정의
const API_CONFIG = {
  method: 'GET',
  headers: { 'API-Key': process.env.WEATHER_API_KEY }
}

function WeatherWidget({ city }) {
  const [weather, setWeather] = useState(null)

  useEffect(() => {
    fetch(`/api/weather/${city}`, API_CONFIG)
      .then(res => res.json())
      .then(setWeather)
  }, [city])

  return <div>{weather?.temperature}°C</div>
}

컴포넌트 외부에 객체를 정의하는 방법은 객체가 절대 변하지 않을 때 사용한다. API_CONFIG는 모든 컴포넌트 인스턴스가 공유하는 단일 객체이므로 참조가 항상 동일하다. 따라서 의존성 배열에 넣어도 무한 루프가 발생하지 않는다.

하지만 실제로는 의존성 배열에 넣을 필요도 없다. React는 컴포넌트 외부의 값들이 렌더링 사이클과 무관하다는 것을 알고 있기 때문이다. 이 방법의 장점은 메모리 효율성이다. 여러 컴포넌트 인스턴스가 있어도 객체는 하나만 존재한다. 단점은 환경변수나 전역 설정이 바뀌어도 자동으로 반영되지 않는다는 점이다.

이것이 바로 React가 객체 비교에 민감한 이유다. 개발자 눈에는 같은 객체지만, React는 완전히 다른 객체로 인식한다.

왜 React는 깊은 비교를 하지 않을까?

React가 shallow comparison을 선택한 이유는 성능뿐만 아니라 설계 철학과 관련이 있다.

React 공식 설계 원칙

React가 shallow comparison을 선택한 배경에는 몇 가지 기술적 고려사항들이 있다.

예측 가능한 성능

useEffect 문서에 따르면 의존성 비교는 Object.is를 사용한다고 명시되어 있다. 객체의 깊이를 미리 알 수 없는 상황에서 깊은 비교는 성능을 예측하기 어렵게 만들기 때문으로 추론된다.

불변성 기반 아키텍처

React 공식 문서의 "객체 State 업데이트하기" 섹션에서는 "객체나 배열을 업데이트할 때는 새로운 객체를 생성해야 한다"고 강조한다. 이런 불변성 패턴이 shallow comparison과 잘 맞아떨어진다.

javascript
// React가 권장하는 패턴
const [user, setUser] = useState({name: 'John', age: 30})

// 올바른 업데이트 방식 - 새로운 객체 생성
setUser(prev => ({...prev, age: 31}))

// 잘못된 업데이트 방식 - 기존 객체 변경
user.age = 31 
setUser(user)

명시적 의존성 관리

useEffect 문서에서는 "모든 의존성을 명시적으로 선언하라"고 권장한다. 이는 개발자가 어떤 값이 변경될 때 Effect가 실행되는지 명확히 알 수 있게 하기 위함으로 보인다.

메모리와 가비지 컬렉션 관점

깊은 비교는 메모리 사용량에도 영향을 미친다.

javascript
// 깊은 비교 시 발생하는 문제들
function deepCompare(obj1, obj2, visited = new WeakSet()) {
  // 순환 참조 체크를 위한 추가 메모리 필요
  if (visited.has(obj1)) return obj1 === obj2

  visited.add(obj1)  // WeakSet에 객체 추가

  // 재귀 호출로 인한 콜 스택 증가
  // 큰 객체의 경우 스택 오버플로우 위험
}

React 애플리케이션에서는 컴포넌트 트리가 깊고 state가 복잡할 수 있기 때문에, 매 렌더링마다 깊은 비교를 수행하면 가비지 컬렉션 압박이 증가한다.

React 팀의 실제 구현 선택

React 소스코드를 살펴보면 일관되게 shallow comparison을 사용한다.

javascript
// packages/shared/shallowEqual.js (React 소스코드)
function shallowEqual(objA, objB) {
  if (is(objA, objB)) return true

  // 타입 체크 후 1 depth만 비교
  for (let i = 0; i < keysA.length; i++) {
    if (!is(objA[currentKey], objB[currentKey])) {
      return false  // 깊이 들어가지 않고 즉시 false
    }
  }
  return true
}

// packages/react-reconciler/src/ReactFiberClassComponent.js
// PureComponent도 shallowEqual 사용
return !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)

왜 이것이 더 나은 선택인가

React의 shallow comparison 선택은 무엇보다 일관된 성능을 보장한다. 객체의 복잡성에 관계없이 O(n) 시간 복잡도를 유지하는데, 여기서 n은 객체의 첫 번째 레벨에 있는 속성의 개수를 의미한다.

javascript
const obj1 = {
  name: 'Kim',    // 1 depth 속성 1
  age: 30         // 1 depth 속성 2
}

const obj2 = {
  name: 'Kim',    // 1 depth 속성 1
  profile: {      // 1 depth 속성 2 (내용이 아무리 복잡해도)
    personal: {
      address: { city: 'Seoul', district: 'Gangnam', street: '...' },
      contacts: { phone: '...', email: '...', sns: [...] }
    },
    work: {
      company: '...',
      projects: [/* 수백 개의 프로젝트 */]
    }
  }
}

// shallow comparison에서는 둘 다 동일하게 2번의 비교만 수행

obj1이든 obj2든 shallow comparison은 항상 2번의 비교만 수행한다. profile 객체 안에 얼마나 복잡한 중첩 구조가 있든 상관없이 profile 참조 자체만 비교하기 때문이다. 반면 deep comparison이었다면 중첩된 모든 속성을 순회한다.

이렇게 함으로써 개발자가 변경 사항을 명시적으로 표현하도록 유도한다. 객체 내부의 값을 변경하고 싶다면 반드시 새로운 객체를 만들어야 한다. setUser({...user, age: 31})처럼 새 객체를 만드는 것을 보면 "age가 변경되었다"는 의도가 즉시 드러난다.

디버깅 관점에서도 참조가 바뀌면 확실히 무언가 변경된 것이므로 문제를 추적하기 쉽다. 깊은 비교에서는 거대한 객체 구조에서 정확히 어느 부분이 변경되었는지 찾아내기 어렵다. shallow comparison에서는 참조 변경 자체가 신호가 될 수 있다.

javascript
// 디버깅이 쉬운 경우
const prevState = state
const nextState = {...state, count: state.count + 1}
console.log('참조가 변했나?', prevState !== nextState)  // true
console.log('무엇이 변했나?', 'count가 변경됨')

// 디버깅이 어려운 경우 (deep mutation)
state.nested.deep.property.count += 1
console.log('참조가 변했나?', prevState !== state)  // false - 하지만 실제로는 변경됨
// 어느 부분이 변경되었는지 찾기 어려움

이런 설계 철학은 React 생태계 전반에 영향을 미쳤다. Immutable.js나 Immer 같은 불변성 도우미 라이브러리들이 발달한 것도 이런 맥락에서다. Redux의 상태 관리 철학, 함수형 프로그래밍의 확산, 심지어 최근의 상태 관리 라이브러리들까지 모두 불변성을 기반으로 한다. 결과적으로 React의 shallow comparison 선택은 성능상의 이유를 넘어 더 견고하고 예측 가능한 아키텍처를 만들어내는 역할을 했다.

references