evenharder's profile image

evenharder

March 19, 2020 17:05

JavaScript의 형변환

Javascript , cheatsheet

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형에는 truefalse밖에 없기 때문에, 거짓값(falsy value)를 열거하는 게 낫습니다. '', 0, NaN, null, undefined, false, BigInt(0)가 전부입니다. 나머지(객체, Date, 리스트, 함수 등등) 는 전부 true로 변환됩니다.

Number conversion

명시적 형변환을 하려면 Number()를 호출하면 됩니다. 암시적으로는 좀 많이 불립니다.

  • 비교 연산자 (>, <, <=, >=, !=, ==) (단, 두 피연산자가 모두 String일 때는 제외)
  • 비트 연산자 (|, &, ^, ~)
  • 산술 연산자 (-, +, *, /, %) (단, +의 연산자에 String이 있을 때는 제외)
  • 단항 연산자 (+)

변환하는 과정은 조금 복잡합니다.

  • String의 경우, 앞뒤 whitespace를 제외하고 빈 문자열이면 0으로, Number로 변환될 수 있으면 해당 Number로 (Infinity, 1e9 등), 아니면 NaN으로 변환됩니다.
  • null0으로, undefinedNaN으로 변환됩니다.
  • Symbol은 명시적으로도 암시적으로도 변환될 수 없으며 TypeError를 야기합니다.
  • null이나 undefined==에서 형변환이 일어나지 않으며, nullundefined== 연산자에서 true가 되는 경우는 이 두 가지 밖에 없습니다.
  • NaN!== 연산자로도 false가 나옵니다.
  • BigInt는 명시적으로밖에 변환하지 못하며, 암시적 변환은 TypeError를 야기한다.
  • 변환은 아니지만, 비트 연산에서 Infinity, -Infinity, NaN0으로 취급됩니다.

JavaScript의 object 형변환

그럼 [1] + [2,3] 같은 건 어떻게 적용되는 걸까요?

우선 JavaScript 엔진은 객제를 원시 타입으로 바꾸려는 시도를 합니다. 그리고 가능한 변환은 String, Number, Boolean밖에 없습니다. Boolean의 경우 앞서 말했듯이 무조건 true로 변환됩니다. 그 외로는 [[ToPrimitive]] 메서드를 이용해 변환되는데, 과정이 대략 다음과 같습니다.

  • [[ToPrimitive]] 메서드에 preferredType을 넘겨서 변환하고자 하는 형(NumberString)을 명시할 수 있습니다 (필수는 아님).
  • Number 로 변환하든 String으로 변환하든 Object.prototypevalueOftoString을 사용하며, 임의의 object에 존재합니다.
  • 원시 타입이 입력으로 들어오면 그 입력을 그대로 반환합니다.
  • 두 경우 모두 valueOftoString을 기본적으로 호출하고, 그 결과가 원시 타입이면 이 값을 반환합니다.
    • Number로 변환하고자 하면 valueOftoString에 앞서, String으로 변환하고자 하면 반대로 toStringvalueOf에 앞서 호출합니다.
  • 이러고도 원시 타입이 나오지 않으면 TypeError를 반환합니다.

많은 내장 객체들이 valueOf가 정의되어 있지 않거나 (원시 타입이 아닌) this를 반환하는 경우가 많기 때문에, 어느 형변환을 하든 결과적으로 toString을 호출하게 됩니다.

각 연산자마다 preferredType을 지정해서 호출하지만, ==+preferredTypedefault를 넘깁니다. 이 경우 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이 되고 true1이 됩니다. 때문에 전체 식은 false가 됩니다.

undefined == ''에선, ==undefined가 있기 때문에 numeric conversion이 일어나지 않습니다. 전체 식은 false가 됩니다.

8 * null에선 null0으로 변환되어 전체 식이 0이 됩니다.

0 == "\n"에선 numeric conversion이 일어나 "\n"0으로 변환됩니다. 때문에 전체 식은 true가 됩니다.

!![]+!!{}+!!"false"에선 !!~sth~~sth~Boolean으로 true로 변환되므로, 두 번 complement를 해 true + true + true가 됩니다. 이후는 numeric conversion이 일어나 3이 됩니다.

~undefinedundefinedNaN으로 형변환되고, 비트 연산에서 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"이 됩니다.

참고 자료