Lilyrs

Javascript에서의 Date 객체 비교할 때

지난 5월 경이었다. 아는 친구와 강변에서 맥주를 한 캔 까면서 나온 이야기 중 하나였다.

두 Date 객체가 있다. new Date(); 로 생성된 두 객체는 밀리초 단위까지 시간이 같다.
이 두 객체를, a와 b라고 하자.
이때 a < b 는 거짓, a > b 는 거짓이다. 그런데 a == b 도 a === b 도 거짓이다.
논리적 모순 아닌가?

확실히 일리 있는 말이다. 숫자 비교를 생각해보면 자명한 사실이다.

그러면 실제 코드로써 Date 를 비교해봐야 사실인지 확인할 수 있을 것이다. 아래는 해당 코드이다.

1
2
3
4
5
6
7
8
let a = new Date(), b = new Date();
console.info("a = " + a);
console.info("b = " + b);
console.info("milliseconds equality check: " + (a.getMilliseconds() === b.getMilliseconds()));
console.info("a > b : " + (a > b));
console.info("a < b : " + (a < b));
console.info("a ==b: " + (a == b));
console.info("a===b: " + (a === b));

이것의 출력은 다음과 같다.

a = Thu Sep 26 2019 20:35:15 GMT+0900 (Korean Standard Time)
b = Thu Sep 26 2019 20:35:15 GMT+0900 (Korean Standard Time)
milliseconds equality check: true
a > b : false
a < b : false
a ==b: false
a===b: false

Javascript의 동등 비교는 비범한 사례가 많다. 그 예로 속칭 자바스크립트의 삼위일체 라고 불리는 이미지가 많이 돌아다니곤 한다. 그리고 절대 == 대신 === 를 사용하라곤 한다. 하지만 구체적인 이유는 잘 언급하지 않는 경우가 많다.

이제 이 Date 객체의 비교 과정에서 일어나는 일을 추적해보자.

이 비교 연산은 ECMAScript Language Specification 에서 정의하는 동작에 따라 구현되어있다. 따라서 이 기괴한 동작을 이해하려면 언어의 사양을 봐야한다. 이 상황이 암시하는 건, 상기의 Date 객체 비교 동작이 절대 버그가 아니란 것이다.

ECMAScript 2015에서의 사양은 문서 번호 ECMA-262, 2015년 7월에 6판이 공개되어있다.[1]

먼저 대소비교 연산자의 스펙을 확인한다. 자명하지만, 어떻게 돌아가는지 검증해보는 것이 좋을 것 같다. 해당 문단은 7.2.11 절의 Abstract Relational Comparison 연산에 정의되어있다. 일단 보면 토할 것 같이 생겼다. 정신줄 잡고 쭉 읽어보면 다음과 같은 흐름이 이루어진다. 이 연산 절차는 x < y 를 기준으로 진행된다.

  1. 좌측 값을 x, 우측 값 y라고 한다.
  2. 좌측 우선(LeftFirst) 플래그가 켜진 경우, x 부터 Javascript의 기본 타입(Primitive Type)으로 변환한다. 이때 number 타입 힌트를 주기 때문에, 최대한 숫자로 변환하려 시도한다. 변환된 각 값들을 px, py라고 부른다.
  3. 좌측 우선(LeftFirst) 플래그가 없다면, y 부터 x 순서로 2번의 작업을 진행한다.
  4. 만약 px 와 py 가 Javascript의 기본 타입으로 변환된 결과 문자열이라면, 문자열에 알맞는 별도의 절차를 통해 비교한다.
  5. 그 외의 경우에는, px 와 py 가 숫자가 아니더라도 최대한 숫자로 변환한다. 숫자로 변환된 두 값을 각각 nx 와 ny 라고 한다.
  6. nx 가 NaN(Not a Number) 라면 더이상 숫자로써 비교가 불가하므로 undefined 를 반환한다.
  7. ny 가 NaN(Not a Number) 라면 더이상 숫자로써 비교가 불가하므로 undefined 를 반환한다.
  8. 만약에 nx 가 +0 이고 ny가 -0 이라면 false 를 반환한다. (수학에서 말하는, 0으로 수렴하는 좌극한과 우극한을 의미)
  9. nx 가 양의 무한대라면 false 를 반환한다. 왜냐면 nx 는 ny 보다 커질 수 있기 때문이다.
  10. ny 가 양의 무한대라면 true 를 반환한다. 왜냐면 ny 는 nx 보다 커질 수 있기 때문에 항상 x < y 를 만족시킬 수 있기 때문이다.
  11. ny 가 음의 무한대라면 false 를 반환한다. nx 가 무엇이든 ny 가 더 작아질 수 있기 때문이다.
  12. nx 가 음의 무한대라면 true 를 반환한다. ny 가 무엇이든 nx 가 더 작아질 수 있기 때문에 항상 x < y 를 만족시킬 수 있기 때문이다.
  13. 극단적인 경우를 모두 배제하였으므로 정상적인 비교 경우만 남았다. nx 가 ny 보다 수학적으로 작다고 판단되면 true 를 반환하고, 아니면 false 를 반환한다. 수학적 판단의 기준은, 이때 nx 와 ny 는 모두 무한대로 향해가는 수도 아니고 0도 아니라는 가정을 한다.

13번 절차에 대한 설명을 부연하면, nx 와 ny의 대소비교는 0이 아닌 두 숫자에 대해 대소관계가 성립하면 true 를 반환한다는 의미로 해석된다. 즉, 0과 0의 비교에 대해선 false 를 반환하겠다는 것과 같다.

우리의 두 Date 객체에 대한 이야기로 돌아오면, Date 를 최대한 숫자로 바꿔야만 비교를 할 수 있을 것 같은 느낌이 드는 건 어쩔 수 없는 것 같다. 만약에 유닉스 타임스탬프[2]를 사용하고 있다면 왠지 가능할 것 같다. 더 깊게 들어가면 힘들기 때문에, 대략적으로 그렇게 변환될거라 가정을 했다.[3][4]

둘 다 유닉스 타임이라면, 수학적 대소비교를 거치는데 이때 밀리초 단위까지 같다면 두 시간은 수학적으로 같을 것이다. 따라서 a > b 와 a < b 는 복잡한 과정을 거쳤지만 꽤나 확실하게 모두 false 로 평가됨을 확인할 수 있다.

그럼 a == b 와 a === b 에서는 무슨 일이 일어났을까? 이 두 연산은 7.2.12 Abstract Equality Comparison 과 7.2.13 Strict Equality Comparison 에 정의되어있다. 일단 == 의 동작부터 살펴본다. 미리 이야기하는거지만, 현재 두 Date 객체를 비교하는 상황에선 ==와 === 동작의 차이가 없다.

  1. x 와 y 의 값을 받아 타입을 비교한다. 두 타입이 같으면 7.2.13의 Strict Equality Comparison(===)으로 넘어간다
  2. (이하 생략)

두 값의 타입이 같을 때 == 와 === 는 동작의 차이가 없으므로 7.2.13으로 이동해서 다시 흐름을 살펴본다.

  1. x 와 y 의 타입이 다르면 false 를 반환한다.
  2. x 가 undefined 이나 null 이라면 true를 반환한다.
  3. x 가 Number 타입일때, x 또는 y 가 NaN(Not a Number) 일때 비교 불가 상태이므로 false 를 반환한다. 같은 숫자라면 true 를 반환하는데, +0 과 -0 은 서로 같은 것으로 처리한다. 앞의 상황을 모두 처리했다면 숫자가 다른 경우이므로 false.
  4. x 가 String 타입이라면, 서로 같은 문자로 구성되어있는지 비교하여 같으면 true.
  5. x 가 Boolean 타입이라면, 둘 다 같은 불리언 상태면 true, 아니면 false.
  6. x와 y가 같은 Symbol 값이라면 true
  7. x와 y가 같은 Object 라면 true
  8. 이외에는 모두 false 를 반환한다.

2, 3, 4, 5에서 x 의 타입만을 언급하는 이유에 대해 의문을 가질 수 있는데, 생각보다 분명한 근거가 1번에 있다. 두 값의 타입이 다르면 false 를 반환하도록 1번 조건에서 거르고 있다. 즉, 2번부터는 x 와 y 의 타입이 같다는 것이 암시되어있다.

두 Date 객체를 비교함에 있어서 포인트는 7번 조건이다.[5] 문서상에서는 8번 조건이다. Object가 서로 같으면 true 라는데, 여기에서 같은 Object를 식별하는 기준은 참조 주소(Reference)이다. 두 Date 객체 a 와 b 는 new 연산을 통해 생성된 다른 객체이므로 a === b 는 false 이다.

대소비교 연산자 설명에서 많은 시간을 쏟았지만, 동등 비교는 생각보다 간결하게 끝났다. 결국 정리하면, 두 Date 객체는 크고 작음을 비교할 때는 정상적으로 비교할 수 있지만 동등(Equality)를 따져야할 때는 수학적 비교가 아니라 Javascript 객체 대 객체의 비교가 이루어진다는 점으로 정리할 수 있을 것이다.

꽤 오래 전에 말로는 이 문제에 대한 해설을 정리했지만, 글로 옮겨놓으니 정말 길고 힘들다. 이 글을 읽을 누군가가 이걸 계기로, Javascript의 기묘한 동작에 대해 웃고 넘기지만 말고 스펙을 확인해볼 수 있는 힘을 기른다면 큰 보람을 느끼지 않을까 싶다.


  1. 1.ECMAScript 2015 Language Specification ↩
  2. 2.Unix Time ↩
  3. 3.이 부분을 더 깊게 보려면 ToPrimitive() 연산과 이어서 나오는 ToNumber 연산을 살펴보아야한다 ↩
  4. 4.이것이 가능하다고 가정한 이유는, Date 객체간의 뺄셈(subtract)이 가능하기 때문이다. A < B 의 양쪽 항에서 -B 를 더하면 A - B < 0 이 된다. 따라서 직접적인 Date 객체 비교를 지원하지 않아도, 우회하여 비교할 방법이 있을 거라 확신했다. ↩
  5. 5.문서상의 순서와는 다르게, 재구성한 점에 대한 양해를 구한다. 번역의 문제도 있는데, 모든 스텝을 적기에는 번거로운 점이 있었다. ↩