자바스크립트는 지나치게 관대하다. 다른 언어라면 에러를 내고 멈출 상황에서, 이 언어는 조용히 이상한 값을 하나 내놓고 태연히 다음 줄로 넘어간다. 그래서 버그가 요란한 크래시가 아니라 "왜 값이 이렇게 나오지"라는 조용한 형태로 온다. 내가 자주 데인 함정들과 그걸 잡는 디버깅 요령을, 실제로 돌려서 나온 결과와 함께 정리한다.
0.1 더하기 0.2는 0.3이 아니다. 컴퓨터는 소수를 이진법으로 근사해 저장하는데, 0.1 같은 값은 이진수로 딱 떨어지지 않는다. 그래서 아주 미세한 오차가 쌓인다.
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
눈으로는 0.3인데 실제로는 뒤에 찌꺼기가 붙어서 === 0.3 비교가 false가 된다. 나는 장바구니 합계가 딱 안 떨어져 결제가 반려되는 버그로 이걸 처음 만났다. 소수를 직접 비교하지 말고, 오차 범위 안이면 같다고 보거나 아예 정수로 다룬다.
const eps = Number.EPSILON;
console.log(Math.abs((0.1 + 0.2) - 0.3) < eps); // true
console.log((0.1 + 0.7) * 10); // 7.999999999999999
아래 줄이 특히 무섭다. 700원에 100원을 더해 열 배 하면 8000이어야 하는데 7.999...가 나온다. 그래서 나는 금액을 항상 정수(원 단위)로 저장하고 표시할 때만 나눈다.
이중 등호는 타입을 멋대로 바꾼다. ==는 양쪽 타입이 다르면 한쪽을 억지로 변환한 뒤 비교하는데, 이 규칙이 하도 괴상해 결과를 예측하기 어렵다.
console.log(1 == "1"); // true
console.log("5" - 1); // 4
console.log("5" + 1); // "51"
console.log([] + {}); // "[object Object]"
console.log(true + true); // 2
빼기는 숫자로 바꿔 4를 주는데 더하기는 문자열로 이어 붙여 "51"을 준다. 이 난장판을 외울 필요 없이 항상 ===(타입까지 같아야 참)를 쓰면 된다. 나는 0 == ""가 true인 걸로 하루를 날린 뒤 ==를 손에서 놨다.
NaN은 자기 자신과도 다르다. 숫자로 바꿀 수 없는 계산을 하면 NaN(Not a Number, 숫자가 아님)이 나오는데, 이 값은 세상에서 유일하게 자기 자신과 같지 않다.
console.log(NaN === NaN); // false
console.log(typeof NaN); // "number"
console.log(isNaN("abc")); // true (믿지 마라)
console.log(Number.isNaN("abc")); // false
=== NaN은 영원히 false라 절대 못 잡는다. 옛날 함수 isNaN도 값을 먼저 숫자로 바꿔보고 판단해서 숫자가 아닌 문자열에도 true를 주니 못 믿는다. 진짜 NaN인지 보려면 Number.isNaN을 써야 한다. 나는 검증을 isNaN으로 짰다가 멀쩡한 문자열이 죄다 튕겨 나가는 버그를 만들었다.
typeof null은 object다. 이건 언어 초기부터 있던 유명한 버그인데, 고치면 세상 코드가 다 깨져서 그냥 두기로 한 거다.
console.log(typeof null); // "object"
console.log(Array.isArray([])); // true
그래서 typeof로는 null도 배열도 제대로 못 가린다. 배열인지는 Array.isArray, null 여부는 value === null로 직접 확인한다. 나는 typeof x === "object"로 객체를 거르다가 null이 통과해 아래에서 터지는 걸 겪었다.
var는 함수 스코프, let은 블록 스코프다. 옛날 var는 블록을 무시하고 함수 전체에서 하나로 산다. 이게 반복문 안에서 함수를 만들 때 사고를 친다.
var funcs = [];
for (var i = 0; i < 3; i++) {
funcs.push(() => i);
}
console.log(funcs[0](), funcs[1](), funcs[2]()); // 3 3 3
0, 1, 2가 나올 것 같지만 3 3 3이 나온다. var i가 하나뿐이라 세 함수가 전부 같은 i를 보고, 반복이 끝난 뒤 그 값은 3이기 때문이다. var를 let으로 한 글자만 바꾸면 반복마다 새 변수가 생겨 0 1 2가 된다. 이래서 요즘은 var를 안 쓰고 let과 const만 쓴다.
얕은 복사는 속까지 베끼지 않는다. 스프레드(...)로 객체를 복사하면 편한데, 이건 겉면만 복사하고 안에 든 배열이나 객체는 원본과 같은 것을 공유한다.
const orig = { name: "kim", tags: ["a", "b"] };
const copy = { ...orig };
copy.name = "lee";
copy.tags.push("c");
console.log(orig.name); // "kim" (안 바뀜)
console.log(orig.tags); // [ 'a', 'b', 'c' ] (바뀜!)
겉의 name은 따로 놀아 원본이 안 바뀌는데, 속의 tags 배열은 원본과 같은 것을 가리켜 복사본에 밀어 넣은 "c"가 원본에도 나타난다. 나는 이걸로 값이 유령처럼 바뀌는 버그를 며칠 쫓았다. 중첩된 것까지 떼어내려면 깊은 복사를 해야 한다.
const deep = structuredClone(orig);
deep.tags.push("z");
console.log(orig.tags); // [ 'a', 'b', 'c' ] (안 바뀜)
structuredClone은 요즘 브라우저와 Node에 기본으로 들어 있어, 속까지 통째로 새로 복제할 때 이걸 쓰면 된다.
객체는 값이 아니라 참조로 비교된다. 내용이 똑같아 보여도 객체와 배열은 메모리 위치가 같아야만 같다고 본다.
console.log({} === {}); // false
const a = { x: 1 };
const b = a;
console.log(a === b); // true (같은 것을 가리킴)
새로 만든 둘은 겉이 같아도 다른 물건이라 false고, 같은 것을 대입한 b는 true다. 그래서 두 객체의 내용이 같은지는 하나하나 비교하거나 문자열로 바꿔 비교해야 한다.
sort는 숫자를 글자처럼 정렬한다. 배열의 sort는 기본적으로 원소를 문자열로 바꿔 사전 순으로 늘어놓는다. 숫자 배열에 그냥 쓰면 엉뚱한 순서가 된다.
console.log([10, 1, 2, 21, 3].sort()); // [ 1, 10, 2, 21, 3 ]
console.log([10, 1, 2, 21, 3].sort((a,b) => a-b)); // [ 1, 2, 3, 10, 21 ]
"10"이 "2"보다 앞에 오는 건 글자로 보면 "1"이 "2"보다 작기 때문이다. 숫자로 정렬하려면 비교 함수 (a, b) => a - b를 넘겨야 한다. 비슷한 함정이 parseInt에도 있다.
console.log(["1", "2", "3"].map(parseInt)); // [ 1, NaN, NaN ]
1, 2, 3이 나올 줄 알았는데 뒤가 NaN이다. map이 인덱스도 넘기는데 parseInt가 그 두 번째 인자를 진법으로 받기 때문이다. 숫자로 바꿀 땐 .map(Number)를 쓰면 안전하다.
디버깅은 결국 값을 눈으로 보는 일이다. 이 함정들은 코드가 안 멈추고 이상한 값만 조용히 흐른다는 공통점이 있다. 그래서 잡는 방법도 하나로 통한다. 의심 가는 지점마다 이름표를 붙여 값을 찍어보는 거다.
console.log("합계 직전 tags:", tags, typeof tags);
이름표와 typeof를 같이 찍으면 값이 내가 생각한 타입이 맞는지 바로 드러난다. 앞의 함정 대부분이 "문자열인 줄 알았는데 숫자였다" 같은 타입 착각에서 오기 때문이다.
위치를 못 찾겠으면 이분법으로 좁힌다. 중간에 값을 찍어 거기까지 멀쩡하면 문제는 뒤에, 아니면 앞에 있다. 절반씩 잘라 들어가면 아무리 긴 코드도 몇 번 만에 범인을 만난다. 결국 디버깅은 짐작을 버리고 실제 값을 눈으로 확인하는 일이고, 이 함정들도 값을 한 번만 찍어봤으면 안 헤맸을 것들이다.