자바스크립트 형변환 - == 와 === 차이, 암묵적 변환이 만드는 버그
이 단원에서 배우는 것
2단원에서 값의 종류가 원시값 7가지와 객체로 나뉜다는 것과, typeof가 늘 정직하지는 않다는 것을 봤다. 이번 단원은 그 종류가 서로 섞였을 때 무슨 일이 벌어지는지 다룬다. 자바스크립트가 초보에게 악명 높은 이유의 절반이 여기에 있고, 스터디 참가자 명단 같은 평범한 화면에서도 매일 터지는 문제다.
- 값을 다른 종류로 바꾸는 명시적 방법(
Number,String,Boolean)과 그 경계값을 외운다. ==가 내부적으로 무슨 일을 하는지 설명하고,===를 기본으로 쓰는 이유를 근거로 댄다.- 암묵적 변환 때문에 생기는 버그를 재현하고, 입력값을 어느 지점에서 변환해야 하는지 정한다.
ES2022 기준이며 이 단원의 동작은 브라우저와 Node.js가 완전히 동일하다.
왜 필요한가
스터디 참가자의 출석 횟수를 화면 입력창에서 받아 1을 더한다고 하자.
const input = "5"; // 화면 입력값은 무조건 문자열로 들어온다
console.log(input + 1); // 51
console.log(input - 1); // 4
+는 "51"인데 -는 4다. 오류도 경고도 없다. 출석 횟수가 51회로 저장되고, 그 값이 DB에 들어가고, 며칠 뒤 통계가 이상하다는 제보로 돌아온다. 자바스크립트는 타입이 안 맞으면 멈추는 대신 알아서 맞춰 주는 언어라서, 잘못된 코드가 오류 없이 잘못된 결과를 만든다.
이 성질은 없앨 수 없다. 그래서 전략은 하나뿐이다. 변환이 일어나는 지점을 내가 먼저 정하고, 그 외에는 변환이 끼어들 수 없게 막는 것이다. 앞은 명시적 변환, 뒤는 === 사용이다.
문법과 예제
명시적 변환 — 숫자로
console.log(Number("5")); // 5
console.log(Number("5.5")); // 5.5
console.log(Number("12px")); // NaN — 숫자가 아니면 정직하게 NaN
console.log(parseInt("12px")); // 12 — 앞에서부터 읽다가 숫자가 끊기면 거기까지
console.log(parseFloat("3.5회")); // 3.5
console.log(+"5"); // 5 — 단항 플러스. Number() 와 같다
Number와 parseInt는 목적이 다르다. 사용자 입력을 검증할 때는 Number를 쓴다. "12px"를 12로 받아 주면 검증이 아니라 추측이 된다. parseInt는 "12px", "3rem"처럼 단위가 붙은 값에서 숫자만 뽑아야 할 때 쓴다.
변환 결과 — 외워야 하는 경계값
| 값 | Number(값) | Boolean(값) |
|---|---|---|
"" (빈 문자열) | 0 | false |
" " (공백만) | 0 | true |
"0" | 0 | true |
"12px" | NaN | true |
null | 0 | false |
undefined | NaN | false |
true / false | 1 / 0 | true / false |
[] (빈 배열) | 0 | true |
[5] | 5 | true |
{} | NaN | true |
굵게 표시한 다섯 칸이 사고의 90%를 만든다. 특히 null은 숫자로 바꾸면 0인데 undefined는 NaN이다. 둘을 같은 것처럼 다루다가 합계가 NaN이 되는 일이 흔하다.
암묵적 변환의 규칙
규칙은 사실 두 줄이다.
+는 한쪽이라도 문자열이면 문자열 이어붙이기가 된다.- 나머지 산술 연산자(
-,*,/,%)는 양쪽을 무조건 숫자로 바꾼다.
console.log("5" + 1); // "51" 문자열
console.log("5" - 1); // 4 숫자
console.log("5" * "2"); // 10 숫자
console.log(1 + 2 + "회"); // "3회" — 왼쪽부터 차례로 계산된다
console.log("회" + 1 + 2); // "회12" — 이미 문자열이 됐으므로 계속 이어붙는다
마지막 두 줄이 실무형 예제다. 참가 인원을 문자열과 섞어 출력할 때 자주 만나는데, 2단원에서 배운 템플릿 리터럴을 쓰면 이 고민이 사라진다.
const count = 1 + 2;
console.log(`참가자 ${count}명`); // 참가자 3명
=== 와 == 의 차이
===는 타입이 다르면 그 자리에서 false다. ==는 타입이 다르면 한쪽을 변환한 뒤 비교한다.
console.log(5 === "5"); // false — 타입이 다르다
console.log(5 == "5"); // true — "5" 를 5 로 바꾼 뒤 비교
console.log(0 == ""); // true
console.log(0 == "0"); // true
console.log("" == "0"); // false ← 앞 두 줄과 모순처럼 보인다
console.log(false == "0"); // true
console.log(null == undefined); // true
console.log(null == 0); // false
세 줄을 붙여 놓으면 규칙이 아니라 예외 목록처럼 보인다. 실제로 그렇다. ==의 변환 규칙은 표를 봐야 알 수 있고, 표를 외운 사람도 리뷰에서 매번 다시 확인해야 한다. 그래서 실무 규칙은 하나다. ===와 !==만 쓴다.
예외를 딱 하나 인정하는 팀이 많다. value == null은 "null 또는 undefined"를 한 번에 검사하는 관용구다. 다만 4단원에서 배울 옵셔널 체이닝과 ??를 쓰면 이것도 거의 필요 없어진다.
비교 연산자와 문자열
console.log(3 > 12); // false
console.log("3" > "12"); // true — 문자열끼리는 사전순 비교
양쪽이 모두 문자열이면 숫자로 바꾸지 않고 글자 순서로 비교한다. "3"의 첫 글자가 "1"보다 뒤이므로 참이다. 출석 횟수를 문자열로 들고 다니면 정렬과 조건 판정이 전부 이런 식으로 조용히 틀어진다.
실무에서 자주 틀리는 것
1. 입력값을 변환하지 않고 그대로 계산에 넣는다
화면 입력창에서 읽은 값은 항상 문자열이다. 숫자만 입력받는 칸으로 만들어도 문자열이다. 서버가 JSON으로 준 값은 숫자일 수도 문자열일 수도 있다. 이 둘이 한 함수 안에서 만나면 결과가 입력 경로에 따라 달라진다.
// 나쁜 예 — 어디선가 문자열이 섞이면 조용히 이어붙는다
let total = 0;
total = total + "5";
total = total + 3;
console.log(total); // "053"
// 좋은 예 — 값이 들어오는 경계에서 한 번만 변환한다
function toSessionCount(raw) {
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) {
return 0; // 변환 실패는 여기서 처리하고 밖으로 내보내지 않는다
}
return n;
}
console.log(toSessionCount("5") + 3); // 8
console.log(toSessionCount("12px") + 3); // 3
(if 조건문은 4단원, function은 5단원에서 제대로 다룬다. 여기서는 "조건이 참이면 중괄호 안을 실행한다", "값을 받아 결과를 돌려주는 코드 묶음" 정도로 읽으면 충분하다.)
핵심은 "변환을 어디서 하느냐"다. 계산하는 곳마다 Number()를 뿌리면 빠뜨리는 곳이 반드시 생긴다. 값이 시스템에 들어오는 입구에서 한 번 변환하고, 그 뒤로는 숫자라고 믿는다.
2. NaN 을 == 나 === 로 검사한다
const n = Number("12px");
console.log(n); // NaN
console.log(n === NaN); // false ← 항상 false 다
console.log(Number.isNaN(n)); // true
console.log(isNaN("abc")); // true — 문자열을 숫자로 바꾼 뒤 판정한다
console.log(Number.isNaN("abc")); // false — "abc" 는 NaN 이라는 값이 아니다
NaN은 자기 자신과도 같지 않은 유일한 값이다. 판별은 Number.isNaN으로 한다. 이름이 비슷한 옛 함수 isNaN은 인자를 먼저 숫자로 바꾸기 때문에 "숫자로 바꿀 수 없는 값인가"를 검사하는 다른 함수다. 실무에서는 Number.isFinite가 더 쓸모 있다. NaN과 Infinity를 한 번에 걸러 준다.
3. 값이 있는지 확인하려고 == 로 0 이나 빈 문자열과 비교한다
const sessions = 0; // 출석 0회인 신규 참가자
if (sessions == false) {
console.log("출석 정보 없음"); // 이 줄이 실행된다 — 0 은 false 와 == 로 같다
}
"출석 0회"라는 정상적인 값이 "정보 없음"으로 분류된다. 신규 참가자만 명단에서 사라지는 종류의 버그다. 판정 대상이 "값이 없음"인지 "값이 0"인지 먼저 정하고, 그에 맞는 검사를 쓴다.
if (sessions === undefined || sessions === null) {
console.log("출석 정보 없음");
} else if (sessions === 0) {
console.log("아직 출석 전");
}
4단원에서 이 조건을 훨씬 짧게 쓰는 방법(??)을 배운다. 다만 지금 단계에서 왜 ==로 뭉뚱그리면 안 되는지를 이해하는 게 먼저다.
4. 두 값을 더해 놓고 문자열인지 확인하지 않는다
const rows = [{ name: "김지훈", sessions: "3" }, { name: "박서연", sessions: 2 }];
let sum = 0;
sum = sum + rows[0].sessions + rows[1].sessions;
console.log(sum); // "032"
console.log(typeof sum); // string
합계가 "032"인데 화면에는 그냥 032로 보인다. 숫자처럼 보이는 문자열은 눈으로 구분되지 않는다. 계산 결과가 이상하면 값이 아니라 typeof를 먼저 찍어 보는 습관이 시간을 아껴 준다.
스스로 확인하기
- 다음 세 줄의 출력을 예측하고, 각각 어떤 규칙이 적용됐는지 설명하라.
console.log("10" - "3" + "1"); console.log("10" + "3" - "1"); console.log(1 + 2 + "명" + 1 + 2); - 참가자의 출석 횟수
raw가"0","",null,"3회"네 가지로 들어올 수 있다.Number(raw)의 결과를 각각 쓰고, 이 중 "잘못된 입력"으로 걸러야 하는 것은 무엇인가? - 다음 조건문은 회비를 낸 사람만 통과시키려는 의도인데
paid가 문자열"false"로 들어오면 어떻게 되는가? 어떻게 고쳐야 하는가?const paid = "false"; if (paid) { console.log("납부 완료"); }
정답
- 순서대로 문자열
"71", 숫자102, 문자열"3명12". 첫 줄은-가 양쪽을 숫자로 바꿔7을 만든 뒤,+가 문자열"1"을 만나 이어붙였다. 둘째 줄은+가 먼저"103"을 만들고-가 이를 숫자로 바꿔102가 됐다. 셋째 줄은 왼쪽부터3을 만든 뒤"명"을 만나 문자열이 되고, 그 뒤로는 계속 이어붙는다. "0"은0,""은0,null은0,"3회"는NaN. 걸러야 하는 것은"3회"뿐 아니라""과null도 포함해야 한다. 셋 다Number가0으로 만들어 주지만 "0회 출석"과 "입력 없음"은 다른 상태이므로, 변환 전에raw === "" || raw === null을 따로 검사해야 한다."false"는 빈 문자열이 아니므로 참으로 판정되어 "납부 완료"가 출력된다. 문자열을 그대로 조건에 넣지 말고if (paid === true)처럼 실제 불리언과 비교하거나, 값이 들어오는 입구에서const paid = raw === "true";로 변환해 두어야 한다.