5th 코드로그 · equalities 만들기

허정석·2025년 7월 20일

TIL

목록 보기
5/19
post-thumbnail

shallowEquals


export const shallowEquals = (a: unknown, b: unknown) => {
  // === 가 아닌 Object.is() 사용
  if (Object.is(a, b)) {
    return true;
  }
  const isNull = a === null || b === null;
  const isNotObject = typeof a !== "object" || typeof b !== "object";
  // 둘 중에 하나라도 null 또는 object 타입이 아닐 경우
  if (isNull || isNotObject) {
    return false;
  }
  // 서로 다른 참조를 가진 객체 또는 배열
  const aKeys = Object.keys(a);
  const bKeys = Object.keys(b);
  /* 배열 얕게 비교 */
  if (aKeys.length !== bKeys.length) {
    return false;
  }
  /* 중첩된 구조 깊게 비교를 위한 a, b 를 인덱싱 가능한 타입으로 간주하도록 설정 */
  const objA = a as Record<string, unknown>;
  const objB = b as Record<string, unknown>;

  for (const key of aKeys) {
    // b 가 key 를 가지고 있지 않거나, a와 b의 값이 다르다는 조건
    if (!Object.hasOwn(b, key) || !Object.is(objA[key], objB[key])) {
      return false;
    }
  }
  return true;
};

Object.is(value1, value2)

Object.is() 는 두 개의 값이 같은 값인지를 결정하는 내장 함수(built-in function)입니다. "같다"는 것을 판단하는 기준이 우리가 흔히 사용하는 == (동등 연산자)나 === (일치 연산자)와 미묘하게 달라서, 특정 엣지 케이스(edge case)들을 더 정확하게 처리할 수 있습니다.

  • === (일치 연산자)와의 차이점

    대부분의 경우 Object.is(a, b)는 a === b와 동일하게 동작하지만, 두 가지 중요한 예외가 있습니다.

  1. NaN (Not a Number)

    • NaN === NaN는 false입니다. NaN은 자기 자신과도 같지 않다고 판단되기 때문이죠.
    • 하지만 Object.is(NaN, NaN)는 true입니다. 논리적으로 NaN이라는 값 자체는 동일하다고 보는 것이 맞을 때가 있는데, Object.is는 이 경우를 true로 처리해 줍니다.
  2. +0-0

    • +0 === -0는 true입니다. 두 값은 수학적으로는 같다고 취급됩니다.
    • 하지만 Object.is(+0, -0)는 false입니다. 두 값의 부호가 다르므로, 메모리 상의 표현이 다르다는 것을 명확히 구분합니다.

shallowEquals에서 Object.is()를 먼저 사용하는 이유

  • 효율성
    • a와 b가 같은 숫자(e.g., 5, 5), 같은 문자열(e.g., 'hello', 'hello'), 또는 정확히 동일한 객체나 배열을 참조하고 있다면,
      Object.is()가 즉시 true를 반환하고 함수가 종료됩니다. 뒤따르는 복잡한 키 비교 로직을 실행할 필요가 없어 매우 효율적입니다.
  • 정확성
    • NaN과 같은 특수한 값을 올바르게 처리할 수 있습니다. 예를 들어, shallowEquals(NaN, NaN)이 true가 되도록 보장합니다.

💡
Object.is()는 ===보다 더 엄격하고 정확하게 "같은 값"을 비교하는 도구라고 생각하시면 됩니다.
shallowEquals의 첫 단계에서 이를 사용하면, 가장 간단하고 확실한 케이스들을 빠르게 처리하고 넘어갈 수 있습니다.

🔗 Object.hasOwn()

Record

Object.is(a[key], b[key])에서 에러가 발생하는 이유??

➡️ TypeScript가 a와 b의 정확한 타입을 추론하지 못하기 때문

  • Record<Keys, Type> 이란?

    Record는 TypeScript에서 제공하는 특별한 도구(유틸리티 타입)로, 객체의 키(key)와 값(value)의 타입을 미리 정의할 때 사용합니다.

    Record<Keys, Type>은 이렇게 읽을 수 있습니다.

    "이 객체의 모든 키는 Keys 타입에 속하고, 모든 값은 Type 타입이어야 한다."

 shallowEquals.ts 코드에서의 사용

    1 // ...
    2   // 이 시점에서 a와 b는 object 타입이지만,
    3   // TypeScript는 a['someKey'] 같은 접근을 허용하지 않음.
    4   // a와 b의 타입은 아직 { a: 1 } 인지, { b: 'hello' } 인지 모르는 상태.
    5 
    6   const objA = a as Record<string, unknown>;
    7   const objB = b as Record<string, unknown>;
    8 
    9   // 이제 TypeScript는 objA와 objB를
   10   // "키는 문자열이고, 값은 아무거나(unknown) 올 수 있는 객체"로 인식함.
   11 
   12   for (const key of aKeys) {
   13     // 따라서 objA[key] 와 같은 코드에서 에러가 발생하지 않음.
   14     if (!Object.hasOwn(b, key) || !Object.is(objA[key], objB[key])) {
   15       return false;
   16     }
   17   }
   18 // ...
  1. 왜 Record가 필요한가? (문제 상황)

    shallowEquals 함수는 어떤 타입의 값이든 받을 수 있도록 파라미터 a와 b를 unknown으로 받습니다. 그리고 typeof a === 'object'와 같은 코드로 a가 객체라는 것까지 확인했습니다.

    하지만 TypeScript 입장에서는 a가 그냥 "객체"라는 사실만 알 뿐, 어떤 키들을 가지고 있는지, 그 키를 사용해 값에 접근(a[key])할 수 있는지는 전혀 모릅니다.
    그래서 a[key] 같은 코드를 쓰면 TypeScript가 객체가 문자열 key로 값을 꺼낼 수 있는 구조인지 보장을 못한다는 에러를 발생합니다.

  2. Record로 어떻게 해결하는가?

    const objA = a as Record<string, unknown>;

    이 코드는 일종의 "타입 강제 선언" 또는 "타입 단언(assertion)" 입니다. 우리는 TypeScript 컴파일러에게 다음과 같이 말해주는 것과 같습니다.

💬: "TypeScript야,
a는 막연한 object가 아님.
a는 키가 문자(string)이고 값은 일단 아무거나(unknown) 들어있는 객체임.
Record<string, unknown> 타입이니까 나 믿고 컴파일 ㄱㄱ."

  • string: 키의 타입. Object.keys()가 문자열 배열을 반환하므로, 키는 string이 맞습니다.
  • unknown: 값의 타입. 객체의 값이 숫자일 수도, 문자열일 수도, 또 다른 객체일 수도 있으므로, 어떤 타입이든 가능하다는 의미에서 unknown을 사용합니다.

💡 Record 타입으로 단언을 해주고 나면, TypeScript는 objA를 "문자열 키로 접근 가능한 객체"로 인식하게 됩니다.
그 결과, for 루프 안에서 objA[key]처럼 키를 사용해 객체의 값에 접근하는 코드가 타입 에러 없이 안전하게 통과되는 것입니다.

🔗 Typescript_Record


deepEquals


... shallowEquals 와 동일 생략
for (const key of aKeys) {
    // 깊은 비교를 위하여 재귀 호출
    if (!Object.hasOwn(b, key) || !deepEquals(objA[key], objB[key])) {
      return false;
    }
  }
... 생략

deepEquals 함수 자신에게 다시 인자로 넣어 호출해야 합니다.

   1 // shallowEquals 에서는...
   2 !Object.is(objA[key], objB[key])
   3
   4 // deepEquals 에서는...
   5 !deepEquals(objA[key], objB[key]) // <--- 바로 이 부분!
  • 이렇게 하면, 만약 objA[key]와 objB[key]가 숫자 1이라면 deepEquals(1, 1)이 호출되어 true를 반환할 것이고
    만약 { c: 3 }과 같은 객체라면 deepEquals({ c: 3 }, { c: 3 })이 호출되어 그 내부까지 다시 비교하게 됩니다.

✍️ 한 줄 회고

같은 듯 다르다.

0개의 댓글