/FEED/2023.12.02

이상한 나라의 JS

들어가는 글

자주 보이지는 않지만 마주치면 당황스러운 JavaScript의 동작들을 알아보자.


바NaN나

("b" + "a" + +"a" + "a").toLowerCase();
// → 'banana'

바NaN나가 만들어지는 과정은 다음과 같다.

  1. 'a' +와 + 'a' 사이의 공백으로 인해 "ba" + (+"a") + "a"로 해석
  2. +"a"는 NaN으로 연산 ("b" + "a" + NaN + "a")
  3. 더하기(+) 연산과 toLowerCase()를 거치면 "banana"가 된다!

제일 웃겼던 동작이다.


객체지향 null

typeof null;
// → 'object'

JavaScript 첫 버전부터 있었던 유명한 버그여서 그런지 mdn 공식 문서에도 관련 내용이 나와있다. 호환성 문제로 ECMAScript 수정 제안도 거절된 것을 확인할 수 있다.


min > max

Math.min() > Math.max();
// → true

이 동작은 인자를 넘기지 않은 Math.min()과 Math.max()가 각각 무엇을 반환하는지 체크해보면 쉽게 이해할 수 있다.

Math.min();
// → Infinity
Math.max();
// → -Infinity

Infinity > -Infinity인 것은 당연하다. 다만 여기서 하나의 궁금증이 생긴다. 왜 양, 음의 무한대가 반환되는 것일까?

직접 Math.min()을 만든다고 상상해보자.

function min(arr) {
    const initialValue = ?

    let smallestValue = initialValue;

    for (let i = 0; i < arr.length; i++) {
        smallestValue = arr[i] < smallestValue ? arr[i] : smallestValue;
    }

  return smallestValue;
}

방식은 조금씩 다를 수 있겠지만 arr을 순회하며 숫자를 비교해 최솟값을 찾는 로직일 것이다. 만약 initialValue의 값이 arr 내부의 값보다 작다면 어떻게 될까?

min 함수의 의도와는 다르게 arr 중 최솟값이 아닌 initialValue를 반환하게 된다. 즉, initialValue는 JavaScript에서 다루는 값 중 가장 큰 값인 Infinity이어야 한다. initialValue가 가지는 값이 곧 인자 없이 호출된 min()의 반환값이 될 것이므로 Infinity를 반환하게 된다.

Math.max()도 동일한 이유로 가장 작은 값인 -Infinity를 반환한다.


0.30000000000000004

0.3 + 0.2;
// → 0.5

0.1 + 0.2;
// → 0.30000000000000004

매우 유명한 부동소수점 이슈이다. 디테일한 이유는 내용이 길어질 것 같아 따로 포스팅하겠다. 간단하게 요약하면 부동소수점 방식의 실수 표현 정밀도 문제 때문에 미세한 오차가 발생하는 것이다.


parseInt라면 I’m NaN

["1", "7", "11"].map(parseInt);
// → [1, NaN, 3]

약간의 힌트가 필요한 수수께끼이다.

["1", "7", "11"].map((num) => parseInt(num));
// → [1, 7, 11]

위의 코드가 정상 작동하는 것으로 미루어 보아 parseInt의 인자가 잘못 들어갔음을 유추할 수 있다. 두 가지 사실을 고려해보자.

  • map은 callbackFn에 element, index, array 세 값을 전달한다.
  • parseInt는 string, radix(optional) 두 인자를 받는다. radix 값에 8이 들어오면 8진수로 해석하고, 2가 들어오면 2진수로 해석한다. 0이거나 값이 없으면 string의 값에 따라 유추해서 해석한다.

이제 종합해볼 시간이다! map(parseInt)에서 parseInt는 element, index 값을 전달받는다. 풀어서 써보면 아래와 같다.

parseInt("1", 0); // 10진법으로 유추, 정상적으로 1 반환
// → 1
parseInt("7", 1); // `radix` 에 2-36 외의 값이 들어오면 NaN 반환
// → NaN
parseInt("11", 2); // 2진법 상 11 → 2 + 1이므로 3 반환
// → 3

18 - 17 = 3

018 - 017;
// → 3

이 동작도 진법 때문에 발생하는 문제이다. JavaScript에서는 접두사로 0이 붙고, 0 이후의 숫자들이 모두 0-7 범위인 경우 8진수로 해석한다. 018은 8이 있으니 패스, 017은 조건을 만족하기 때문에 8진수로 해석되어 15로 계산된다. 즉 18 - 15로 계산되어 3이 반환된다. Strict Mode에서는 접두사 0에 대해 금지하고 있으므로, 문법 오류가 발생한다. (여섯째로, ECMAScript 5 에서의 엄격 모드는 8진 구문을 금지합니다. | mdn)


진실에 진실을 더하면

true + true;
// → 2

false + true;
// → 1

연산 시 true가 1로 변환되고, false가 0으로 변환되어 이와 같이 동작한다.


비밀 편지

(![] + [])[+[]] +
  (![] + [])[+!+[]] +
  ([![]] + [][[]])[+!+[] + [+[]]] +
  (![] + [])[!+[] + !+[]];
// → 'fail'

조금 어지럽지만 하나하나 뜯어보자.

// (![] + [])[+[]]

![] + [] 
// → 'false'
+[];
// → 0

"false"[0];
// → 'f'
// (![] + [])[+!+[]]

+!+[]; // +[]이 0이므로, !+[]은 true, +!+[]은 1
// → 1

"false"[1];
// → 'a'
// ([![]] + [][[]])[+!+[] + [+[]]]

["false"] + undefined // [![]]은 ['false'], [][[]]는 undefined
// → 'falseundefined'

!+[] + [+[]]; // 1 + [0]
// → '10'

"falseundefined"["10"];
// → 'i'
// (![] + [])[!+[] + !+[]]

!+[] + !+[]; // !+[]은 true, true + true는 2
// → 2

"false"[2];
// → 'l'

'f'+'a'+'i'+'l'! 해독을 완료했다. 이 사이트에서 []()!+ 만을 이용해 여러 영어 문장을 표현해볼 수 있다. JS 개발자에게 비밀 편지를 쓰고 싶을 때 활용해보는 것은 어떨까? (사이트 내 욕설이 포함되어 있으므로 주의를 요한다.)


후기

JS의 이상한 동작들은 개발자 커뮤니티에서 밈으로 많이 접했었는데, 세계에서 가장 많이 사용하는 개발 언어임에도 불구하고 한눈에 이해하기 어려운 동작을 하는 이유가 뭘까 궁금했었다. 조사하는 과정에서 JS가 동적, 약타입 언어로서 가지는 특징들이 무엇인지, 그로 인해 코딩 시 주의해야 하는 부분이 무엇인지 체감하게 되었다. 추가로, 예상치 못한 동작들을 막기 위한 lint 규칙들에 대해서도 알아보고 싶다.


참고 자료

  • Why is the result of ('b'+'a'+ + 'a' + 'a').toLowerCase() 'banana'? | stackoverflow
  • Why is Math.max() smaller than Math.min()? | Quora
  • What is the oddest JavaScript behavior? | dev.to
  • The Weird Parts of JavaScript