자바스크립트가 욕을 먹는 지분의 절반쯤은 여기서 나온다. 타입이 안 맞으면 에러를 내는 게 아니라, JS는 뒤에서 조용히 타입을 바꿔치기해서 어떻게든 계산을 성사시킨다. 이걸 암묵적 형변환(implicit coercion, 개발자 몰래 일어나는 타입 변환)이라고 부르는데, 편하라고 만든 기능이 오히려 가장 악랄한 버그의 온상이 됐다. 이번 편은 그 변환이 언제 어떻게 튀는지를 손으로 확인한다.
더하기 연산자는 성격이 두 개다. +는 상황을 봐서 숫자 덧셈을 하기도 하고 문자열 이어붙이기를 하기도 한다. 규칙은 단순하다. 양쪽 중 하나라도 문자열이면 나머지를 문자열로 바꿔서 이어붙인다. 그 외엔 숫자로 더한다. 문제는 이게 왼쪽부터 순서대로 계산된다는 점이라, 같은 값이라도 순서에 따라 결과가 달라진다.
console.log(1 + '1'); // '11' (숫자가 문자열로 끌려감)
console.log('1' + 1); // '11'
console.log(1 + 2 + '3'); // '33' (1+2=3 먼저, 그 뒤 '3' 붙임)
console.log('1' + 2 + 3); // '123' ('1'+2='12', 다시 +3)
1 + 2 + '3'이 33인 이유는 왼쪽 1 + 2가 먼저 숫자로 3이 되고, 그 뒤에 문자열 '3'을 만나 '33'이 되기 때문이다. 반대로 '1' + 2 + 3은 처음부터 문자열이 껴서 '12', '123'으로 굴러간다. 사용자 입력을 숫자로 안 바꾸고 그냥 더했다가 합계가 '150300'처럼 나오는 사고, 신입 시절 한 번씩은 다 친다.
더하기를 뺀 나머지 산술 연산은 무조건 숫자로 끈다. -, *, /, %에는 문자열 이어붙이기 같은 이중성이 없다. 그래서 문자열이 끼어도 숫자로 바꿔서 계산한다. 이 비대칭 때문에 +만 조심하면 되긴 하는데, 숫자로 못 바꾸는 문자열이 들어오면 곧장 NaN이 튄다.
console.log('5' - 1); // 4 (문자열이 숫자로 변환됨)
console.log('5' * 2); // 10
console.log('10' / '2'); // 5
console.log('abc' - 1); // NaN (숫자로 못 바꿈)
console.log(10 % 3); // 1 (나머지)
'5' - 1이 4인 걸 보면 뺄셈에선 문자열이 알아서 숫자가 된다. 그런데 'abc' - 1은 abc를 숫자로 바꿀 방법이 없으니 NaN이 된다. 그리고 NaN은 전염된다. 한번 NaN이 끼면 뒤따르는 계산이 전부 NaN으로 오염돼서, 화면 어딘가에 NaN원이 뜨는 순간 범인은 십중팔구 이 지점이다.
부동소수점은 신뢰하지 마라. 앞 편에서 0.1 + 0.2가 0.3이 아니라고 했는데, 여기서 실전 함정으로 이어진다. 계산 결과를 === 0.3 같은 식으로 직접 비교하면 영원히 false다.
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
console.log((0.1 + 0.2).toFixed(2)); // '0.30' (반올림해서 문자열로)
0.1 + 0.2 === 0.3이 false인 걸 모르고 이걸 조건문에 넣으면, 분명 맞는 계산인데 왜 안 들어가냐며 반나절을 태운다. 소수 비교가 필요하면 toFixed()로 자릿수를 맞추거나, 두 값의 차이가 아주 작은지를 보는 식으로 우회한다.
명시적 변환은 함수로 대놓고 바꾼다. 암묵적 변환이 무서우면 내가 직접 Number(), String(), Boolean()으로 타입을 바꿔주면 된다. 의도가 코드에 드러나서 훨씬 안전하다. 다만 Number()의 변환 규칙에도 함정이 몇 개 숨어 있다.
console.log(Number('42')); // 42
console.log(Number('42px')); // NaN (통째로 실패)
console.log(parseInt('42px')); // 42 (앞 숫자만 뽑음)
console.log(Number('')); // 0 (빈 문자열은 0?!)
console.log(Number(null)); // 0
console.log(Number(undefined)); // NaN
console.log(String(null)); // 'null'
Number('42px')는 NaN이지만 parseInt('42px')는 앞의 숫자만 뽑아 42를 준다. 이 둘은 용도가 다르니 구분해서 써야 한다. 더 얄궂은 건 Number('')가 0이고 Number(null)도 0인데, Number(undefined)는 NaN이라는 점이다. 빈 값이라고 다 같은 대접을 받는 게 아니다.
불리언 변환, 즉 truthy와 falsy를 외워라. 조건문이 값을 참거짓으로 판단할 때 이 규칙을 쓴다. 거짓으로 취급되는 값(falsy)은 딱 여덟 개뿐이다. false, 0, -0, 0n, ''(빈 문자열), null, undefined, NaN. 이것 말고는 전부 참이다. 그 전부에는 빈 배열과 빈 객체도 포함된다.
console.log(Boolean(0)); // false
console.log(Boolean('')); // false
console.log(Boolean(NaN)); // false
console.log(Boolean('0')); // true (문자열 '0'은 참!)
console.log(Boolean([])); // true (빈 배열도 참)
console.log(Boolean({})); // true (빈 객체도 참)
console.log(Boolean(' ')); // true (공백 한 칸도 참)
여기서 제일 많이 데는 게 Boolean('0')이 true라는 것과, 빈 배열 []이 true라는 것이다. 배열이 비었는지 보려고 if (arr)라고 썼다간, 빈 배열도 참이라 항상 통과해버린다. 배열이 비었는지는 arr.length === 0으로 확인해야 한다.
그리고 대망의 == 대 ===. 이거 하나만 제대로 잡아도 JS 버그의 상당수가 사라진다. ==(느슨한 같음)는 비교 전에 타입을 맞춰버리고 나서 비교하고, ===(엄격한 같음)는 타입이 다르면 그냥 false를 낸다. 아래 표를 보면 ==가 얼마나 예측 불가능한지 실감난다.
console.log(1 == '1'); // true
console.log(1 === '1'); // false
console.log(0 == ''); // true
console.log(0 == false); // true
console.log('0' == false); // true
console.log(null == undefined); // true
console.log(null === undefined); // false
console.log(null == 0); // false (?!)
console.log(NaN === NaN); // false
압권은 null == undefined는 true인데 null == 0은 false라는 조합이다. 논리적으로 일관성을 찾으려 하지 마라, 이건 그냥 규칙표를 외우는 영역이다. 게다가 NaN === NaN이 false라 자기 자신과도 안 같다. 그래서 어떤 값이 NaN인지 검사하려면 === NaN이 아니라 Number.isNaN()을 써야 한다.
결론은 짧다. ==는 쓰지 말고 항상 ===를 써라. 타입까지 같아야 통과하니 예측이 되고, 위 표 같은 괴담을 통째로 피할 수 있다. 유일한 예외처럼 언급되는 게 value == null 하나인데(null과 undefined를 한 번에 걸러줘서), 이것도 === null || === undefined로 풀어 쓰는 게 명확하다. 암묵적 변환은 JS의 본성이라 없앨 순 없지만, ===와 명시적 변환 습관만으로도 그 본성에 물리는 횟수를 확 줄일 수 있다.