이 글을 쓰게 된 계기
솔직히 7장 단 한 장으로 포스팅을 할 줄은 몰랐다. 단순히 그냥 연산자 개념만 알고 넘어갈 수 있을 줄 알았는데, 공부할수록 JS의 철학이 느껴져서 포스팅을 참을 수 없었다.
이걸 보다보면 아래의 상황에 조금 공감할 수 있을지도...
typeof NaN === 'number' // true
typeof null === 'object' // true
NaN === NaN // false
"5" + 1 // "51"
"5" - 1 // 4
하나하나 보면 "왜?"가 튀어나오는데, 모아놓고 보니까 패턴이 보였다. 이 언어는 에러를 던지는 걸 극도로 꺼린다. 뭔가 이상한 상황이 오면 죽는 대신 일단 뭐라도 결과를 내놓는다. NaN도, 암묵적 타입변환도, typeof null도 전부 같은 뿌리에서 나온 거였다.
7장을 읽으면서 "자바스크립트는 왜 이렇게 생겼는가"에 대한 실마리를 꽤 많이 얻었다. 그 과정을 기록한다.
이 글은 자바스크립트 기초 문법을 아는 사람을 대상으로 쓴다. IEEE 754나 비트 레이아웃 얘기가 나오는데, 모르더라도 읽을 수 있게 정리했다. 다만 일부는 내 추론이 섞여 있다.
브라우저에서 태어난 언어
7장의 연산자들이 왜 이런 식으로 동작하는지를 이해하려면, 자바스크립트가 어떤 환경에서 태어났는지를 먼저 봐야 한다.
1995년 5월, Brendan Eich가 Netscape에서 10일 만에 프로토타입을 만든 언어. 정확히는 1995년 5월 6~15일경으로, 당시 코드네임은 "Mocha"였다. (Wikipedia - Brendan Eich) Eich는 원래 Scheme을 브라우저에 넣으려 했지만, Netscape 경영진이 Java와의 제휴 때문에 Java처럼 생긴 문법을 요구했고, 그 사이에서 타협한 결과물이 자바스크립트다.
Eich 본인이 인터뷰에서 밝힌 설계 의도가 있다. "컴파일러가 뭔지도 모르는 사람들이 쓸 수 있는 언어를 만들고 있었다"는 것. (The New Stack - Brendan Eich on Creating JavaScript in 10 Days) Marc Andreessen과 Sun의 Bill Joy도 "비전문가, 아마추어, 디자이너가 접근할 수 있는 언어"를 원했다고 한다.
재밌는 건, 초기 Mocha에는 암묵적 타입변환이 없었다는 점이다. 사용자들이 1.0 버전에 넣어달라고 요청해서 추가된 거다. (Hillel Wayne - Was Javascript really made in 10 days?) 10일의 급한 설계 탓만은 아니고, "비전문가를 위한 관대함"이라는 방향 자체가 의도된 것이었다.
그리고 Eich가 10일 안에 하지 못한 것 중 하나가 예외 처리(exception handling)의 추가였다. 자바스크립트는 태생적으로 "조용히 실패하는(fail silently)" 방향으로 설계됐다. (Killing Coercion and Silent Failure in JavaScript)
1990년대 웹 환경을 떠올려 보면 이 선택이 이해가 간다. 에러가 나면 페이지 전체가 멈춘다. 개발자 도구는 없다. 사용자가 에러 메시지를 확인할 방법도 마땅치 않다. 이 환경에서 언어가 취할 수 있는 전략은 두 가지다.
- 엄격하게 에러를 던져서 개발자가 실수를 바로 잡게 한다.
- 일단 뭐라도 결과를 내고 실행을 계속한다.
자바스크립트는 2번을 택했다. 그리고 이 선택이 7장 연산자 곳곳에 유전자처럼 남아 있다.
첫 번째 증거: 암묵적 타입변환
7장에서 가장 먼저 이 철학이 드러나는 곳이 산술 연산자다.
"5" - 1 // 4
"5" + 1 // "51"
true + 1 // 2
null + 1 // 1
C나 Java였으면 "5" - 1은 컴파일 에러다. 문자열과 숫자를 빼려고 하면 "타입이 안 맞는다"고 거부한다. 합리적이다.
자바스크립트는 거부하지 않는다. "문자열 '5'? 숫자로 바꿀 수 있겠는데? 바꿔서 계산하자." 이게 암묵적 타입변환이다.
UC Berkeley의 연구(An Empirical Study of Implicit Type Conversions in JavaScript)에 따르면, 자바스크립트에서 명시적 타입변환 1건당 암묵적 타입변환이 약 269건 발생한다고 한다. 이 수치를 보고 놀랐다. 우리가 의식하지 못하는 사이에 엔진이 엄청나게 많은 타입변환을 수행하고 있다는 뜻이다.
문제는 이 지점이 “유연하다”기보다 예측을 어렵게 만든다는 데 있다.
"5" + 1 // "51" — 문자열 연결
"5" - 1 // 4 — 숫자 연산
같은 피연산자 조합인데 +와 -의 결과가 완전히 다르다. -는 숫자 연산만 가능하니까 "5"를 숫자로 바꾸고, +는 문자열 연결도 되니까 1을 문자열로 바꿔버린다. MDN에서도 + 연산자가 피연산자 중 하나가 문자열이면 문자열 연결로 동작한다고 명시한다. (MDN - Type coercion)
이걸 보고 처음엔 "설계 실수 아닌가?" 싶었다. 근데 다시 생각해 보니, 이것도 "에러를 삼키는" 철학의 연장선이다. +가 문자열 연결과 덧셈 두 가지 역할을 하는 건 분명 혼란스럽지만, 어떤 경우에도 에러를 던지지 않는다. 타입이 안 맞으면? 맞춰서라도 결과를 낸다.
Eich 본인도 이후 인터뷰에서 암묵적 타입변환을 후회한다고 밝힌 바 있다. Kyle Simpson의 "You Don't Know JS"에서는 이 발언을 인용하면서도, Eich 스스로가 x + "" 같은 암묵적 변환 관용구를 공개적으로 사용한다고 지적한다. (You Don't Know JS - Types & Grammar Ch4) 만든 사람도 완전히 부정하지 못하는 양면성이다.
여기서 씁쓸해졌다. "에러를 안 던지는 것"과 "예측 가능한 것"은 다른 문제다. 에러는 안 나는데, 의도와 다른 결과가 조용히 나오는 게 오히려 더 위험하지 않나.
두 번째 증거: NaN — 에러 대신 돌려주는 "실패 표기"
암묵적 변환이 안 되는 경우가 있다. "hello" - 1은 "hello"를 숫자로 바꿀 수 없다. 다른 언어라면 여기서 에러가 난다.
자바스크립트는? NaN을 돌려준다.
"hello" - 1 // NaN
undefined + 1 // NaN
0 / 0 // NaN
Math.sqrt(-1) // NaN
parseInt("abc") // NaN
"Not a Number"라는 이름의 근본적 오해
NaN은 "Not a Number"의 약자인데, 이 이름이 오해를 만든다.
typeof NaN // 'number'
"Not a Number"인데 왜 number야? 처음에 이걸 보고 자바스크립트가 또 이상한 짓을 한 거라고 생각했다. 근데 다른 언어를 확인해 봤더니:
# Python
import math
type(math.nan) # <class 'float'>
// Java
Double.NaN // double 타입
전부 숫자 타입이다. "아, 이건 자바스크립트만의 버그가 아니구나" 싶었다. 그러면 뭔가 더 근본적인 이유가 있을 텐데.
컴퓨터는 숫자를 어떻게 저장하는가
이걸 이해하려면 자바스크립트보다 더 아래 층위, 즉 컴퓨터가 소수점 있는 숫자를 어떻게 표현하는지부터 봐야 한다.
1985년에 IEEE 754라는 국제 표준이 만들어졌다. 이 표준의 핵심 설계자는 UC Berkeley의 William Kahan 교수로, "부동소수점의 아버지"로 불리며 1989년 튜링상을 수상했다. (Wikipedia - William Kahan)
이 표준은 자바스크립트만의 것이 아니다. C, C++, Java, Python, Rust, Go, Swift — 사실상 현대의 거의 모든 프로그래밍 언어가 따른다. 소프트웨어가 아니라 CPU의 하드웨어(FPU, 부동소수점 연산 장치)에 직접 구현되어 있다. 실리콘 칩 수준의 약속이다. (Wikipedia - IEEE 754)
Kahan 본인의 말이 인상적이다. IEEE 754의 기능들은 "수치 해석 전문가만을 위한 것이 아니라, 선의의 무지를 가진 프로그래머들도 관대하게 다룰 수 있도록" 설계됐다는 것. (Wikipedia - IEEE 754) 여기서도 "관대함"이라는 키워드가 나온다.
64비트 부동소수점의 구조
자바스크립트의 모든 숫자는 IEEE 754의 배정밀도(double-precision) 형식, 즉 64비트로 저장된다. 정수든, 소수든, NaN이든, Infinity든 전부 이 64비트 안에 들어간다.
64비트의 구조:
┌──────┬─────────────┬───────────────────────────────┐
│ 부호 │ 지수부 │ 가수부(mantissa) │
│ 1bit │ 11bits │ 52bits │
└──────┴─────────────┴───────────────────────────────┘
부호(sign): 0이면 양수, 1이면 음수
지수(exponent): 숫자의 크기(자릿수)를 결정
가수(mantissa): 숫자의 정밀도(유효숫자)를 결정
여기서 지수부의 11비트가 모두 1인 경우(11111111111)가 특수한 값들을 위해 예약되어 있다. Infinity와 NaN이 여기서 나온다.
+Infinity: 0 11111111111 0000000000000000000000000000000000000000000000000000
-Infinity: 1 11111111111 0000000000000000000000000000000000000000000000000000
NaN (예시): 0 11111111111 0000000000000000000000000000000000000000000000000001
NaN (예시): 0 11111111111 0100000000000000000000000000000000000000000000000000
NaN (예시): 0 11111111111 1111111111111111111111111111111111111111111111111111
규칙이 단순하다.
- 지수부 전부 1 + 가수부 전부 0 → Infinity
- 지수부 전부 1 + 가수부가 0이 아닌 아무 값 → NaN
이걸 보고 놀랐다. NaN이 하나의 고정된 비트 패턴이 아니라, 가수부가 0만 아니면 전부 NaN이다. 2^52 - 1개, 대략 4천조 개의 서로 다른 NaN이 존재할 수 있다. "Not a Number"라는 이름이 단수형인데, 실제로는 엄청나게 많은 비트 패턴을 커버하는 범주였다.
그제서야 typeof NaN === 'number'가 이상하지 않게 느껴졌다. NaN은 64비트 부동소수점 형식 안에 존재하는 비트 패턴이다. typeof는 "이 값이 뭘 의미하느냐"가 아니라 "어떤 형식으로 저장되어 있느냐"를 본다. NaN은 64비트 double 구조 안에 있으니까 'number'가 맞다.
// typeof의 관심사: 저장 형식
typeof 42 // 'number' — 64비트 double
typeof 3.14 // 'number' — 64비트 double
typeof Infinity // 'number' — 64비트 double (지수 모두 1, 가수 0)
typeof NaN // 'number' — 64비트 double (지수 모두 1, 가수 ≠ 0)
// 다른 타입들은 완전히 다른 내부 구조
typeof "hello" // 'string' — 문자 시퀀스 + 길이 정보
typeof true // 'boolean' — 단일 비트 정보
typeof {} // 'object' — 프로퍼티 맵 + 프로토타입 링크
typeof undefined // 'undefined' — 특수 태그
"Not a Number"를 "숫자가 아닌 것"이라고 읽으면 혼란스럽다. 실제 의미는 "이 연산의 결과를 수로 표현할 수 없다"에 가깝다.
잘못된 해석: "나는 숫자가 아니다" (I am not a number)
올바른 해석: "숫자 연산을 시도했는데 결과를 수로 나타낼 수 없었다"
은행 계좌로 비유하면 이렇다.
계좌 잔액: 50,000원 → 정상
계좌 잔액: 0원 → 정상
계좌 잔액: NaN → 잔액 계산을 시도했는데 결과를 못 구함
잔액 칸에 NaN이 들어있다고 해서 그 칸이 "숫자 칸이 아닌 것"은 아니다. 여전히 숫자 칸이고, 유효한 숫자를 채우지 못한 상태인 것이다.
Infinity는 무한대가 아닌 Overflow
NaN을 파다 보니 같은 "지수부 전부 1" 구간에서 나오는 Infinity도 눈에 들어왔다.
수학에서 무한대는 "수"가 아니라 "개념"이다. 그런데 IEEE 754는 이걸 64비트 안에 특정 비트 패턴으로 넣어버렸다. 억지스러워 보였는데, 이유를 보니 납득이 갔다. 표현 가능한 가장 큰 수를 넘어가는 결과가 나왔을 때, 프로그램을 그냥 죽일 수는 없으니까. "오버플로우 났는데 일단 Infinity라고 표시해둘게" 하는 식이다.
Number.MAX_VALUE // 1.7976931348623157e+308 (가장 큰 수)
Number.MAX_VALUE * 2 // Infinity
// 수학에서는 정의되지 않지만, IEEE 754는 Infinity 반환
1 / 0 // Infinity
-1 / 0 // -Infinity
1 / 0이 Infinity를 반환하는 건 수학적으로는 정의되지 않는 연산이다. 그런데 IEEE 754는 "프로그램을 멈추지 말자"는 방향을 택했다. 에러를 삼키는 철학이 자바스크립트 이전, 하드웨어 수준에서부터 있었다.
NaN의 "독성" — Quiet NaN과 Signaling NaN
IEEE 754는 NaN을 두 종류로 나눈다.
가수부의 최상위 비트가 1 → Quiet NaN (qNaN)
: 조용히 전파됨. 연산에 참여해도 예외를 발생시키지 않음.
: NaN + 5 = NaN처럼 결과를 오염시키며 퍼져나감.
가수부의 최상위 비트가 0 (나머지가 0은 아님) → Signaling NaN (sNaN)
: 연산에 사용되면 하드웨어 예외(인터럽트)를 발생시킴.
: "이 값은 절대 사용되면 안 된다"는 표식.
: 초기화되지 않은 변수를 감지하는 데 사용.
자바스크립트는 Quiet NaN만 사용한다. 이게 무슨 뜻이냐면, NaN이 한번 발생하면 에러 없이 연산을 오염시킨다.
// NaN의 "독성" — 한 번 발생하면 모든 연산을 오염시킴
const result = 0 / 0; // NaN
const step2 = result + 10; // NaN
const step3 = step2 * 100; // NaN
const step4 = Math.max(step3, 50); // NaN
// 100단계의 계산이 있어도, 1단계에서 NaN이 나오면
// 100단계의 결과도 NaN. 에러 메시지 없이 조용히.
에러를 던졌으면 1단계에서 바로 잡았을 텐데, 조용히 삼켜버리니까 문제가 어디서 시작됐는지 추적이 안 된다. "에러를 삼키는" 철학이 여기서 명확한 비용을 보여준다.
왜 자바스크립트에서 유독 NaN을 자주 마주치나
C나 Java에서는 타입이 엄격해서 "hello" - 1 같은 코드가 컴파일 에러다. NaN이 나올 일 자체가 적다. 자바스크립트는 암묵적 타입변환 때문에 일단 변환을 시도하고, 안 되면 NaN을 뱉는 구조다. "에러를 삼키는" 철학과 "관대한 타입변환"이 결합되면서, NaN이 생성될 경로가 훨씬 많아진 거다.
이 맥락을 알고 나니 자바스크립트를 욕하기보다는, 태생적 한계를 이해하게 됐다.
세 번째 증거: NaN !== NaN — 이것도 에러를 삼킨 결과
NaN === NaN이 false라는 건 알고 있었다. 왜 그런지는 생각해 본 적 없었는데, 이번에 파보니 자바스크립트의 결정이 아니라 IEEE 754 위원회의 설계였다.
논리적 근거
const a = 0 / 0; // NaN — "0÷0은 정의되지 않음"
const b = Math.sqrt(-1); // NaN — "음수의 제곱근은 실수에 없음"
const c = parseInt("hello"); // NaN — "파싱 실패"
// 이 셋은 모두 NaN이지만, 발생 원인이 완전히 다름.
// a == b가 true라면, "0÷0의 결과 = 음수의 제곱근"이라는
// 수학적으로 말이 안 되는 주장을 하는 셈.
여기까지는 수긍했다.
IEEE 754-2008 표준 5.11절에서는 이렇게 명시한다.
비교에는 네 가지 관계가 있다.
- 작음, 같음, 큼, 순서 없음(unordered).
피연산자 중 하나라도 NaN이면 "순서 없음"에 해당한다. NaN은 자기 자신을 포함해 어떤 값과도 순서 관계가 없다. (Exploring JavaScript - Why is NaN Different from Itself?)
실용적 이유 — isNaN이 없던 시대의 감지법
실용적인 이유도 있었다는 걸 알고 나서 감탄했다.
1985년 당시, Intel 8087 프로세서의 수학 모델에는 isNaN을 감지하는 방법이 없었다. isNaN 함수를 표준에 포함시킬 수도 있었지만, 그러면 모든 언어와 프로세서에 구현이 지연됐을 것이다. 그래서 IEEE 754 위원회는 프로그래밍 언어가 isNaN을 지원하든 안 하든 NaN을 감지할 수 있는 방법을 만들어야 했다. (Exploring JavaScript - Why is NaN Different from Itself?, William Kahan, Lecture Notes on the Status of IEEE 754)
x !== x가 true인 값은 IEEE 754 전체에서 오직 NaN뿐이라는 성질을 의도적으로 만들어 넣었다.
// 1985년의 NaN 감지법 — 어떤 언어, 어떤 환경에서든 동작
function isNaN_universal(x) {
return x !== x;
}
// 이것이 가능한 이유:
// IEEE 754 전체에서 자기 자신과 같지 않은 값은 NaN뿐.
// 버그가 아니라 의도된 기능.
Kahan은 튜링상 수상 기록에서 NaN에 대해 이렇게 설명한다. NaN 코드는 "어떤 수치적 표현도 불가능한 결과를 알려주기 위해" 제공됐으며, 프로그래밍 언어가 이를 지원하면 "투기적으로 실행된 연산에 대한 판단을 유보"할 수 있게 해준다고. (ACM Turing Award - William Kahan)
하지만 이 설계에 대한 비판도 있다
현대에 와서 모순이 드러나는 곳이 있다.
// NaN이 포함된 배열 정렬
const arr = [3, NaN, 1, NaN, 2];
arr.sort((a, b) => a - b);
// 결과가 구현마다 다를 수 있다
// NaN과의 비교가 항상 false라서 정렬의 추이성 전제가 깨진다
// Set에서의 모순
const set = new Set();
set.add(NaN);
set.add(NaN);
set.size; // 1
// NaN === NaN은 false인데, Set은 내부적으로 NaN을 같은 값으로 취급
// 특별 처리를 해놓은 것
// ES6의 Object.is()는 NaN에 대해 다른 결정을 내림
Object.is(NaN, NaN); // true!
ES6에서 Object.is(NaN, NaN)이 true를 반환하게 만든 건, 40년 전 설계에 대한 현대적 수정이라고 볼 수 있겠다. 에러를 삼키는 방식으로 만든 규칙이 시간이 지나면서 새로운 문제를 낳고, 또 그걸 우회하는 도구를 추가하는 패턴. 이게 자바스크립트 역사에서 반복적으로 나타난다.
네 번째 증거: typeof null — 진짜 버그인데 못 고치는
NaN 쪽은 "왜 이렇게 설계했는지" 납득이 갔다. 근데 같은 7장에서 나온 이건 납득이 안 된다.
let foo = null;
typeof foo === null // false
typeof foo // 'object'
null이 object라고? 이건 합리적인 설명이 불가능하다. 실제로 자바스크립트 커뮤니티에서도 인정하는 버그다.
버그의 원인 — 32비트 타입 태그
Dr. Axel Rauschmayer의 분석(The history of "typeof null")에 따르면, 원인은 초기 자바스크립트 엔진의 값 표현 방식에 있다.
초기 자바스크립트 엔진은 값을 32비트 단위로 저장했고, 하위 1~3비트를 타입 태그로 사용했다.
000 → object (데이터는 객체 참조)
1 → 정수 (31비트 부호 정수)
010 → 부동소수점 (double 참조)
100 → 문자열 (문자열 참조)
110 → 불리언
그리고 특별한 값이 두 개 있었다.
undefined는 정수-2³⁰(범위 밖의 특수 값)으로 표현null은 기계어 수준의 NULL 포인터, 즉0x00으로 표현
문제가 보인다. 객체의 타입 태그가 000이고, null은 모든 비트가 0이니까 하위 비트도 000이다. typeof 연산자가 하위 비트만 비교했기에, null을 object로 착각한 것이다.
실제 엔진 코드에서도 null에 대한 별도 검사(JSVAL_IS_NULL 매크로)가 존재했지만, typeof 구현체에서는 이 검사를 빼먹었다. Rauschmayer는 "매우 명백한 버그처럼 보이지만, 첫 번째 버전을 완성할 시간이 매우 적었다는 점을 잊지 말라"고 적고 있다. (2ality.com)
"JavaScript: The First 20 Years"라는 공식 역사 문서에서도, Brendan Eich의 회상에 따르면 typeof null의 결과는 원래 Mocha 구현의 "추상화 누수(leaky abstraction)"였다고 기록되어 있다. null의 런타임 값이 객체 값에 사용하는 것과 같은 내부 태그로 인코딩되어 있어서, 별도의 특별 처리 없이도 typeof가 "object"를 반환하게 됐다는 것. (JavaScript: The First 20 Years)
왜 고치지 않았는가 — "Don't break the web"
ECMAScript 제안으로 typeof null === 'null'로 수정하자는 논의가 있었다. ES5.1에서 제안됐고, V8의 harmony 버전에서 실제로 구현까지 됐다. 그런데 기존 사이트들이 깨지기 시작했고, 기각됐다. (MDN - typeof)
Brendan Eich 본인도 이 문제에 대해 언급했다. "typeof를 고치기엔 너무 늦었다고 생각한다. typeof null에 대한 변경은 기존 코드를 깨뜨릴 것이다." 다른 토론에서는 "typeof null === 'object'가 버그라는 건 우리도 알고 있지만, 웹을 크롤링한 결과 실제 콘텐츠에 영향을 줄 수 있다"고 썼다. (JavaScript Plain English - typeof null bug)
이미 수많은 코드가 typeof x === 'object'로 null 체크를 포함하는 패턴에 의존하고 있었기 때문이다. 고치면 기존 웹사이트가 깨진다.
"Don't break the web."
자바스크립트는 에러를 삼키는 것도 모자라, 버그조차 삼킨다. 한번 웹에 퍼진 동작은 설령 그게 실수였더라도 되돌릴 수 없다. TC39(자바스크립트 표준을 결정하는 위원회)는 "모든 변경은 이전 버전과 의미적으로 하위호환되어야 한다"는 철학을 따른다. (Killing Coercion and Silent Failure in JavaScript)
언어가 성장하면서 과거의 실수를 영원히 안고 가야 하는 무게. 자바스크립트를 쓸 때 느끼는 기묘한 감정의 원천이 여기 있는 것 같다.
다섯 번째 증거: == 와 === 가 공존하는 이유
동등 연산자(==)와 일치 연산자(===)가 둘 다 존재하는 것 자체가 "에러를 삼키는 언어"의 산물이다.
==는 비교하기 전에 암묵적 타입변환을 수행한다.
5 == '5' // true
0 == false // true
null == undefined // true
"" == 0 // true
이게 처음엔 편리해 보인다. 타입을 일일이 맞추지 않아도 "대충 같으면" true를 돌려주니까. 근데 예측이 안 된다.
"" == 0 // true
"" == false // true
0 == false // true
// 그러면 이것도 true일까?
"" == "0" // false
이 결과를 처음 봤을 때 한참 멍했다. ""와 0이 같고, 0과 false가 같은데, ""와 "0"은 다르다? 추이성이 깨져버린다.
Eich 본인도 ==의 설계를 후회하며, 이것이 표준화 과정에서 ===를 추가하게 된 계기였다고 인터뷰에서 밝혔다. 당시 표준화 작업에 참여했던 Scheme 공동 창시자 Guy Steele이 "걱정하지 마. Lisp에는 등호 연산자가 다섯 종류나 있어. 하나 더 추가하면 돼"라고 말했다고 한다. (The New Stack)
===는 타입까지 같아야 true를 반환한다. 예측 가능하다.
5 === '5' // false — 타입이 다르니까 변환 없이 false
0 === false // false
근데 이것도 생각해 보면 웃긴 일이다. ==가 문제가 많으니까 ===를 추가한 건데, ==를 없앤 게 아니라 공존시켰다. 왜? ==를 없애면 기존 코드가 깨지니까. 또 "Don't break the web"이다.
결국 자바스크립트 개발자들 사이에서 ==는 쓰지 말라는 암묵적 합의가 생겼다. 언어가 제공하는 기능인데 "쓰지 마세요"라니. 이게 에러를 삼키는 설계의 장기적 비용이다.
다만 ===도 만능은 아니다.
NaN === NaN // false
+0 === -0 // true
이 두 경우에서 ===도 직관과 어긋난다. 그래서 ES6에서 Object.is가 나왔다.
Object.is(NaN, NaN) // true
Object.is(+0, -0) // false
문제가 생기면 에러를 던지는 대신 새로운 도구를 추가한다. == → === → Object.is. 에러를 삼키는 철학이 도구의 증식으로 이어지는 구조다.
+0과 -0 — 이건 왜 나눠져 있을까
7장을 읽다가 +0과 -0이 별도로 존재한다는 것도 처음 알았다. 이것도 자바스크립트가 직접 만든 건 아니고, IEEE 754 표준을 그대로 따르기 때문이다.
IEEE 754는 부호 비트(sign bit)를 별도로 두는 구조라서, 0에도 부호가 붙는다. 왜 필요한가? 수학적으로 극한의 방향을 보존하기 위해서다.
1 / +0 // +Infinity
1 / -0 // -Infinity
아주 작은 음수가 언더플로우로 0이 되더라도, "음의 방향에서 접근했다"는 정보를 잃지 않게 해주는 것이다. 물리 시뮬레이션이나 수치 해석에서 부호 정보가 결과에 영향을 줄 수 있어서 이렇게 설계됐다.
+0 === -0 // true
근데 ===로 비교하면 true다. 일상적인 프로그래밍에서 혼란을 줄이기 위해서라는데, "에러를 삼키는" 패턴이 여기서도 보인다. 실제로 다른 값인데, 비교 연산에서는 같다고 말해버리는 것. 진짜 구분이 필요하면 Object.is(+0, -0)을 써야 한다.
NaN 판별법 — 도구가 이렇게 많은 것도 증거
NaN을 판별하는 방법이 네 가지나 된다. 이것 자체가 "에러를 삼키고 → 문제가 생기고 → 우회 도구를 추가하는" 패턴의 결과물이다.
// 1. Number.isNaN() — ES6, 가장 정확하고 권장됨
Number.isNaN(NaN) // true
Number.isNaN(undefined) // false (타입 변환 안 함)
Number.isNaN("hello") // false (타입 변환 안 함)
Number.isNaN(42) // false
// 2. 전역 isNaN() — 위험! 먼저 Number()로 변환함
isNaN(NaN) // true
isNaN(undefined) // true! Number(undefined) → NaN → true
isNaN("hello") // true! Number("hello") → NaN → true
isNaN("42") // false Number("42") → 42 → false
// 3. x !== x — 가장 원시적이지만 확실한 방법
NaN !== NaN // true
42 !== 42 // false
"a" !== "a" // false
// 4. Object.is() — NaN 외에 +0/-0 구분도 가능
Object.is(NaN, NaN) // true
Object.is(+0, -0) // false (=== 는 true)
전역 isNaN()이 왜 위험한지 직접 겪어봤다. isNaN(undefined)가 true를 반환하는 걸 보고 한참 헷갈렸다. Number(undefined)가 NaN이 되니까, 그 NaN을 보고 true를 뱉는 거다. 암묵적 타입변환이 여기서도 문제를 만든다.
그래서 ES6에서 Number.isNaN()을 추가했다. 타입 변환 없이 정확하게 판별한다. 전역 isNaN()을 고친 게 아니라 새 함수를 추가한 것. 역시 기존 동작은 건드리지 않는다.
정리
7장을 다 읽고 나니 흩어져 있던 것들이 하나로 묶였다.
- 암묵적 타입변환 — 타입이 안 맞아도 에러 대신 변환해서 결과를 냄
- NaN — 변환이 불가능해도 에러 대신 "실패 표식"을 돌려줌
- NaN의 조용한 전파 — 실패가 발생해도 예외 없이 퍼져나감
- typeof null — 버그인 줄 알면서도 하위호환 때문에 못 고침
- == 와 === 공존 — 문제 있는 기능을 없애지 않고 새 기능을 추가
전부 "에러를 삼키는 언어"라는 한 문장으로 연결된다.
자바스크립트가 이렇게 된 건 브라우저에서 태어났기 때문이다. "컴파일러가 뭔지도 모르는 사람들"을 위해 만들어진 언어에서, 에러가 나면 페이지가 죽는 환경에서, "일단 살아남는 것"이 최우선이었다. 그 결정이 30년이 지난 지금까지 언어 곳곳에 남아 있고, 우리는 그 위에서 코드를 쓴다.
이걸 결함으로 볼 수도 있고, 생존 전략으로 볼 수도 있다. 읽기 전에는 "자스 왜 이래" 쪽이었는데, 7장을 꼼꼼히 읽고 나니 "이 환경에서는 이럴 수밖에 없었겠구나" 쪽으로 생각이 옮겨갔다.
다만 그 관대함의 비용은 개발자가 치른다. 에러 대신 NaN이 조용히 퍼져나가고, ==의 함정에 빠지고, typeof null에 당하고. 그래서 TypeScript 같은 타입 시스템이 나왔고, ESLint에서 == 사용을 경고하고, Number.isNaN이 추가됐다.
에러를 삼킨 대가를 도구로 메우는 언어. 7장 연산자를 읽으면서 자바스크립트라는 언어의 성격이 좀 더 선명해진 느낌이다.
참고 자료
- Wikipedia - Brendan Eich
- The New Stack - Brendan Eich on Creating JavaScript in 10 Days
- Hillel Wayne - Was Javascript really made in 10 days?
- The History of the Web - The 10-Day Programming Language
- JavaScript: The First 20 Years (HOPL)
- Dr. Axel Rauschmayer - The history of "typeof null"
- MDN - typeof
- MDN - Type coercion
- Wikipedia - IEEE 754
- Wikipedia - William Kahan
- ACM Turing Award - William Kahan
- William Kahan - Lecture Notes on the Status of IEEE 754
- Exploring JavaScript - Why is NaN Different from Itself?
- UC Berkeley - An Empirical Study of Implicit Type Conversions in JavaScript
- Kyle Simpson - You Don't Know JS: Types & Grammar
- Killing Coercion and Silent Failure in JavaScript