자바스크립트 기초: 타입·== 와 ===·NaN 을 jsc 로 실행하며
자바스크립트 강좌 1. 맥 기본 jsc 로 5분 만에 실행, let/const/var 오류, typeof null, == 와 === 실행표, 0.1+0.2, "12,000원" 파싱과 부가세 올림 함정까지 실측.
시리즈: 자바스크립트 강좌 1/6 (초급). 자바스크립트를 처음 써 보는 분, 또는 복사해 붙여서 돌려는 봤지만 "10" + 5 가 왜 "105" 인지 아직 설명하지 못하는 분을 위한 글입니다. 다 읽으면 터미널에서 5분 안에 자바스크립트를 실행하고, let·const·var 를 골라 쓰고, 초보자가 반드시 만나는 오류 메시지를 읽고, == 가 거짓말하는 순간을 알아볼 수 있습니다.
자바나 C 로 프로그래밍을 배우고 입사한 분이 자바스크립트에서 가장 먼저 걸리는 것은 문법이 아니라 "알아서 해 주는 타입 변환"입니다. 입력창에서 온 값이 문자열인 채로 더해져 "105" 가 되고, "12,000원" 을 parseInt 에 넣으면 12 가 나오고, 부가세를 Math.ceil(price * 1.1) 로 올림했더니 100 원짜리가 111 원이 됩니다. 셋 다 예외 없이 조용히 틀린 숫자가 흘러가기 때문에, 발견했을 때는 이미 정산서에 찍혀 있습니다. 이 글의 출력은 모두 Apple Silicon Mac(M1 Pro)의 macOS 26.6 에 기본으로 들어 있는 JavaScriptCore(사파리의 엔진) 셸 jsc 로 직접 실행한 결과입니다. Node.js·Deno·브라우저는 실행하지 않았습니다. 동작이 다를 수 있는 곳은 그때그때 적었습니다.
5분 만에 첫 프로그램
맥에는 처음부터 자바스크립트 엔진과 그 명령줄 셸이 들어 있습니다. PATH 에는 없으므로 먼저 짧은 이름을 붙입니다.
$ alias jsc=/System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/Helpers/jsc
$ jsc -e 'print(1 + 2)'
3
계속 쓰려면 alias 줄을 ~/.zshrc 에 넣어 둡니다. 이제 hello.js 를 만듭니다.
print("Hello, JavaScript!");
console.log("브라우저와 Node 에서 쓰는 방식");
실행하면 이렇게 나옵니다.
$ jsc hello.js
Hello, JavaScript!
Exception: TypeError: undefined is not an object (evaluating 'console.log')
global code@hello.js:2:8
$ echo $?
3
첫 오류입니다. 천천히 읽어 봅시다.
print()는jsc셸의 출력 함수입니다. 브라우저와 Node.js 에서는console.log()를 씁니다.- 이
jsc에는console이 없습니다(typeof console이undefined였습니다). 그래서console.log는 "undefined의 속성log를 읽어라"가 되고, 이것이TypeError(맞지 않는 타입의 값에 연산을 했다)입니다. global code@hello.js:2:8은 스택 트레이스로,hello.js파일의 2번째 줄 8번째 칸이라는 뜻입니다. Exception 은 아무도 오류를 잡지 않았다는 뜻이라 프로그램이 멈췄고, 종료 코드는 3 이었습니다.
같은 파일을 jsc 와 브라우저 양쪽에서 돌리고 싶다면 맨 위에 globalThis.console ??= { log: print }; 한 줄이면 됩니다. 이 강좌에서는 그냥 print 를 씁니다. 파일 없이 jsc 만 실행하면 대화형 모드가 되고(Ctrl+D 로 종료), jsc -i 에 입력을 흘려 넣어 보니 0.1 + 0.2 는 0.30000000000000004, typeof null 은 "object" 가 나왔습니다. 둘 다 뒤에서 설명합니다.
let·const·var 와 각각의 오류
변수는 값에 붙이는 이름입니다. 요즘 자바스크립트에는 선언 방법이 두 가지, 옛 방법이 하나 있습니다.
const TAX = 0.1; // 다시 대입할 수 없다
let count = 0; // 다시 대입할 수 있다
count = count + 1;
var legacy = 1; // 2015년 이전 방식. 새 코드에서는 쓰지 않는다
실패하는 방식이 각각 다릅니다. const 에 다시 대입하면
$ jsc const.js # const TAX = 0.1; TAX = 0.2;
Exception: TypeError: Attempted to assign to readonly property.
global code@const.js:1:21
같은 이름의 let 을 두 번 선언하면 실행하기도 전에 SyntaxError(애초에 올바른 자바스크립트가 아니다)로 멈춥니다.
$ jsc redecl.js # let name = "Mina"; let name = "Ken";
Exception: SyntaxError: Cannot declare a let variable twice: 'name'.
at redecl.js:1
var 만 특이합니다. 선언이 호이스팅(hoisting, 끌어올림) 되어 선언 줄보다 앞에서도 이름이 존재하고, 값은 undefined 입니다. let·const 도 끌어올려지지만 선언 전에 읽으면 오류입니다.
print(a); // undefined
var a = 1;
print(b); // 오류
let b = 2;
undefined
Exception: ReferenceError: Cannot access 'b' before initialization.
global code@hoist.js:3:8
let/const 줄 앞의 구간을 TDZ(Temporal Dead Zone, 일시적 사각지대) 라고 부르고, 함수 안에서 어떻게 문제가 되는지는 2강에서 봅니다. ReferenceError 는 "그 이름을 쓸 수 없다"는 뜻이고, 단순한 오타에서도 납니다. print(totl) 은 ReferenceError: Can't find variable: totl 이었습니다. Node 나 크롬에서는 같은 오류가 totl is not defined 로 나오므로(여기서는 실행 안 함), 검색할 때는 두 문구로 다 찾아보세요.
반대로 기본 설정에서는 알려 주지 않는 오타가 있습니다.
count = 5; // 어디서도 선언하지 않았다
print(count, typeof globalThis.count); // 5 number
선언하지 않은 이름에 대입하면 조용히 전역 변수가 생깁니다. 파일 첫 줄에 "use strict"; 를 쓰면 같은 대입이 ReferenceError: Can't find variable: count 가 됩니다. ES 모듈과 class 내부는 항상 strict 모드라서, 요즘 코드는 대개 이 보호를 처음부터 받고 있습니다.
원칙: 기본은 const, 값이 정말 바뀔 때만 let, 새 코드에서 var 는 쓰지 않는다.
타입과 typeof
모든 값에는 타입이 있습니다. 자바스크립트에는 일곱 가지 원시 타입(단순하고 바꿀 수 없는 값)과 객체가 있습니다. typeof 연산자로 타입을 알 수 있는데, 유명한 예외가 두 개 있습니다. types.js 의 출력입니다.
42 number
3.14 number
hi string
true boolean
undefined undefined
null object
10n bigint
Symbol(id) symbol
{} object
[] object
function(){} function
NaN number
눈여겨볼 점은 이렇습니다.
- 정수와 소수가 하나의
number타입(64비트 부동소수점)입니다. 자바의int/double구분이 없습니다. 2⁵³ 을 넘는 정수용으로 별도의bigint(10n으로 씀)가 있습니다. typeof null은"object"입니다. 초기 버그가 호환성 때문에 남은 것이니 null 확인은value === null로 합니다.- 배열도 객체라서
typeof []는"object"입니다.Array.isArray([])를 씁니다(true였습니다). NaN(Not a Number)의 타입은number입니다. 계산에 실패한 결과로 나온 "숫자"라고 생각하면 납득이 됩니다.undefined는 "한 번도 값이 들어간 적 없음",null은 "일부러 비워 둠"입니다. API 는 둘 다 돌려주므로 뒤에 나오는== null이 쓸모가 있습니다.
== 와 ===: 직접 돌려 본 비교표
===(일치 연산자)는 타입을 바꾸지 않고 비교하므로 타입이 다르면 무조건 false 입니다. ==(동등 연산자)는 먼저 양쪽을 정해진 규칙으로 변환한 다음 비교합니다. 아래 조합을 두 연산자로 모두 실행했습니다(eqtable.js).
| a | b | a == b |
a === b |
|---|---|---|---|
0 |
"0" |
true | false |
0 |
"" |
true | false |
0 |
false |
true | false |
"0" |
false |
true | false |
"" |
false |
true | false |
"" |
"0" |
false | false |
null |
undefined |
true | false |
null |
0 |
false | false |
null |
false |
false | false |
undefined |
false |
false | false |
NaN |
NaN |
false | false |
[] |
0 |
true | false |
[] |
"" |
true | false |
[] |
false |
true | false |
"1" |
1 |
true | false |
2·5·6번째 줄을 보세요. 0 == "" 도 0 == "0" 도 true 인데 "" == "0" 은 false 입니다. 느슨한 비교는 추이성(a=b, b=c 이면 a=c)조차 성립하지 않아서 수학의 "=" 처럼 생각하면 안 됩니다. [] == false 가 true 인 것은 빈 배열이 "" 로, 다시 0 으로 바뀌기 때문입니다.
남길 가치가 있는 느슨한 비교는 x == null 하나입니다. null 과 undefined 일 때만 true 이고 나머지는 전부 false 입니다(7~9번째 줄). "이 필드가 아예 안 왔나?"를 볼 때 편합니다. 다만 "항상 ===" 라는 린터 규칙을 단순하게 유지하려고 x === null || x === undefined 라고 쓰는 팀도 많습니다.
| 알고 싶은 것 | 이렇게 | 이렇게 쓰지 말 것 |
|---|---|---|
| 값과 타입이 모두 같은가 | a === b |
a == b |
x 가 null 또는 undefined 인가 |
x == null 또는 x ?? 기본값 |
!x (0 과 "" 도 걸림) |
x 가 NaN 인가 |
Number.isNaN(x) |
x === NaN (항상 false) |
x 가 배열인가 |
Array.isArray(x) |
typeof x === "object" |
x 가 정말 null 인가 |
x === null |
typeof x === "object" |
숫자: 0.1 + 0.2, NaN, 그리고 부가세
print(0.1 + 0.2); // 0.30000000000000004
print(0.1 + 0.2 === 0.3); // false
print(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON); // true
print((0.1 + 0.2).toFixed(2), typeof (0.1 + 0.2).toFixed(2)); // 0.30 string
print(7 / 2, Math.trunc(7 / 2), 7 % 2, -7 % 2); // 3.5 3 1 -1
print(1 / 0, -1 / 0, 0 / 0); // Infinity -Infinity NaN
print(Number.MAX_SAFE_INTEGER, 2 ** 53 + 1, 2n ** 53n + 1n);
// 9007199254740991 9007199254740992 9007199254740993
print(1.15.toFixed(1), 1.25.toFixed(1), 1.35.toFixed(1)); // 1.1 1.3 1.4
10진수에서 1/3 이 나누어떨어지지 않듯이 0.1 은 2진수로 정확히 표현되지 않아 작은 오차가 생깁니다. 자바와 달리 7 / 2 는 3 이 아니라 3.5 이고, 정수 나눗셈이 필요하면 Math.trunc 를 씁니다. 정수는 Number.MAX_SAFE_INTEGER(2⁵³ − 1)까지는 정확하지만, 하나 넘긴 2 ** 53 + 1 은 ...993 이 아니라 ...992 가 됐습니다. BigInt 는 맞게 계산합니다. 0 으로 나눠도 예외가 나지 않고 Infinity 나 NaN 이 됩니다. toFixed 는 문자열을 돌려주고, 입력한 10진수가 아니라 내부의 2진수 값을 반올림하므로 1.15 는 실제로 1.149999… 라서 1.1 이, 1.25 는 1.3 이 됩니다.
실무에서 가장 가까운 예는 부가세 10% 입니다. 100 원에 1.1 을 곱해 올림해 봅니다.
print(100 * 1.1, Math.ceil(100 * 1.1), Math.ceil(100 * 110 / 100));
// 110.00000000000001 111 110
100 * 1.1 이 110 을 아주 조금 넘기 때문에 Math.ceil 로 111 원이 됩니다. 1~10,000 원의 모든 가격에서 Math.ceil(p * 1.1) 과 정수로 계산한 Math.ceil(p * 110 / 100) 을 비교하니 460 건이 달랐습니다(처음 몇 개는 50, 90, 100, 110 원). 내림(Math.floor)으로는 같은 범위에서 0 건이었는데, 이는 오차가 우연히 위쪽으로 났기 때문일 뿐입니다. 세율은 정수(110)로 곱한 다음 100 으로 나눈다가 가장 안전합니다.
NaN 에는 고유한 규칙이 있습니다.
const age = Number("twenty");
print(age, typeof age); // NaN number
print(age === NaN, age == NaN); // false false
print(Number.isNaN(age), isNaN("hello"), Number.isNaN("hello")); // true true false
print(parseInt("42px"), Number("42px"), Number(""), Number(" 7 "), Number(null));
// 42 NaN 0 7 0
NaN 은 자기 자신을 포함해 무엇과도 같지 않습니다. 옛 전역 함수 isNaN("hello") 는 먼저 변환하기 때문에 true 를 돌려줍니다. 변환하지 않는 Number.isNaN 을 쓰세요. parseInt("42px") 는 숫자가 아닌 문자가 나올 때까지 읽고, Number("42px") 는 전체를 거부합니다. 그리고 Number("") 는 NaN 이 아니라 0 입니다(연습 문제 2).
문자열과 템플릿 리터럴
const price = 1200, qty = 3;
print(`합계: ${price * qty}원 (${qty}개)`); // 합계: 3600원 (3개)
print('합계: ${price}'); // 합계: ${price}
print(`${null} ${undefined} ${[1, 2]} ${{a: 1}}`); // null undefined 1,2 [object Object]
백틱()으로 감싼 것이 **템플릿 리터럴**입니다.${...}안에 아무 식이나 넣을 수 있고 줄바꿈도 그대로 씁니다. 작은따옴표·큰따옴표에서는 끼워 넣기가 되지 않아 두 번째 줄은${price}가 그대로 나왔습니다. 객체를${}에 넣으면[object Object]가 되니, 내용을 보려면JSON.stringify(obj)` 를 씁니다.
흔한 실수: 입력창 값을 그대로 더하기
실수. 텍스트 입력, URL 파라미터, prompt(), 대부분의 CSV 값은 숫자처럼 보여도 문자열입니다.
const input = "10";
print(input + 5, input - 5, input * 2); // 105 5 20
실제로 일어나는 일. + 는 "덧셈"과 "문자열 이어 붙이기"를 둘 다 뜻하고, 한쪽이라도 문자열이면 이어 붙입니다. 다른 산술 연산자는 수학 뜻밖에 없으므로 문자열을 숫자로 바꿉니다. "3" * "4" 는 12, 유명한 "b" + "a" + +"a" + "a" 는 baNaNa 였습니다(단항 +"a" 가 NaN). 예외는 나지 않고 틀린 숫자가 그대로 합계에 들어갑니다.
한국 쇼핑몰 화면에서 긁어 온 가격 문자열에도 함정이 있습니다.
print(Number("₩12,000"), Number("12000원"), parseInt("12000원"));
// NaN NaN 12000
print(Number("1,200"), parseInt("1,200"), Number("1,200".replaceAll(",", "")));
// NaN 1 1200
parseInt("12000원") 은 뒤의 "원" 을 무시하고 12000 을 돌려주니 편해 보이지만, 천 단위 쉼표가 있는 "1,200" 은 조용히 1 이 됩니다. 쉼표와 단위를 먼저 지운 다음 Number 로 바꾸는 쪽이 안전합니다.
고치는 법. 값이 들어오는 경계에서 한 번만 변환하고 확인합니다.
const qty = Number(input.replaceAll(",", "").replace(/원$/, ""));
if (!Number.isFinite(qty)) throw new Error(`숫자가 아닙니다: ${input}`);
print(qty + 5); // 15
기본값에 || 를 쓰는 것도 같은 계열의 버그입니다. || 는 falsy 값(0, "", NaN, null, undefined, false)을 전부 바꿔 버리므로 수량 0 이 사라집니다.
const qty0 = 0;
print(qty0 || 10, qty0 ?? 10); // 10 0
??(널 병합 연산자)는 null 과 undefined 만 바꿉니다. 기본값에는 이쪽을 씁니다.
실측: 금액을 소수로 더할까, 정수로 더할까
부동소수점 오차가 실제로 얼마나 쌓이는지 0.10(10센트)을 소수로 N 번, 10 을 정수 센트로 N 번 더해 봤습니다(drift.js, 난수 없음).
N=10: float=0.9999999999999999 cents/100=1 error=-1.11e-16
N=1000: float=99.9999999999986 cents/100=100 error=-1.41e-12
N=1000000: float=100000.00000133288 cents/100=100000 error=1.33e-6
N=10000000: float=999999.9998389754 cents/100=1000000 error=-1.61e-4
1,000만 번이면 0.00016 이 어긋납니다. 영수증 한 장에서는 안 보이지만, 은행 입출금과 원 단위까지 맞춰야 하는 월 정산에서는 드러납니다. 정수 쪽은 매번 정확합니다.
속도도 쟀습니다. N = 10,000,000 인 각 루프를 performance.now() 로 재고, 한 프로세스 안에서 5번, 별도 프로세스로 2번, Apple M1 Pro, macOS 26.6 의 jsc 입니다.
float ms: 10.8, 9.5, 9.6, 9.5, 9.5
cents ms: 4.7, 3.2, 3.1, 3.2, 3.2
float ms: 10.7, 9.5, 9.5, 9.4, 9.5
cents ms: 4.4, 3.2, 3.1, 3.1, 3.2
두 번째 실행부터 약 9.5 ms 대 3.2 ms 입니다(첫 실행은 JIT 컴파일이 섞여 느리고, 이것은 6강에서 다룹니다). 이유를 엔진 내부까지 확인하지는 않았습니다. 작은 정수를 빠른 정수 표현 그대로 다룰 수 있다는 점, 부동소수점 덧셈은 직전 결과를 기다려야 한다는 점이 원인으로 보이지만 추측입니다. 정수로 다루는 이유는 어디까지나 정확성이고 속도는 덤입니다. 참고로 jsc 자체의 시작은 가벼워서 jsc hello2.js 는 20번 중앙값 8.0 ms 였습니다(3묶음 8.0·8.0·8.1 ms).
한계와 버전 메모
- 엔진: macOS 26.6.2 에 들어 있는 JavaScriptCore(프레임워크 버전 21624.5.1.11.3)를
jsc셸로 실행했습니다. Node.js·Deno·브라우저는 실행하지 않았습니다. 위의 비교·typeof·숫자 결과는 ECMAScript 명세가 정하는 것이라 다른 엔진에서도 같아야 하지만, 오류 메시지 문구는 엔진마다 다릅니다. - jsc 만의 사정:
print는 엔진 셸에만 있습니다. 이jsc에서는console이undefined였습니다(브라우저와 Node 에는 있습니다). DOM 도prompt()도fetch도 없습니다. - 버전:
let·const·템플릿 리터럴·Number.isNaN·Number.EPSILON·Symbol은 ES2015,BigInt·??·globalThis는 ES2020, 숫자 구분자(10_000_000)와replaceAll은 ES2021 에 들어왔습니다. 모두 이jsc에서 동작했습니다. - 시간 측정은 한 대의 기기, 한 버전에서 잰 값으로 규모 감을 잡기 위한 것이지 엔진끼리 순위를 매기려는 것이 아닙니다.
연습 문제
1. 출력을 예상한 다음 실행해 보세요: print("5" + 3, "5" - 3, [] == false, null == 0, typeof NaN);
2. 입력창의 문자열을 받아 "29" 면 true, "abc"·""·" "·"29.5" 면 false 를 돌려주는 isValidAge(s) 를 작성하세요.
3. 다음 코드는 0.6000000000000001 을 출력합니다. 0.60 이 나오게 고치고, 왜 정확해지는지 설명하세요.
let total = 0;
for (const p of [0.1, 0.2, 0.3]) total += p;
print(total);
정답
1. 실제 출력은 53 2 true false number 입니다. + 는 문자열이 있으면 이어 붙이고, - 는 숫자로 바꾸고, [] 는 "" 를 거쳐 0 이, false 도 0 이 되며, null 이 느슨하게 같은 것은 undefined 뿐입니다.
2. 바로 떠오르는 Number.isInteger(Number(s)) && Number(s) >= 0 은 두 군데서 틀립니다. Number("") 와 Number(" ") 는 0, Number("0x1F") 는 31 입니다. 실제로 돌려 보니 "0x1F" 가 통과했습니다. 엄격한 버전은 이렇습니다.
function isValidAge(s) {
const t = s.trim();
return /^\d{1,3}$/.test(t) && Number(t) < 150;
}
"29" 와 " 7 " 에서 true, "abc"·""·" "·"29.5"·"-1"·"0x1F"·"1e2" 에서 false 였습니다.
3. 금액을 정수(센트나 원)로 다루고 마지막에 표시용으로 바꿉니다. let cents = 0; for (const p of [10, 20, 30]) cents += p; print((cents / 100).toFixed(2)); 는 0.60 을 출력했습니다. 정수 덧셈은 정확하고, 나눗셈은 표시 직전에 한 번만 일어납니다.
다음 강좌
2강은 함수입니다. 함수 선언과 화살표 함수의 차이, this 의 정체, 클로저, 반복문 안의 var 함정, 함수 안에서 나는 TDZ 오류를 직접 일으켜 봅니다.
- 다음: 자바스크립트 강좌 2/6: 함수·스코프·클로저
- 시리즈 이후: 객체·배열·메서드, 클래스·프로토타입·모듈, 비동기·Promise·이벤트 루프, 성능·메모리·숫자
- 다른 언어의 같은 주제: 자바 강좌 1: 자료형·문자열·동등성, 파이썬 강좌 1: 자료형과 가변성
- 전체 지도: 프로그래밍 학습 로드맵
자주 묻는 질문
자바스크립트에서 == 와 === 중 무엇을 써야 하나요?
기본은 === 입니다. == 는 비교 전에 타입을 바꾸기 때문에 0 == "", "0" == false, [] == false 가 모두 true 인데 "" == "0" 은 false 입니다. 일부러 == 를 써도 되는 경우는 null 과 undefined 에만 true 가 되는 x == null 정도입니다.
0.1 + 0.2 가 왜 0.3 이 아닌가요?
자바스크립트의 숫자는 모두 64비트 2진 부동소수점이고 0.1, 0.2 는 2진수로 정확히 표현되지 않기 때문입니다. 결과는 0.30000000000000004 입니다. 비교는 허용 오차로 하고, 반올림은 화면에 보일 때만 하고, 금액은 정수(원 단위)로 다룹니다. 100 * 1.1 은 110.00000000000001 이라 Math.ceil 로 올리면 111 원이 됐습니다.
typeof null 이 왜 "object" 인가요?
자바스크립트 초기 구현의 버그인데, 옛 웹 페이지를 깨뜨리지 않으려고 지금도 고치지 못합니다. null 은 별개의 원시 타입입니다. null 확인은 value === null, 배열 확인은 Array.isArray 를 씁니다(typeof [] 도 "object" 입니다).