JavaScript의 기묘한 동작 원리
타입 강제 변환, typeof null, 부동소수점 연산 등 JavaScript 기묘한 동작의 원리 분석
읽는 데 29분
- #javascript
- #type-coercion
- #ecmascript
- #fundamentals
이 문서의 목차
적용 환경: ECMAScript 2015+ (ES6+)
JavaScript로 개발하다 보면 예상과 전혀 다른 결과를 마주할 때가 있습니다. [] + []가 빈 문자열이 되고, typeof null이 "object"를 반환하며, 0.1 + 0.2가 정확히 0.3이 되지 않는 상황이 대표적입니다. 처음 이런 결과를 보면 버그가 아닌가 의심하게 되지만, 사실 이 동작들은 모두 ECMAScript 명세에 정확히 정의되어 있는 정상적인 동작입니다.
이러한 "기묘한" 동작의 배경에는 JavaScript가 탄생한 1995년의 설계 결정, 타입 변환에 관한 복잡한 규칙, 그리고 IEEE 754 부동소수점 표준이 복합적으로 작용하고 있습니다. 이 문서에서는 개발자들 사이에서 유명한 JavaScript 밈들과 예상 밖의 동작들이 왜 그렇게 동작하는지 살펴봅니다.
JavaScript에서 발생하는 많은 예상 밖의 동작은 암묵적 타입 변환(type coercion)에서 비롯됩니다. 특히 + 연산자는 피연산자의 타입에 따라 숫자 덧셈이 될 수도 있고 문자열 연결이 될 수도 있어서, 개발자가 의도하지 않은 결과를 만들어내는 주요 원인이 됩니다.
인터넷에서 유명한 JavaScript 밈 중 하나로, 문자열 조합만으로 "banana"를 만들어내는 예제가 있습니다.
("b" + "a" + +"a" + "a").toLowerCase()
// 결과: "banana"언뜻 보면 마법처럼 보이지만, 이 표현식이 어떻게 "banana"가 되는지 단계별로 분석해보면 JavaScript의 타입 변환 규칙을 명확히 이해할 수 있습니다. 핵심은 +"a" 부분인데, 이 표현식을 파싱하면 실제로는 'b' + 'a' + (+'a') + 'a'로 해석됩니다.
여기서 단항 + 연산자가 중요한 역할을 합니다. ECMAScript 명세의 ToNumber 연산에 따르면, 단항 +는 피연산자를 숫자로 변환하려 시도하는데, 문자열 'a'는 유효한 숫자 리터럴이 아니므로 결과는 NaN이 됩니다. NaN도 엄연히 number 타입의 값이라는 점이 흥미롭습니다.
+"a" // NaN
typeof +"a" // "number"이제 전체 표현식은 'b' + 'a' + NaN + 'a'가 됩니다. + 연산자는 피연산자 중 하나라도 문자열이면 나머지를 모두 문자열로 변환해서 연결하는 특성이 있으므로, NaN은 문자열 "NaN"으로 변환됩니다. 그 결과 "baNaNa"가 만들어지고, toLowerCase()를 거쳐 최종적으로 "banana"가 됩니다.
타입 변환이 더 복잡하게 작용하는 예시를 살펴보겠습니다.
[] + [] // ""
[] + {} // "[object Object]"
{} + [] // 0 (콘솔에서 직접 입력 시)이 결과들이 나오는 이유를 이해하려면 ToPrimitive 연산을 알아야 합니다. + 연산자가 객체를 만나면 먼저 해당 객체를 원시 값으로 변환하려 시도하는데, 이 과정에서 valueOf()와 toString() 메서드가 순서대로 호출됩니다.
배열의 경우 valueOf()는 배열 자신을 반환하므로 원시 값이 아니고, 따라서 toString()이 호출됩니다. 빈 배열의 toString()은 빈 문자열 ""을 반환하며, 일반 객체의 toString()은 "[object Object]"를 반환합니다. 이러한 변환 규칙을 알고 나면 위 결과들이 자연스럽게 이해됩니다.
[].valueOf() // [] (원시 값 아님, 배열 자체 반환)
[].toString() // "" (빈 문자열)
({}).valueOf() // {} (원시 값 아님, 객체 자체 반환)
({}).toString() // "[object Object]"따라서 [] + []는 "" + ""가 되어 빈 문자열이 되고, [] + {}는 "" + "[object Object]"가 되어 "[object Object]"가 됩니다.
{} + []가 0인 이유
콘솔에서 {} + []를 직접 입력하면 0이 나오는데, 이는 {}가 빈 코드 블록으로 해석되기 때문입니다. 실제로 평가되는 것은 +[] 부분뿐이며, 이는 ToNumber("")로 0이 됩니다. 만약 표현식 컨텍스트에서 평가하면, 예를 들어 ({} + [])처럼 괄호로 감싸면 "[object Object]"가 됩니다.
+ 연산자는 왼쪽에서 오른쪽으로 순서대로 평가되는 좌결합 연산자입니다. 이 평가 순서가 결과에 큰 영향을 미치므로, 문자열과 숫자가 섞인 연산에서는 특히 주의가 필요합니다.
1 + "2" + 3 // "123"
1 + 2 + "3" // "33"
"1" + 2 + 3 // "123"첫 번째 예시에서는 1 + "2"가 먼저 평가되어 문자열 "12"가 되고, 여기에 숫자 3이 더해지면서 "123"이 됩니다. 두 번째 예시에서는 1 + 2가 먼저 평가되어 숫자 3이 되고, 그 다음 문자열 "3"과 연결되어 "33"이 됩니다. 이처럼 동일한 값들이라도 순서에 따라 완전히 다른 결과가 나올 수 있으므로, 의도를 명확히 하려면 괄호를 적극적으로 활용하는 것이 좋습니다.
typeof 연산자는 값의 타입을 문자열로 반환하는 기본적인 연산자이지만, 몇 가지 결과는 직관과 상당히 다릅니다.
typeof null // "object"
typeof [] // "object"
typeof NaN // "number"
typeof function(){} // "function"JavaScript 역사상 가장 유명한 버그로 알려진 이 동작은 1995년 Brendan Eich가 단 10일 만에 JavaScript를 만들 때 발생한 구현 오류에서 비롯되었습니다. 당시 JavaScript에서 값은 타입 태그와 실제 값의 조합으로 메모리에 저장되었는데, 객체를 나타내는 타입 태그는 000이었습니다.
문제는 null이 널 포인터(0x00)로 표현되었다는 점입니다. 하위 비트가 모두 0이었기 때문에 타입 태그가 000, 즉 객체로 잘못 판별되었습니다. 이 버그는 수십 년이 지난 지금까지도 수정되지 않았는데, 그 이유는 웹의 하위 호환성 때문입니다. TC39에서 이를 수정하려는 제안이 있었지만, 이미 너무 많은 코드가 typeof null === "object"라는 동작에 의존하고 있어서 변경이 불가능했습니다.
참고로 null을 체크할 때는 typeof 대신 === null을 사용하면 됩니다. value == null을 쓰면 null과 undefined를 동시에 체크할 수 있는데, ECMAScript 명세에서 null == undefined가 true로 정의되어 있기 때문입니다.
"Not-a-Number"라는 이름과 달리 NaN의 타입이 "number"인 것은 혼란스러워 보일 수 있습니다. 그러나 NaN은 IEEE 754 부동소수점 표준에서 정의된 특수한 숫자 값으로, 숫자 연산이 정의되지 않은 결과를 낼 때 반환되는 값입니다. 0을 0으로 나누거나 음수의 제곱근을 구하는 등의 연산이 대표적인 예입니다.
NaN의 또 다른 특이한 점은 자기 자신과도 같지 않다는 것입니다. 이것 역시 IEEE 754 명세에 따른 의도된 동작으로, NaN은 어떤 값과 비교해도 항상 false를 반환합니다.
NaN === NaN // false (IEEE 754 명세에 따른 동작)
Number.isNaN(NaN) // true
Object.is(NaN, NaN) // true (ES6에서 추가)NaN인지 확인할 때는 Number.isNaN()을 쓰는 게 안전합니다. 전역 isNaN() 함수는 인자를 먼저 숫자로 변환하기 때문에 isNaN("hello")가 true를 반환하는 등 예상치 못한 결과가 나올 수 있습니다.
JavaScript에는 두 가지 동등 비교 연산자가 있으며, 이 둘의 차이를 정확히 이해하는 것이 예상치 못한 버그를 피하는 데 중요합니다.
"" == false // true
"" === false // false
0 == "" // true
0 == "0" // true
"" == "0" // false
null == undefined // true=== 연산자는 타입이 다르면 어떤 변환도 시도하지 않고 즉시 false를 반환합니다. 타입이 같은 경우에만 값을 비교하므로 동작이 단순하고 예측 가능합니다.
== 연산자는 타입이 다를 때 ECMAScript 명세의 Abstract Equality Comparison 알고리즘에 따라 복잡한 변환을 수행합니다. 이 알고리즘의 주요 규칙을 이해하면 위의 예제들이 왜 그런 결과를 내는지 알 수 있습니다.
먼저, null과 undefined는 서로만 같고 다른 어떤 값과도 같지 않다는 특별 규칙이 있습니다. 숫자와 문자열을 비교할 때는 문자열이 숫자로 변환되고, 불리언이 포함된 비교에서는 불리언이 먼저 숫자로 변환된 후 다시 비교됩니다.
"" == false의 경우를 분석해보면, 먼저 false가 숫자 0으로 변환되고, 그 다음 빈 문자열 ""도 숫자 0으로 변환되어 최종적으로 0 == 0이 되므로 true가 됩니다. 이처럼 여러 단계의 변환이 연쇄적으로 일어나기 때문에 결과를 예측하기 어렵습니다.
대부분의 경우 ===를 쓰면 이런 복잡한 규칙을 신경 쓸 필요가 없습니다. 다만 value == null로 null과 undefined를 동시에 체크하는 패턴은 꽤 널리 쓰입니다.
0.1 + 0.2 // 0.30000000000000004
0.1 + 0.2 === 0.3 // false이 결과는 JavaScript의 버그가 아니며, JavaScript만의 문제도 아닙니다. Python, Java, C++, Ruby 등 IEEE 754 부동소수점 표준을 사용하는 모든 프로그래밍 언어에서 동일한 현상이 발생합니다.
JavaScript의 Number 타입은 64비트 배정밀도 부동소수점 형식을 사용하는데, 이 형식은 1비트의 부호, 11비트의 지수, 52비트의 가수로 구성됩니다. 문제는 십진수 0.1을 이진수로 변환하면 0.0001100110011...처럼 무한히 반복되는 순환소수가 된다는 점입니다.
52비트 가수부에 저장할 때 이 무한소수는 잘려나가게 되고, 그 결과 0.1은 정확히 0.1이 아닌 약 0.1000000000000000055511151231...로 저장됩니다. 0.2도 마찬가지로 정확히 표현되지 않으며, 이 두 근사값을 더하면 0.3의 근사값과 미세하게 다른 값이 됩니다.
부동소수점 비교가 필요하면 직접 동등 비교 대신 차이가 매우 작은지 확인하는 엡실론 비교를 쓰면 됩니다. 금융 계산처럼 정밀도가 중요하면 정수 단위로 변환하거나 decimal.js 같은 라이브러리를 쓰기도 합니다.
// 엡실론 비교: 차이가 충분히 작으면 같다고 판단
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON // true
// 정수 연산 후 변환: 금액 계산 등에 유용
(0.1 * 100 + 0.2 * 100) / 100 === 0.3 // trueJavaScript에서 this는 함수가 정의된 위치가 아니라 어떻게 호출되었는가에 따라 결정됩니다. 이 특성은 다른 객체지향 언어와 다른 JavaScript만의 독특한 점으로, 많은 개발자들이 처음에 혼란을 겪는 부분입니다.
const obj = {
name: "JavaScript",
greet: function() {
return this.name;
}
};
obj.greet(); // "JavaScript"
const greet = obj.greet;
greet(); // undefined (strict mode에서는 TypeError)
setTimeout(obj.greet, 0); // undefined메서드를 obj.greet()처럼 객체의 메서드로 호출하면 this는 점(.) 앞의 객체를 가리킵니다. 그러나 같은 함수를 변수에 할당한 후 greet()처럼 일반 함수로 호출하면, this는 전역 객체(strict mode에서는 undefined)를 가리키게 됩니다. setTimeout에 메서드를 전달할 때도 마찬가지로, 함수 참조만 전달되고 호출 시점에는 객체와의 연결이 끊어지기 때문에 this가 예상과 다르게 동작합니다.
ES6에서 도입된 화살표 함수는 자체적인 this를 가지지 않고, 대신 정의된 위치의 렉시컬 스코프에서 this를 가져옵니다. 이 특성 덕분에 콜백 함수에서 this를 유지하기가 훨씬 쉬워졌습니다.
const obj = {
name: "JavaScript",
greetNormal: function() {
// 화살표 함수는 외부 함수의 this를 캡처
const inner = () => this.name;
return inner();
}
};
obj.greetNormal(); // "JavaScript"콜백에서 원래 객체의 this를 유지하고 싶으면 화살표 함수로 감싸거나 bind()를 쓰면 됩니다.
var funcs = [];
for (var i = 0; i < 3; i++) {
funcs.push(function() { return i; });
}
funcs.map(f => f()); // [3, 3, 3]0, 1, 2를 기대했지만 모든 함수가 3을 반환합니다. 이 문제는 var의 스코프 특성과 클로저가 결합되어 발생하는 고전적인 함정입니다.
var는 블록 스코프가 아닌 함수 스코프를 가지기 때문에, for 루프 전체에서 단 하나의 i 변수만 존재합니다. 루프가 돌 때마다 새로운 함수가 생성되지만, 이 함수들은 모두 동일한 i 변수를 클로저로 캡처합니다. 루프가 끝나면 i의 값은 3이 되고, 나중에 함수를 호출하면 모두 그 시점의 i 값인 3을 반환하게 됩니다.
반면 let은 블록 스코프를 가지며, for 루프에서 특별하게 처리되어 각 반복마다 새로운 바인딩이 생성됩니다. 따라서 각 클로저가 서로 다른 값을 캡처하게 되어 기대한 대로 동작합니다.
let funcs = [];
for (let j = 0; j < 3; j++) {
funcs.push(function() { return j; });
}
funcs.map(f => f()); // [0, 1, 2]ES6 이후로는 let과 const를 쓰면 이런 함정을 자연스럽게 피할 수 있습니다.
[10, 2, 30, 4].sort() // [10, 2, 30, 4]숫자 배열을 정렬했는데 결과가 이상합니다. 이는 sort() 메서드의 기본 동작이 요소를 문자열로 변환한 후 UTF-16 코드 유닛 순서로 정렬하기 때문입니다. 문자열 "10"은 "1"로 시작하므로 "2"보다 앞에 오게 되어, 숫자 크기와 무관한 순서가 됩니다.
숫자를 크기순으로 정렬하려면 비교 함수를 넘겨줘야 합니다.
[10, 2, 30, 4].sort((a, b) => a - b) // [2, 4, 10, 30]["1", "2", "3"].map(parseInt) // [1, NaN, NaN]이 결과는 map의 콜백 함수가 세 개의 인자(요소, 인덱스, 배열)를 받고, parseInt가 두 개의 인자(문자열, 진법)를 받기 때문에 발생합니다. map이 전달하는 인덱스가 parseInt의 진법 인자로 전달되어, 첫 번째 요소는 parseInt("1", 0)으로 10진법 해석이 되지만, 두 번째 요소는 parseInt("2", 1)로 유효하지 않은 1진법 해석이 되어 NaN이 됩니다.
// 이렇게 하면 의도대로 동작합니다
["1", "2", "3"].map(x => parseInt(x, 10)) // [1, 2, 3]
["1", "2", "3"].map(Number) // [1, 2, 3]이 문서에서 다룬 동작들은 버그가 아니라 모두 ECMAScript 명세에 정의된 정상적인 동작입니다. 1995년의 설계 결정, 복잡한 타입 변환 규칙, IEEE 754 표준이 복합적으로 작용한 결과이며, 이런 배경을 알고 나면 "왜 이렇게 동작하지?"라는 의문이 "아, 그래서 그랬구나"로 바뀌게 됩니다.
관련 문서
글쓴이 mirunamu00



