녕후킴

equality 방법 비교하기

0 views

본인은 지금까지 어떤 두 객체를 비교하기 위해서 JSON.stringify를 써왔다. 그러다가 lodash라는 라이브러리에서 isEqual을 제공한다는 것을 알고 있었는데, 문득 JSON.stringify 해주면 끝나는 것을 왜 별도 라이브러리를 설치하면서까지 객체를 비교해야하나 싶어서 리서치하여 정리했다.


JSON.stringify의 문제점

JSON.stringify가 갖는 문제점은 다음 코드들처럼 예상과 다른 로깅 결과로부터 알수있다.

console.log(JSON.stringify({ a:1, b:2 }) === JSON.stringify({ b:2, a:1 })); // false
console.log(JSON.stringify(NaN) === JSON.stringify((null))) // true

더불어서 다음 코드와 같이, 순환 참조하는 객체에 대해서 콘솔을 찍어보면 “Uncaught ReferenceError: Cannot access ‘a’ before initialization” 에러가 발생한다.

const a = {
  b: a,
}

console.log(JSON.stringify(a)); // Uncaught ReferenceError: Cannot access 'a' before initialization

=== Equality, Shallow Eqaulity, Deep Equality 비교

두 객체의 동일함을 판단하기위한 방법으로 JSON.stringify를 이용한 방법, Shallow Equality를 이용한 방법, Deep Eqaulity를 이용한 방법이 존재한다. JSON.stringify는 객체를 JSON으로 만들어서 비교하는 방법이다.

그렇다면 Shallow Equality와 Deep Equality는 무엇일까? 다음 예시들을 통해서 알아보자.

const user1 = {
  name: "John",
  address: {
      line1: "55 Green Park Road",
      line2: "Purple Valley"  
  }
};

const user2 = user1;

console.log(user1 === user2); // true
console.log(shallowEqual(user1, user2)); // true
console.log(deepEqual(user1, user2)); // true

위 코드에서 ===는 reference equality를 기반으로 동작한다. user1과 user2는 동일한 주소를 가리키기 때문에 true를 출력한다. 만약 다음과 같이 user2에 객체를 만들어서 할당한다면 false가 출력되게된다.

const user2 = {
  name: "John",
  address: user1.address,
}

console.log(user1 === user2); // false
console.log(shallowEqual(user1, user2)); // true
console.log(deepEqual(user1, user2)); // true

그럼에도 불구하고 shallowEqual은 여전히 true를 출력해낸다. shallowEqual은 프로퍼티 하나하나에 대해서 ===을 적용하기 때문에, user2의 address reference가 동일함으로 true를 출력하는 것이다. 만약 다음과 같이 user2의 address 프로퍼티에 새로운 객체를 만들어서 값을 할당한다면, 결과는 false가 나오게 될것이다.

const user2 = {
  name: "John",
  address: {
      line1: "55 Green Park Road",
      line2: "Purple Valley"  
  }
}

console.log(user1 === user2); // false
console.log(shallowEqual(user1, user2)); // false
console.log(deepEqual(user1, user2)); // true

지금까지 user2를 다양하게 수정했음에도 불구하고 deepEqual은 계속해서 true를 출력하고 있다. 이유는 deep Equal이 프로퍼티 내부를 전부 비교하기 때문이다.


Shallow Equality와 Deep Equality는 어떻게 만들 수 있을까?

자바스크립트에서는 비교를 위해서 ==, ===연산자와 Object.is 메서드를 이용해서 비교를 한다. 그렇다면 두 변수를 깊게(deeply) 비교하기 위해서는 어떻게 해야할까?

== 연산자는 값을 비교하기에 앞서 동일한 타입을 갖도록 변환한 후 비교를 진행하는, 굉장히 느슨한 비교(loose eqaulity operator)연산자다. ===연산자는 ==연산자와는 다르게 타입 변환 과정 없이 비교를 진행하는 엄격한 비교(strict equaility operator)연산자다. 하지만 ===연산자도 다음과 같은 허점이 존재한다.

console.log(+0 === -0); // true
console.log(NaN === NaN); // false

Object.is는 대부분의 경우 ===연산자와 동일하게 동작하지만, 앞선 두 케이스와는 반대되는, 올바른 결과를 내놓는다.

console.log(Object.is(+0, -0)); // false
console.log(Object.is(NaN, NaN)); // true

다만 이것이 Object.is===보다 더 엄격하게 비교한다는 것은 아니다. 상황에 따라서 둘중 하나를 선택하면 된다.

Deep Eqaul은 여러가지 edge case를 고려해야 하기 때문에 성능이 느릴 수 있다. 그래서 React에서는 상태 변화 비교를 위해 Shallow Equal을 이용한다. Shallow Equal은 다음과 같이 구현될 수 있다.

import is from './objectIs';
import hasOwnProperty from './hasOwnProperty';

function shallowEqual(objA: mixed, objB: mixed): boolean {
  // P1
  if (is(objA, objB)) {
    return true;
  }

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

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

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

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

  return true;
};
  • P1은 === 연산으로 비교하여 동일하면 true를 return한다
  • P2는 이후 로직을 실행시키기 위해 객체가 아닌 경우를 return한다
  • P3은 키들의 개수가 다른 경우 false를 return한다
  • P4는 본격적으로 프로퍼티를 하나하나 비교하며 값이 객체인 경우 재귀적으로 비교한다.

그리고 Deep Equal은 다음과 같이 구현된다.

const deepEqual = (objA, objB, map = new WeakMap()) => {
  // P1
  if (Object.is(objA, objB)) return true;

  // P2
  if (objA instanceof Date && objB instanceof Date) {
    return objA.getTime() === objB.getTime();
  }
  if (objA instanceof RegExp && objB instanceof RegExp) {
    return objA.toString() === objB.toString();
  }

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

  // P4
  if (map.get(objA) === objB) return true;
  map.set(objA, objB);

  // P5
  const keysA = Reflect.ownKeys(objA);
  const keysB = Reflect.ownKeys(objB);

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

  // P7
  for (let i = 0; i < keysA.length; i++) {
    if (
      !Reflect.has(objB, keysA[i]) ||
      !deepEqual(objA[keysA[i]], objB[keysA[i]], map)
    ) {
      return false;
    }
  }

  return true;
};
  • P1, P3, P6, P7은 Shallow Eqaul과 동일하다.
  • P2에서 Date와 RegExp의 경우의 비교를 진행한다.
  • P4에서 순환 참조인 경우 true를 반환한다.
  • P5에서는 Shallow Equal과는 다르게 Object.keys로 키를 얻는게 아니라, Reflect.ownKeys로 키들을 얻는다.

Reflect와 WeakMap에 대해서는 설명을 건너뛰겠다. 궁금하다면 ReflectWeakMap을 참고하자

📚 참고문헌

JavaScript deep object comparison - JSON.stringify vs deepEqual

Is it fine to use JSON.stringify for deep comparisons and cloning?

How to Get a Perfect Deep Equal in JavaScript?