자바스크립트의 억까

아직 휴가라 일하는 날은 아닌데 딱히 땡기는 작업이 없어서 그냥 swc 작업했다. swc의 미니파이어가 react-joyride를 깨는 문제를 보고 있었는데, 진짜 어이없는 케이스라는 걸 깨달았다. 자세한 설명은 https://github.com/swc-project/swc/pull/6509 에 적어놓았으니 여기선 간략하게 설명하겠다.

입력 코드는 다음과 같다.

var fragments = offset.split(/(\+|\-)/).map(function (frag) {
  return frag.trim();
});

var divider = fragments.indexOf(
  find(fragments, function (frag) {
    return frag.search(/,|\s/) !== -1;
  })
);

if (fragments[divider] && fragments[divider].indexOf(",") === -1) {
  console.warn(
    "Offsets separated by white space(s) are deprecated, use a comma (,) instead."
  );
}

그리고 문제되는 룰은 a = foo, use(b(a))use(b(a = foo))처럼 바꾸는 룰이었다. swc minifier는 저거 압축할 때 사이드 이펙트나 변수 간의 관계를 다 따진다. 근데 resolution 순서를 따지진 않았다. 여기서 골 때리는 버그가 생겨났다.

위의 코드에 저 룰을 한번 적용하면

var fragments;
var divider = (fragments = offset.split(/(\+|\-)/).map(function (frag) {
  return frag.trim();
})).indexOf(
  find(fragments, function (frag) {
    return frag.search(/,|\s/) !== -1;
  })
);

if (fragments[divider] && fragments[divider].indexOf(",") === -1) {
  console.warn(
    "Offsets separated by white space(s) are deprecated, use a comma (,) instead."
  );
}

처럼 최적화된다. 그런데 저 룰을 한번 더 적용할 수 있는 것처럼 보인다. divider에 할당하는 식이 존재하고 fragments[divider]가 존재하니까. 그리고 대부분의 경우에 이건 올바른 최적화가 맞다. 문제는 저게 올바르지 않은 예시 케이스가 존재한다는 것이다. 코드 실행 순서와 resolution 순서가 달라서 그렇다.

fragments[divider = (fragments = foo)]의 실행 순서는 foo => fragments = foo => divider = (fragments = foo) => fragments => fragments[divider = (fragments = foo)]이다. 이거엔 문제가 없다. 문제는 resolution 순서이다. 자바스크립트에서는 [ 왼쪽의 fragments가 먼저 resolve 된다. 따라서 undefined[divider = (fragments = foo)]가 되어버리고, 런타임에 익셉션이 발생한다.

참고로 이 문제는 디버거를 붙여도 뭐가 undefined인지 안 보여준다. 익셉션이 발생한 시점은 fragments[divider = (fragments = foo)]실행하는 시점이고 이 시점에 fragments는 올바른 값을 갖고 있다. 그래서 억까라고 하는 것이다. 디버거에 낚여서 삽질한 거라 짧게 기록해놓기로 했다.