JavaScript의 형변환

JavaScript만큼 프로그래머들이 농담 따먹기를 하는 프로그래밍 언어가 PHP 말고 있을까요?
JavaScript의 매력이자 악명 높은 점이 타입의 유연성입니다. 변수를 선언할 때 타입을 지정할 필요가 없으며, 서로 타입이 다른 변수들끼리 연산을 해야 할 때 최대한 에러를 내지 않는 방향으로 진행이 됩니다. 이런 규칙을 통해 ![]+-*만 이용해서 0부터 1000까지 만들라는 이런 문제도 있습니다. 하지만 일반적으로 이런 변환 과정은 갸우뚱할 때가 많으며, 프로그래머 개그의 단골 소재이기도 합니다.
이 포스트에서는 형변환(coercion)에 깔려있는 법칙들을 설명하고자 합니다. Alexey Samoshkin님의 이 포스트에서 지대한 영향을 받았으며, 기본적으로 해당 포스트의 흐름을 따라 번역하되 내용과 예제를 보완하며 작성하였습니다.
그럼 시작 전에 다음 표현식이 어떻게 될지 생각해보시기 바랍니다.
4 + 10 + "string"
"string" + 4 + 10
"true" == true
undefined == ''
8 * null
0 == "\n"
!![]+!!{}+!!"false"
~undefined
[2] > "1"
"hello" > 3
"hello" < 3
"-1" > "+1"
"-1" > +1
"b" + "a" + + "a" + "a"
[] + undefined + 1
[2,3,5] == [2,3,5]
{}+[]+{}+[1]
!+[]+[]+![]
!+[]+![]+[]
[1] + [2,3]
기본 이론
형변환은 명시적일 수도 있고, 암시적일 수도 있습니다. 명시적 형변환(explicit coercion)은 Number("123") 처럼 프로그래머의 코드에서 암시적으로 자료형을 정해서 변환하는 과정입니다. 암시적 형변환(implicit coercion)은 연산자 사용으로 인해 자연적으로 일어나는 형변환입니다. 대표적인 예시로 ==, +, > 등 연산자의 사용이 있습니다. 예외적으로 ===는 형변환을 야기하지 않습니다. 이 암시적 형변환을 잘 이용하면 더욱 가독성 있는 코드를 작성할 수 있지만 잘못 생각하면 프로그램의 버그가 될 수 있습니다.
JavaScript에서의 형변환은 세 가지가 있습니다.
String으로 형변환Number로 형변환Boolean으로 형변환
또, 원시 타입과 객체(object)에 대해 형변환이 다르게 적용됩니다. 각자 알아보려고 합니다.
JavaScript의 원시 타입 형변환
String conversion
명시적 형변환은 String() 함수를 쓰면 됩니다. 암시적 형변환은 + 연산자를 사용할 때 피연산자에 String이 있을 때 일어납니다.
String으로의 변환은 자연스럽습니다. 출력되는 형태 그대로 변환되기 때문입니다.
String(12345) // "12345"
String(-3.14) // "-3.14"
String(true) // "true"
String(false) // "false"
String(undefined) // "undefined"
String(null) // "null"
String(BigInt(42)) // "42"
Symbol은 암시적 형변환이 되지 않기 때문에 명시적 형변환을 해야 합니다.
String(Symbol("Explicit")) // "Explicit"
"and..." + Symbol("implicit") // TypeError
Boolean conversion
명시적 형변환을 하려면 Boolean()을 호출하면 됩니다. 암시적으로는 ||, &&, !에 의해 일어납니다. ||와 &&는 조건에 맞는 실제 피연산자를 반환하지만, 내부적으로는 형변환이 일어납니다.
Boolean형에는 true와 false밖에 없기 때문에, 거짓값(falsy value)를 열거하는 게 낫습니다. '', 0, NaN, null, undefined, false, BigInt(0)가 전부입니다. 나머지(객체, Date, 리스트, 함수 등등) 는 전부 true로 변환됩니다.
Number conversion
명시적 형변환을 하려면 Number()를 호출하면 됩니다. 암시적으로는 좀 많이 불립니다.
- 비교 연산자 (
>,<,<=,>=,!=,==) (단, 두 피연산자가 모두String일 때는 제외) - 비트 연산자 (
|,&,^,~) - 산술 연산자 (
-,+,*,/,%) (단,+의 연산자에String이 있을 때는 제외) - 단항 연산자 (
+)
변환하는 과정은 조금 복잡합니다.
String의 경우, 앞뒤 whitespace를 제외하고 빈 문자열이면0으로,Number로 변환될 수 있으면 해당Number로 (Infinity,1e9등), 아니면NaN으로 변환됩니다.null은0으로,undefined는NaN으로 변환됩니다.Symbol은 명시적으로도 암시적으로도 변환될 수 없으며TypeError를 야기합니다.null이나undefined는==에서 형변환이 일어나지 않으며,null과undefined가==연산자에서true가 되는 경우는 이 두 가지 밖에 없습니다.NaN은!==연산자로도false가 나옵니다.BigInt는 명시적으로밖에 변환하지 못하며, 암시적 변환은TypeError를 야기한다.- 변환은 아니지만, 비트 연산에서
Infinity,-Infinity,NaN은0으로 취급됩니다.
JavaScript의 object 형변환
그럼 [1] + [2,3] 같은 건 어떻게 적용되는 걸까요?
우선 JavaScript 엔진은 객제를 원시 타입으로 바꾸려는 시도를 합니다. 그리고 가능한 변환은 String, Number, Boolean밖에 없습니다. Boolean의 경우 앞서 말했듯이 무조건 true로 변환됩니다. 그 외로는 [[ToPrimitive]] 메서드를 이용해 변환되는데, 과정이 대략 다음과 같습니다.
[[ToPrimitive]]메서드에preferredType을 넘겨서 변환하고자 하는 형(Number나String)을 명시할 수 있습니다 (필수는 아님).Number로 변환하든String으로 변환하든Object.prototype의valueOf랑toString을 사용하며, 임의의 object에 존재합니다.- 원시 타입이 입력으로 들어오면 그 입력을 그대로 반환합니다.
- 두 경우 모두
valueOf와toString을 기본적으로 호출하고, 그 결과가 원시 타입이면 이 값을 반환합니다.Number로 변환하고자 하면valueOf를toString에 앞서,String으로 변환하고자 하면 반대로toString을valueOf에 앞서 호출합니다.
- 이러고도 원시 타입이 나오지 않으면
TypeError를 반환합니다.
많은 내장 객체들이 valueOf가 정의되어 있지 않거나 (원시 타입이 아닌) this를 반환하는 경우가 많기 때문에, 어느 형변환을 하든 결과적으로 toString을 호출하게 됩니다.
각 연산자마다 preferredType을 지정해서 호출하지만, ==와 +는 preferredType에 default를 넘깁니다. 이 경우 Date를 제외한 타입은 Number로 변환됩니다.
예제
4 + 10 + "string" // "14string"
"string" + 4 + 10 // "string410"
"true" == true // false
undefined == '' // false
8 * null // 0
0 == "\n" // true
!![]+!!{}+!!"false" // 3
~undefined // -1
[2] > "1" // true
"hello" > 3 // false
"hello" < 3 // false
"-1" > "+1" // true
"-1" > +1 // false
"b" + "a" + + "a" + "a" // "baNaNa"
[] + undefined + 1 // undefined1
[2,3,5] == [2,3,5] // false
{}+[]+{}+[1] // "0[object Object]1"
!+[]+[]+![] // "truefalse"
!+[]+![]+[] // "1"
[1] + [2,3] // "12,3"
하나하나 분석해보도록 하겠습니다.
4 + 10 + "string"에선 4 + 10이 먼저 계산되어 14가 되고, 이후 14 + "string"이 되어 "14string"이 됩니다.
"string" + 4 + 10에선 "string" + 4가 먼저 계산되어 "string4"가 되고, 이후 "string4" + 10이 계산되어 "string410"이 됩니다. +에 String이 들어가면 계속 String이라 보시면 됩니다.
"true" == true에서, == 에 의해 numeric conversion이 일어나 "true"가 NaN이 되고 true는 1이 됩니다. 때문에 전체 식은 false가 됩니다.
undefined == ''에선, ==에 undefined가 있기 때문에 numeric conversion이 일어나지 않습니다. 전체 식은 false가 됩니다.
8 * null에선 null이 0으로 변환되어 전체 식이 0이 됩니다.
0 == "\n"에선 numeric conversion이 일어나 "\n"이 0으로 변환됩니다. 때문에 전체 식은 true가 됩니다.
!![]+!!{}+!!"false"에선 !!~sth~의 ~sth~가 Boolean으로 true로 변환되므로, 두 번 complement를 해 true + true + true가 됩니다. 이후는 numeric conversion이 일어나 3이 됩니다.
~undefined는 undefined가 NaN으로 형변환되고, 비트 연산에서 NaN이 0으로 간주되기 때문 ~NaN은 -1로 계산됩니다.
[2] > "1"의 경우, numeric conversion이 일어나 [2]는 valueOf 메서드에 의해 2로, "1"은 1로 변환되기 때문에 true가 됩니다.
"hello" > 3과 "hello" < 3에서 "hello"는 NaN으로 변환되기 때문에 비교 결과도 둘 다 NaN이 됩니다.
"-1" > "+1"는 conversion이 일어나지 않습니다. -의 ASCII 코드(45)가 +보다 크므로(43), true가 됩니다.
"-1" > +1는 타입이 일치하지 않으므로 numeric conversion이 일어납니다. 결과는 -1 > 1이 되어 false입니다.
"b" + "a" + + "a" + "a"는 서두의 그림에 있던 예시입니다. 흐름을 표현하면 다음과 같습니다.
>> "b" + "a" + + "a" + "a"
- ("b" + "a") + + "a" + "a"
- ("ba" + (+ "a")) + "a"
- ("ba" + NaN) + "a"
- "baNaN" + "a"
- "baNaNa"
[] + undefined + 1에서 우선 [] + undefined이 계산됩니다. numeric conversion에 의해[].valueOf()가 호출되는데, 이는 자기 자신이므로 원시 타입이 아니어서 numeric conversion이 실패합니다. Number([])이 0임에도 불구하고 []가 object이기 때문에 그렇습니다. 때문에 string conversion이 일어나고 이 땐 "" + "undefined"가 되어 "undefined"가 됩니다. 이후 결과는 당연히 "undefined1"이 됩니다.
[2,3,5] == [2,3,5]는 타입이 같아서 형변환이 일어나지 않고, 둘이 같은 객체가 아니므로 false가 됩니다. 이와 달리 [2,3,5] == "2,3,5"는 string conversion이 일어나므로 true가 됩니다.
{}+[]+{}+[1]는 원 포스트 최상단에 있는 예제인데, 약간의 낚시가 들어가 있습니다. 우선, 첫 중괄호 ({})는 scope로 인식되어 연산에 아무런 영향이 없습니다. 실제 연산은 +[]부터 시작합니다.
>> +[]+{}+[1]
- +''+{}+[1] // numeric conversion에서 []이 toString에 의해 ''로 변환
- +0+{}+[1] // 이후 ''이 (계속된 numeric conversion에 의해) 0으로 변환
- 0+{}+[1] // {}이 toString에 의해 "[object Object]"로 변환
- "0[object Object]" + [1]
- "0[object Object]1"
!+[]+[]+![]도 위랑 비슷합니다. !+[]는 !0이 되므로 true가 되며, true+[]에서 []는 ''으로 변환되어 "true"가 됩니다. 이후 나머지도 String이 되기에 결과적으로 "truefalse"가 됩니다.
!+[]+![]+[]는 비슷하지만 약간 다릅니다. !+[]가 true, ![]는 false이므로 둘이 더해서 1이 되며, 이후 []가 ''으로 변환되어 결과적으로 "1"이 됩니다.
[1] + [2,3]에선 valueOf가 원시 타입을 반환하지 않으므로 toString이 사용되어 "1" + "2,3"이 되기에 "12,3"이 됩니다.
참고 자료
- JavaScript type coercion explained : 이 글의 기반이 된 포스트입니다.
- JavaScript Equality Table :
==에 대한 진리표입니다. - wtfjs.com : JS의 고약한 예제들이 있습니다.
- Eloquent JavaScript : 좋은 JS 입문서입니다. 웹 위주가 아닌 언어 위주로 설명되어 있습니다.