(번역) 자바스크립트 생태계를 더 빠르게 - 라이브러리 하나씩

superlipbalm·2023년 1월 15일
38

FE 글 번역

목록 보기
10/10
post-thumbnail

원문: https://marvinh.dev/blog/speeding-up-javascript-ecosystem/

📖 요약: 대부분의 인기 있는 라이브러리들은 불필요한 타입 변환을 피하거나 함수 내부에서 함수를 생성하지 않음으로써 속도를 높일 수 있습니다.

Rust나 Go와 같은 다른 언어로 모든 자바스크립트 빌드 도구를 다시 작성하는 추세이긴 하지만, 현재의 자바스크립트 기반 도구도 충분히 빠를 수 있습니다. 일반적인 프런트엔드 프로젝트의 빌드 파이프라인은 보통 함께 작동하는 여러 도구들로 이뤄져 있습니다. 그러나 도구가 다양화되며 도구의 메인테이너 입장에서는 어떤 도구와 주로 같이 사용되는지 알아야 하므로 성능 문제를 발견하기가 보다 어려워졌습니다.

순수하게 언어 관점에서 보면 자바스크립트는 확실히 Rust나 Go보다 느리지만, 현재의 자바스크립트 도구들은 상당히 개선될 여지가 있습니다. 물론 자바스크립트가 더 느리긴 하지만, 오늘날 비교하면 그렇게까지 느리지는 않을 겁니다. 요즘 JIT 엔진은 엄청나게 빠르거든요!

호기심에 이끌려 그 모든 시간이 어디에서 사용된 것인지 일반적인 자바스크립트 기반 도구들을 프로파일링 해보려 합니다. 매우 인기 있는 파서이자 CSS 트랜스파일러인 PostCSS부터 살펴봅시다.

PostCSS에서 4.6초 절약하기

오래된 브라우저에서 CSS 사용자 정의 프로퍼티에 대한 기본적인 지원을 추가하는 postcss-custom-properties라는 매우 유용한 플러그인이 있습니다. 어찌 된 일인지 내부적으로 사용되는 하나의 정규식으로 인한 4.6초의 큰 비용이 성능 추적 결과상에서 매우 두드러지게 나타났습니다. 이상해 보였습니다.

정규식은 eslint 에서 특정 린팅 규칙을 비활성 하는 것처럼 플러그인의 동작을 변경하기 위해 특정 주석 값을 찾는 것으로 보였습니다. README 에 언급되어 있지는 않았지만, 소스 코드를 살펴보니 앞서 짐작한 것을 확인할 수 있었습니다.

정규식은 CSS 규칙이나 선언이 해당 주석에 의해 선행되는지 확인하는 함수에서 생성되고 있었습니다.

function isBlockIgnored(ruleOrDeclaration) {
  const rule = ruleOrDeclaration.selector ? ruleOrDeclaration : ruleOrDeclaration.parent;

  return /(!\s*)?postcss-custom-properties:\s*off\b/i.test(rule.toString());
}

rule.toString() 호출이 꽤 빠르게 눈에 띄었습니다. 한 타입이 다른 타입으로 변환되는 부분은 변환을 수행하지 않으면 항상 시간을 절약할 수 있기 때문에 성능 문제를 다루고 있는 경우 살펴볼 가치가 있습니다. 이 시나리오에서 흥미로운 점은 rule 변수가 항상 커스텀 toString 메서드를 갖는 object를 담는다는 것입니다. 애초에 문자열이 아니었기 때문에 정규식을 테스트하기 위해 항상 약간의 직렬화 비용을 지불하고 있다는 것을 알 수 있습니다. 경험상 많은 수의 짧은 문자열을 매칭하는 것이 몇 개의 긴 문자열을 매칭하는 것보다 훨씬 느립니다. 이는 최적화를 기다리는 주요 후보입니다!

이 코드에서 다소 문제가 되는 부분은 postcss 주석의 존재 여부와 관계없이 모든 입력 파일이 이 비용을 지불해야 한다는 것입니다. 긴 문자열에 대해 하나의 정규식을 실행하는 것이 짧은 문자열에 대해 반복해서 정규식을 실행하는 것과 직렬화 비용보다 저렴하다는 것을 알고 있으므로, 파일에 postcss 주석이 포함되지 않다는 것을 알고 있는 경우 isBlockIgnored를 호출조차 하지 않도록 함수를 가드 할 수 있습니다.

수정 사항이 적용되면서 빌드 시간이 무려 4.6초나 단축되었습니다.

SVG 압축 속도 최적화

다음으로 살펴볼 것은 SVG 파일을 압축하기 위한 라이브러리인 SVGO입니다. 꽤 멋지고 SVG 아이콘이 많은 프로젝트에 필수적입니다. CPU 프로파일에서 SVG 압축에 3.1초가 소비된 것으로 나타났습니다. 속도를 더 높일 수 있을까요?

프로파일링 데이터를 통해 살펴보았을 때 strongRound라는 함수가 눈에 띄었습니다. 이 함수 직후에는 항상 약간의 GC 클린업이 뒤따랐습니다.(작은 빨간 박스 참조)

이는 제 호기심을 자극했습니다! Github에서 소스를 가져와봅시다.

/**
 * 데이터의 지정된 소수점 자릿수로 유지하는 과정에서 부동 소수점 숫자의 정확도를 감소시킵니다.
 * 2.3491를 반올림한 값은 2.349가 아닌 2.35가 됩니다.
 */
function strongRound(data: number[]) {
  for (var i = data.length; i-- > 0; ) {
    if (data[i].toFixed(precision) != data[i]) {
      var rounded = +data[i].toFixed(precision - 1);
      data[i] =
        +Math.abs(rounded - data[i]).toFixed(precision + 1) >= error
          ? +data[i].toFixed(precision)
          : rounded;
    }
  }
  return data;
}

아하, 일반적인 SVG 파일에 많이 있는 숫자를 압축할 때 사용하는 함수군요. 이 함수는 number 배열을 받고 배열의 요소들을 변경할 것으로 예상됩니다. 구현에 사용되는 변수 타입을 살펴보겠습니다. 조금 더 자세히 살펴보면 문자열과 숫자 간 변환이 양방향으로 많이 존재함을 알 수 있습니다.

function strongRound(data: number[]) {
  for (var i = data.length; i-- > 0; ) {
    // string과 number를 비교 -> string이 number로 변환됨
    if (data[i].toFixed(precision) != data[i]) {
      // number로부터 string을 생성하고 즉시 number로 다시 변환
      var rounded = +data[i].toFixed(precision - 1);
      data[i] =
        // 또 string으로 변환되고 즉시 number로 다시 변환되는 다른 number
        +Math.abs(rounded - data[i]).toFixed(precision + 1) >= error
          ? // 이전 if문 조건에 사용된 값과 동일하지만, 또다시 number로 변환됨
            +data[i].toFixed(precision)
          : rounded;
    }
  }
  return data;
}

숫자를 반올림하는 작업은 숫자를 문자열로 변환하지 않고 약간의 수학만으로도 수행할 수 있는 작업으로 보입니다. 일반적으로 최적화할 때는 숫자로 표현하는 것이 좋은데, 주된 이유는 CPU가 숫자를 다루는데 매우 뛰어나기 때문입니다. 약간의 변경으로 항상 숫자로 유지되게 해 문자열로 변환하는 것을 완전히 피할 수 있습니다.

// `Number.prototype.toFixed`과 동일하지만 반환 값을 문자열로 변환하지 않습니다.
function toFixed(num, precision) {
  const pow = 10 ** precision;
  return Math.round(num * pow) / pow;
}

// 모든 문자열 변환을 제거하고 대신 자체 `toFixed()` 함수를 호출하도록 재작성되었습니다.
function strongRound(data: number[]) {
  for (let i = data.length; i-- > 0; ) {
    const fixed = toFixed(data[i], precision);
    // 보세요, 이제 엄격한 동등 비교를 사용할 수 있습니다!
    if (fixed !== data[i]) {
      const rounded = toFixed(data[i], precision - 1);
      data[i] =
        toFixed(Math.abs(rounded - data[i]), precision + 1) >= error
          ? fixed // 이제 여기서 이전 값을 재사용할 수 있습니다.
          : rounded;
    }
  }
  return data;
}

프로파일링을 다시 실행해 빌드 시간을 약 1.4초 단축할 수 있음을 확인했습니다! 이에 대한 PR을 등록했습니다.

짧은 문자열에 대한 정규식 (파트 2)

strongRound 부근의 또 다른 함수는 완료하는 데 거의 1초(0.9초)가 걸려 의심스러워 보였습니다.

stringRound와 마찬가지로 이 함수도 숫자를 압축하지만, 숫자에 소수점이 있고 1보다 작고 -1보다 큰 경우 맨 앞의 0을 제거하는 트릭이 추가되었습니다. 따라서 0.5.5-0.2-.2로 각각 압축될 수 있습니다. 특히 마지막줄이 흥미로워 보입니다.

const stringifyNumber = (number: number, precision: number) => {
  // ...생략

  // 소수에서 0을 제거
  return number.toString().replace(/^0\./, '.').replace(/^-0\./, '-.');
};

여기서는 숫자를 문자열로 변환하고 정규식을 호출합니다. 숫자의 문자열 버전은 짧은 문자열일 가능성이 매우 큽니다. 그리고 우리는 숫자가 n > 0 && n < 1n > -1 && < 0을 동시에 만족시킬 수 없다는 것을 알고 있습니다. NaN 조차도 그런 힘을 갖고 있지 않습니다! 이를 통해 우리는 정규식 중 하나만 일치하거나 일치하지 않거나 둘 다 일치하지 않는다는 것을 추론할 수 있습니다. .replace 호출 중 적어도 하나는 항상 낭비됩니다.

각각의 경우를 직접 구분함으로써 이를 최적화할 수 있습니다. 0이 맨 앞에 존재하는 숫자를 처리하는 경우에만 교체 로직을 적용해야 합니다. 이러한 숫자 확인은 정규식 탐색을 수행하는 것보다 빠릅니다.

const stringifyNumber = (number: number, precision: number) => {
  // ...생략

  // 소수에서 0을 제거
  const strNum = number.toString();
  // 간단한 숫자 확인 사용
  if (0 < num && num < 1) {
    return strNum.replace(/^0\./, '.');
  } else if (-1 < num && num < 0) {
    return strNum.replace(/^-0\./, '-.');
  }
  return strNum;
};

한 단계 더 나아가 문자열의 맨 앞에 0이 있는 것이 100% 확실하므로 정규식 탐색을 완전히 제거하고 문자열을 직접 조작할 수 있습니다.

const stringifyNumber = (number: number, precision: number) => {
  // ...생략

  // 소수에서 0을 제거
  const strNum = number.toString();
  if (0 < num && num < 1) {
    // 일반 문자열 처리만 필요합니다.
    return strNum.slice(1);
  } else if (-1 < num && num < 0) {
    // 일반 문자열 처리만 필요합니다.
    return '-' + strNum.slice(2);
  }
  return strNum;
};

이미 svgo의 코드 베이스에 맨 앞의 0을 트리밍 하는 별도의 함수가 존재하므로 이를 대신 활용할 수 있습니다. 또 다른 0.9초가 절약됐습니다! 업스트림 PR

인라인 함수, 인라인 캐시 및 재귀

monkeys라는 함수는 이름만으로도 흥미를 끌었습니다. 성능 추적에서 저는 그것이 내부에서 여러 번 호출되었음을 볼 수 있었고, 이건 여기서 일종의 재귀가 일어나고 있다는 강력한 지표입니다. 이는 트리와 같은 구조를 탐색하는 데 흔히 사용됩니다. 보통 어떤 탐색이 사용되면, 그 부분이 코드의 "핫" 패스가 될 가능성이 큽니다. 모든 시나리오에 해당되는 것은 아니지만 제 경험상 좋은 경험법칙입니다.

function perItem(data, info, plugin, params, reverse) {
  function monkeys(items) {
    items.children = items.children.filter(function (item) {
      // reverse pass
      if (reverse && item.children) {
        monkeys(item);
      }
      // main filter
      let kept = true;
      if (plugin.active) {
        kept = plugin.fn(item, params, info) !== false;
      }
      // direct pass
      if (!reverse && item.children) {
        monkeys(item);
      }
      return kept;
    });
    return items;
  }
  return monkeys(data);
}

여기에는 내부 함수를 다시 호출하는 또 다른 함수를 생성하는 함수가 있습니다. 추측하건대 모든 인자를 다시 전달하지 않고 일부 키 입력을 저장하기 위한 것 같습니다. 문제는 외부 함수를 자주 호출할 때 내부에서 생성되는 함수는 최적화하기가 상당히 어렵다는 것입니다.

function perItem(items, info, plugin, params, reverse) {
  items.children = items.children.filter(function (item) {
    // reverse pass
    if (reverse && item.children) {
      perItem(item, info, plugin, params, reverse);
    }
    // main filter
    let kept = true;
    if (plugin.active) {
      kept = plugin.fn(item, params, info) !== false;
    }
    // direct pass
    if (!reverse && item.children) {
      perItem(item, info, plugin, params, reverse);
    }
    return kept;
  });
  return items;
}

이전처럼 클로저로 인자를 캡처하는 대신 항상 모든 인자를 명시적으로 전달해 내부 함수를 제거할 수 있습니다. 이 변경의 영향은 조금 미미하지만, 전체적으로 0.8초를 더 절약했습니다.

다행히도 이는 이미 새로운 메이저 버전인 3.0.0 릴리즈에서 해결되었지만, 생태계가 새로운 버전으로 전환되기까지 조금 시간이 걸릴 것입니다.

for...of의 변환에 주의

@vanilla-extract/css에서 거의 동일한 문제가 발생합니다. 배포된 패키지는 다음 코드와 함께 제공됩니다.

class ConditionalRuleset {
  getSortedRuleset() {
    //...
    var _loop = function _loop(query, dependents) {
      doSomething();
    };

    for (var [query, dependents] of this.precedenceLookup.entries()) {
      _loop(query, dependents);
    }
    //...
  }
}

이 함수의 흥미로운 점은 원본 소스 코드에는 존재하지 않는다는 것입니다. 원본 소스에서는 표준 for...of 루프입니다.

class ConditionalRuleset {
  getSortedRuleset() {
    //...
    for (var [query, dependents] of this.precedenceLookup.entries()) {
      doSomething();
    }
    //...
  }
}

babel이나 타입스크립트의 repl에서 이 문제를 재현할 수 없었지만 빌드 파이프라인에서 이 문제가 발생한 것을 확인할 수 있습니다. 빌드 도구에서 공유되는 추상화인 것 같다는 것을 고려했을 때 더 많은 프로젝트가 이에 의해 영향을 받는다고 생각됩니다. 그래서 지금은 node_modules 내부의 로컬 패키지에 패치를 적용했고, 이를 통해 빌드 시간이 0.9초 더 단축된 것을 확인할 수 있었습니다.

시멘틱 버저닝(semver)의 흥미로운 사례

이것은 제가 뭔가를 잘못 설정한 것일지도 모르겠습니다. 기본적으로 프로파일은 파일을 변환할 때마다 모든 babel 설정이 항상 새로 읽힌다는 것을 보여줬습니다.

스크린샷으로 보기에는 조금 어렵지만 시간을 많이 잡아먹는 함수 중 하나가 npm의 cli에서 사용하는 것과 같은 패키지인 semver 패키지의 코드였습니다. 엥? semver가 babel과 무슨 상관이 있나요? 이를 떠올리는 데까지 시간이 꽤 걸렸습니다. @babel/preset-env의 대상 브라우저 목록을 파싱 하기 위한 것입니다. 대상 브라우저 목록 설정이 매우 짧아 보일 수 있지만, 이는 궁극적으로 290개의 개별 대상으로 확장되었습니다.

이것만으로는 충분히 영향을 미치지 못하지만, 유효성 검사 함수를 사용할 때의 할당 비용을 놓치기 쉽습니다. babel의 코드 베이스에 조금 흩어져 있지만 기본적으로 대상 브라우저의 버전은 "10" -> "10.0.0"와 같이 semver 문자열로 변환된 후 유효성을 검사합니다. 이러한 버전 번호의 일부는 이미 semver 형식과 일치합니다. 이러한 버전과 버전 범위는 변환해야 하는 가장 낮은 공통 기능 세트를 찾을 때까지 서로 비교됩니다. 이 접근법에는 아무런 문제가 없습니다.

여기서 성능 문제가 발생합니다. 파싱 된 semver 데이터 타입 대신 semver 버전이 문자열로 저장되기 때문입니다. 즉, semver.valid('1.2.3')에 대한 모든 호출은 새 semver 인스턴스를 생성하고 즉시 삭제합니다. semver.lt('1.2.3', '9.8.7')과 같이 문자열을 사용해 semver 버전을 비교할 때도 마찬가지입니다. 그래서 성능 추적에서 semver가 두드러지게 나타났던 것입니다.

또다시 node_modules에 로컬 패치를 적용해 빌드 시간을 4.7초 줄일 수 있었습니다.

결론

이 시점에서 더 이상 찾아보지는 않았지만, 인기 있는 라이브러리에서 이런 사소한 성능 문제를 더 많이 발견할 수 있으리라 생각합니다. 오늘은 주로 몇 가지 빌드 도구에 대해 살펴보았지만, UI 컴포넌트나 기타 라이브러리에서도 일반적으로 성능 저하 문제가 동일하게 발생합니다.

이걸로 Go나 Rust의 성능을 따라잡기 충분할까요? 그럴 가능성은 작지만, 분명한 것은 자바스크립트 도구들이 지금보다 빨라질 수 있다는 것입니다. 그리고 이 글에서 살펴본 것은 빙산의 일각에 불과합니다.

🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!

profile
FE 개발을 하고 있어요🌱

3개의 댓글

comment-user-thumbnail
2023년 1월 18일

이 글에서는 널리 사용되고 있는 자바스크립트 기반 빌드 도구들을 프로파일링해 성능 문제를 찾고 이를 해결한 방법을 소개하고 있습니다. 어떤 부분이 성능 문제를 유발했고, 이를 어떻게 해결했는지 살펴보며 진행중이신 프로젝트를 한 번 점검해 보시는 것도 좋을 것 같습니다. Dollar General Customer Satisfaction Survey

답글 달기
comment-user-thumbnail
2023년 1월 20일

이 글에서는 널리 사용되고 있는 자바스크립트 기반 빌드 도구들을 프로파일링해 성능 문제를 찾고 이를 해결한 방법을 소개하고 있습니다.

VW Credit

답글 달기
comment-user-thumbnail
2023년 1월 20일

.

답글 달기