NaN === NaN은 왜 false일까: 자바스크립트의 ===, Object.is, includes가 값을 비교하는 방식
JavaScript에서 NaN === NaN은 false인데 [NaN].includes(NaN)은 true입니다. ===, Object.is, includes·Map·Set이 NaN과 -0을 다르게 다루는 ECMA-262의 규칙을 코드와 함께 정리했습니다.
NaN === NaN은 false입니다. 그런데 [NaN].includes(NaN)은 true이고, [NaN].indexOf(NaN)은 -1입니다. 버그처럼 보이지만 셋 다 명세대로 나온 결과입니다. JavaScript 표준인 ECMA-262에는 두 값이 “같은지” 판정하는 알고리즘이 여러 개 있고, 연산자와 메서드마다 쓰는 알고리즘이 다릅니다.
아래 명세 내용은 2026년 10월 3일자 ECMA-262 최신 초안을 기준으로 했습니다. 코드 결과는 Node.js 24.13.0에서 실행해 확인했습니다.
차이는 두 경우에서만 생긴다
명세의 비교 알고리즘은 세 가지입니다. 엄격한 동등 비교(IsStrictlyEqual), SameValue, SameValueZero입니다. 셋 모두 먼저 두 값의 타입을 보고, 타입이 다르면 바로 false를 돌려줍니다. 숫자가 아니면 셋 다 같은 절차로 넘어갑니다. 문자열은 같은 코드 유닛이 같은 순서로 있는지, 객체는 같은 객체인지를 봅니다.
세 알고리즘의 결과가 갈리는 경우는 둘뿐입니다. NaN과 NaN을 비교할 때, 그리고 +0과 -0을 비교할 때입니다. NaN과 1처럼 그 밖의 숫자 조합은 셋 다 같은 답을 냅니다.
| 알고리즘 | NaN과 NaN |
+0과 -0 |
쓰는 곳 |
|---|---|---|---|
| IsStrictlyEqual | 다름 | 같음 | ===, indexOf, lastIndexOf, switch의 case |
| SameValue | 같음 | 다름 | Object.is |
| SameValueZero | 같음 | 같음 | includes, Map의 키, Set의 값 |
==는 두 값의 타입이 같으면 ===와 같은 결과를 냅니다. 그래서 NaN == NaN도 false입니다.
NaN은 왜 자기 자신과 같지 않을까
===가 숫자를 비교할 때 쓰는 단계(Number::equal)는 처음 두 줄이 “x가 NaN이면 false, y가 NaN이면 false”입니다. 상대가 무엇이든, 심지어 자기 자신이어도 한쪽이 NaN이면 결과는 false입니다.
JavaScript의 Number는 IEEE 754 배정밀도(binary64) 값입니다. IEEE 754에는 NaN을 나타내는 비트 패턴이 많지만, ECMA-262는 이를 하나의 NaN 값으로 다루고 JavaScript 코드에서는 서로 구별할 수 없다고 정합니다. NaN이 하나뿐이니 “NaN끼리는 같다”고 보는 SameValue와 SameValueZero도 성립합니다.
NaN === NaN; // false
Object.is(NaN, NaN); // true
[NaN].indexOf(NaN); // -1
[NaN].includes(NaN); // true
new Set([NaN, NaN]).size; // 1
배열에서 NaN의 위치가 필요하면 indexOf 대신 findIndex(Number.isNaN)을 씁니다. NaN인지 확인할 때도 함수를 골라야 합니다. 전역 isNaN은 인자를 먼저 숫자로 바꾸기 때문에 isNaN("abc")가 true입니다. Number.isNaN은 바꾸지 않으므로 같은 입력에 false를 돌려줍니다. 명세는 x !== x가 x가 NaN일 때만 true라는 점도 함께 적어 둡니다.
-0은 어디서 생기고 왜 놓치기 쉬울까
Number에는 +0과 -0이 따로 있습니다. ===는 둘을 같다고 보고 Object.is는 다르다고 봅니다.
-0을 놓치기 쉬운 이유 하나는 문자열 변환입니다. 숫자를 문자열로 바꾸는 규칙(Number::toString)은 +0과 -0 모두에 "0"을 돌려줍니다. 그래서 String(r)이나 문자열로 이어 붙인 로그 메시지에서는 -0도 0으로 찍힙니다. 개발자 도구 콘솔처럼 값을 직접 보여 주는 곳에서는 -0으로 표시될 수 있습니다.
const r = Math.round(-0.4);
String(r); // "0"
r === 0; // true
Object.is(r, -0); // true
1 / r; // -Infinity
Math.round는 -0.5 이상이면서 0보다 작은 값에 -0을 돌려주도록 명세돼 있습니다. 양수를 -0으로 나누면 -Infinity가 되므로, 이런 값이 나눗셈에 들어가면 부호 차이가 결과에 드러납니다. +0과 -0을 구별해야 할 때는 Object.is를 씁니다.
includes, Map, Set은 0을 하나로 본다
Array.prototype.includes는 SameValueZero로 비교합니다. 명세는 indexOf와 다른 점 두 가지를 따로 적어 둡니다. 하나는 NaN을 찾을 수 있다는 점이고, 다른 하나는 비어 있는 배열 칸을 건너뛰지 않고 undefined로 본다는 점입니다.
[-0].includes(0); // true
[, 1].includes(undefined); // true
[, 1].indexOf(undefined); // -1
명세는 Map의 키와 Set의 값도 SameValueZero 의미로 구별한다고 각 장 첫머리에 적어 둡니다. 실제 알고리즘은 값이 -0이면 먼저 +0으로 바꾼 다음 SameValue로 비교하는 식으로 쓰여 있습니다. 그래서 -0으로 넣은 키를 꺼내 보면 +0입니다.
const m = new Map([[-0, "zero"]]);
m.get(0); // "zero"
Object.is([...m.keys()][0], -0); // false
어떤 비교를 쓸까
- 보통의 비교는
=== - 값이 NaN인지 확인할 때는
Number.isNaN(x) - 배열에 NaN이 있는지는
includes, 위치까지 필요하면findIndex(Number.isNaN) - +0과 -0까지 구별해야 하면
Object.is
객체는 어느 방법을 쓰든 같은 객체인지만 봅니다. 내용이 같아도 Object.is({}, {})는 false입니다.