트리 쉐이킹으로 자바스크립트 페이로드 줄이기

오늘날의 웹 애플리케이션은 특히 JavaScript 부분이 꽤 커질 수 있습니다. 2018년 중반 현재, HTTP Archive에서는 휴대기기에서의 자바스크립트 전송 크기 중앙값을 약 350KB로 설정했습니다. 이것이 전송 크기입니다. JavaScript는 네트워크를 통해 전송될 때 압축되는 경우가 많습니다. 즉, 브라우저에서 압축을 해제한 후에는 JavaScript의 실제 양이 훨씬 더 많습니다. 리소스 처리에 관한 한 압축은 관련이 없다는 점을 짚고 넘어갈 필요가 있습니다. 압축 해제된 JavaScript의 900KB는 압축 시 약 300KB일 수 있지만 파서와 컴파일러에 여전히 900KB입니다.

<ph type="x-smartling-placeholder">
</ph> JavaScript 다운로드, 압축 해제, 파싱, 컴파일, 실행 프로세스를 보여주는 다이어그램 <ph type="x-smartling-placeholder">
</ph> JavaScript를 다운로드하고 실행하는 프로세스입니다. 스크립트의 전송 크기는 300KB로 압축되지만 파싱, 컴파일 및 실행해야 하는 JavaScript는 여전히 900KB에 달하는 JavaScript입니다.

JavaScript는 처리하는 데 비용이 많이 드는 리소스입니다. 다운로드 후 비교적 간단한 디코딩 시간만 발생하는 이미지와 달리 JavaScript는 파싱, 컴파일한 다음 최종적으로 실행되어야 합니다. 바이트당 바이트이므로 JavaScript는 다른 유형의 리소스보다 비용이 더 많이 듭니다.

<ph type="x-smartling-placeholder">
</ph> 동일한 크기의 JPEG 이미지와 170KB의 JavaScript 처리 시간을 비교한 다이어그램 JavaScript 리소스는 JPEG보다 바이트 측면에서 훨씬 더 리소스 집약적인 바이트입니다. <ph type="x-smartling-placeholder">
</ph> 170KB의 JavaScript를 파싱/컴파일하는 데 드는 처리 비용과 동일한 크기의 JPEG를 디코딩하는 데 드는 비용이 비교됩니다. (출처)

JavaScript 엔진의 효율성을 개선하기 위해 지속적인 개선이 이루어지고 있지만, 개발자는 항상 그렇듯이 JavaScript의 성능을 개선해야 합니다.

이를 위해 JavaScript 성능을 개선하는 기법이 있습니다. 코드 분할은 애플리케이션 자바스크립트를 청크로 분할하고 필요한 애플리케이션 경로에만 청크를 제공하여 성능을 개선하는 기법 중 하나입니다.

이 기법은 효과적이지만 한 번도 사용하지 않는 코드가 포함된 JavaScript를 많이 사용하는 애플리케이션의 일반적인 문제를 해결하지는 못합니다. 나무 흔들기에서는 이 문제를 해결하기 위해 노력합니다.

트리 쉐이킹이란 무엇인가요?

트리 쉐이킹은 데드 코드 제거의 한 형태입니다. Rollup이라는 용어로 널리 알려졌지만 데드 코드 제거라는 개념은 얼마 전부터 존재해 왔습니다. 이 개념은 webpack에서도 구매를 발견했으며 이는 이 도움말에서 샘플 앱을 통해 확인할 수 있습니다.

'나무 흔들기'라는 용어 애플리케이션 및 애플리케이션의 멘탈 모델에서 비롯됩니다. 트리의 각 노드는 앱에 고유한 기능을 제공하는 종속 항목을 나타냅니다. 최신 앱에서 이러한 종속 항목은 다음과 같이 정적 import을 통해 가져옵니다.

// Import all the array utilities!
import arrayUtils from "array-utils";
드림

앱을 개발 중인 초기 단계에는 종속 항목이 거의 없을 수 있습니다. 또한 개발자가 추가하는 종속 항목 대부분을 사용합니다. 그러나 앱이 발전함에 따라 더 많은 종속 항목이 추가될 수 있습니다. 복합적으로, 오래된 종속 항목은 사용하지 못하게 되지만 코드베이스에서 제거되지 않을 수 있습니다. 결과적으로 앱은 많은 사용되지 않는 JavaScript와 함께 제공됩니다. 트리 쉐이킹은 정적 import 문이 ES6 모듈의 특정 부분을 가져오는 방식을 활용하여 이 문제를 해결합니다.

// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";

import 예와 이전 예시의 차이점은 이 예에서는 "array-utils" 모듈에서 모든 것(코드가 많을 수 있음)을 가져오는 대신 모듈의 특정 부분만 가져온다는 점입니다. 개발자 빌드에서는 이와 관계없이 전체 모듈을 가져오므로 아무것도 변경하지 않습니다. 프로덕션 빌드에서 webpack이 '흔들림'하도록 구성할 수 있음 명시적으로 가져오지 않은 ES6 모듈에서 내보내기가 제외되어 프로덕션 빌드가 작아졌습니다. 이 가이드에서는 그렇게 하는 방법을 알아봅니다.

나무를 흔들 수 있는 기회 찾기

설명을 위해 샘플 1페이지 앱에서 트리 쉐이킹의 작동 방식을 확인할 수 있습니다. 원하는 경우 클론하여 진행할 수 있지만 이 가이드에서 모든 단계를 함께 다루므로 클론은 필요하지 않습니다 (실습을 원하는 경우가 아니라면).

샘플 앱은 기타 효과 페달의 검색 가능한 데이터베이스입니다. 검색어를 입력하면 이펙트 페달 목록이 나타납니다.

<ph type="x-smartling-placeholder">
</ph> 기타 효과 페달 데이터베이스를 검색하는 1페이지 애플리케이션 샘플의 스크린샷 <ph type="x-smartling-placeholder">
</ph> 샘플 앱의 스크린샷

이 앱을 구동하는 동작은 공급업체로 분리됩니다 (예: PreactEmotion) 및 앱별 코드 번들 (또는 Webpack에서 이를 호출하는 '청크')이 있습니다.

<ph type="x-smartling-placeholder">
</ph> Chrome DevTools의 네트워크 패널에 표시된 두 개의 애플리케이션 코드 번들 (또는 청크) 스크린샷 <ph type="x-smartling-placeholder">
</ph> 앱의 두 JavaScript 번들 이는 압축되지 않은 크기입니다.

위 그림에 표시된 JavaScript 번들은 프로덕션 빌드입니다. 즉, UIU를 통해 최적화됩니다. 앱별 번들에 21.1KB는 나쁘지 않지만 나무 떨림은 전혀 발생하지 않습니다. 앱 코드를 살펴보고 이를 해결하기 위해 무엇을 할 수 있는지 알아보겠습니다.

모든 애플리케이션에서 트리 쉐이킹 기회를 찾으려면 정적 import 문을 찾아야 합니다. 기본 구성요소 파일 상단에 다음과 같은 줄이 표시됩니다.

import * as utils from "../../utils/utils";

다양한 방법으로 ES6 모듈을 가져올 수 있지만 이와 같은 작업에 주의해야 합니다. 이 특정 줄은 'import utils 모듈의 모든 항목utils라는 네임스페이스에 넣습니다.'라고 말합니다. 여기서 중요한 질문은 '모듈에 얼마나 많은 내용이 포함되어 있는가입니다.

utils 모듈 소스 코드를 보면 약 1,300줄의 코드를 찾을 수 있습니다.

이 모든 것이 필요하신가요? utils 모듈을 가져오는 기본 구성요소 파일을 검색하여 이 네임스페이스 인스턴스가 몇 개인지 확인해 보겠습니다.

<ph type="x-smartling-placeholder">
</ph> 3개의 결과만 반환하는 &#39;utils.&#39;를 텍스트 편집기에서 검색한 스크린샷 <ph type="x-smartling-placeholder">
</ph> 수많은 모듈을 가져온 utils 네임스페이스는 기본 구성요소 파일 내에서 세 번만 호출됩니다.

utils 네임스페이스는 애플리케이션에서 세 위치에만 표시되었는데 어떤 기능에 사용되었을까요? 기본 구성요소 파일을 다시 보면 utils.simpleSort이라는 함수 하나만 있는 것으로 보입니다. 이 함수는 정렬 드롭다운이 변경될 때 여러 기준에 따라 검색결과 목록을 정렬하는 데 사용됩니다.

if (this.state.sortBy === "model") {
  // `simpleSort` gets used here...
  json = utils.simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  // ..and here...
  json = utils.simpleSort(json, "type", this.state.sortOrder);
} else {
  // ..and here.
  json = utils.simpleSort(json, "manufacturer", this.state.sortOrder);
}

다수의 내보내기가 포함된 1,300행 파일 중 하나만 사용됩니다. 이로 인해 사용되지 않는 JavaScript가 많이 배포됩니다.

이 예시 앱은 다소 부자연스럽지만, 이러한 합성 시나리오가 프로덕션 웹 앱에서 발생할 수 있는 실제 최적화 기회와 유사하다는 사실에는 변함이 없습니다. 나무 흔들기가 유용할 수 있는 기회를 파악했으니 이제 실제로 어떻게 작동할까요?

Babel이 ES6 모듈을 CommonJS 모듈로 변환하지 않도록 유지

Babel은 반드시 사용해야 하는 도구이지만 나무 흔들림의 효과를 관찰하기가 약간 더 어려울 수 있습니다. @babel/preset-env를 사용하는 경우 Babel은 ES6 모듈을 더 광범위하게 호환되는 CommonJS 모듈, 즉 import 대신 require로 변환할 수 있습니다.

트리 쉐이킹은 CommonJS 모듈에서 수행하기가 더 어렵기 때문에, 개발자가 이를 사용하기로 한 경우 Webpack은 번들에서 무엇을 프루닝해야 하는지 알지 못합니다. 해결 방법은 명시적으로 ES6 모듈을 그대로 두도록 @babel/preset-env를 구성하는 것입니다. babel.config.js 또는 package.json 등 Babel을 구성할 때마다 무언가를 추가해야 합니다.

// babel.config.js
export default {
  presets: [
    [
      "@babel/preset-env", {
        modules: false
      }
    ]
  ]
}

@babel/preset-env 구성에서 modules: false를 지정하면 Babel이 원하는 대로 동작하므로 webpack이 종속 항목 트리를 분석하고 사용되지 않는 종속 항목을 쉐이킹할 수 있습니다.

부작용을 염두에 두기

앱에서 종속 항목을 흔들 때 고려해야 할 또 다른 측면은 부작용이 있을 수 있습니다 부작용의 예로는 함수는 자체 범위 밖의 항목을 수정하는데, 이는 부작용입니다. 확인할 수 있습니다.

let fruits = ["apple", "orange", "pear"];

console.log(fruits); // (3) ["apple", "orange", "pear"]

const addFruit = function(fruit) {
  fruits.push(fruit);
};

addFruit("kiwi");

console.log(fruits); // (4) ["apple", "orange", "pear", "kiwi"]

이 예에서 addFruit는 범위를 벗어나는 fruits 배열을 수정하면 부작용이 발생합니다.

부수 효과는 ES6 모듈에도 적용되며, 트리 쉐이킹과 관련하여 중요합니다. 자체 범위 밖의 아무것도 수정하지 않고 예측 가능한 입력을 사용하고 동일하게 예측 가능한 출력을 생성하는 모듈은 사용하지 않는 경우 안전하게 삭제될 수 있는 종속 항목입니다. 독립 실행형 모듈식 코드입니다. 즉, '모듈'입니다.

webpack과 관련되어 있다면 힌트를 사용하여 프로젝트의 package.json 파일에 "sideEffects": false를 지정하여 패키지와 그 종속 항목에 부작용이 없음을 지정할 수 있습니다.

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": false
}

또는 부작용이 없는 특정 파일을 webpack에 알려줄 수 있습니다.

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": [
    "./src/utils/utils.js"
  ]
}

후자의 예에서 지정되지 않은 모든 파일은 부작용이 없는 것으로 간주됩니다. package.json 파일에 추가하지 않으려면 module.rules를 통해 webpack 구성에서 이 플래그를 지정하면 됩니다.

필요한 항목만 가져오기

ES6 모듈을 그대로 두도록 Babel에 지시한 후에는 import 문법을 약간 조정하여 utils 모듈에서 필요한 함수만 가져와야 합니다. 이 가이드의 예에서는 simpleSort 함수만 있으면 됩니다.

import { simpleSort } from "../../utils/utils";

전체 utils 모듈 대신 simpleSort만 가져오므로 utils.simpleSort의 모든 인스턴스를 simpleSort로 변경해야 합니다.

if (this.state.sortBy === "model") {
  json = simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  json = simpleSort(json, "type", this.state.sortOrder);
} else {
  json = simpleSort(json, "manufacturer", this.state.sortOrder);
}

이 예시에서 트리 쉐이킹이 작동하는 데 필요한 것은 이것뿐입니다. 다음은 종속 항목 트리를 쉐이킹하기 전의 webpack 출력입니다.

                 Asset      Size  Chunks             Chunk Names
js/vendors.16262743.js  37.1 KiB       0  [emitted]  vendors
   js/main.797ebb8b.js  20.8 KiB       1  [emitted]  main

트리 쉐이킹에 성공한 출력은 다음과 같습니다.

                 Asset      Size  Chunks             Chunk Names
js/vendors.45ce9b64.js  36.9 KiB       0  [emitted]  vendors
   js/main.559652be.js  8.46 KiB       1  [emitted]  main

두 번들 모두 축소되었지만 실제로는 main 번들이 가장 큰 이점을 제공합니다. utils 모듈에서 사용되지 않는 부분을 삭제하면 main 번들이 약 60% 축소됩니다. 이렇게 하면 스크립트를 다운로드하는 데 걸리는 시간뿐만 아니라 처리 시간도 단축됩니다.

나무 좀 흔들어 봐!

트리 쉐이킹에서 얻는 마일리지는 앱과 앱의 종속 항목, 아키텍처에 따라 다릅니다. 사용해 보기 이 최적화를 수행하기 위해 모듈 번들러를 설정하지 않았다면, 이를 시도하고 애플리케이션에 어떤 이점을 제공하는지 확인해 볼 필요가 없습니다.

트리 쉐이킹으로 인해 성능이 크게 향상되거나 전혀 향상되지 않을 수도 있습니다. 그러나 프로덕션 빌드에서 이러한 최적화를 활용하도록 빌드 시스템을 구성하고 애플리케이션에 필요한 항목만 선택적으로 가져오면 애플리케이션 번들을 가능한 한 작게 유지할 수 있습니다.

Kristofer Baxter, Jason Miller, Addy Osmani, Jeff Posnick, Sam Saccone, Philip Walton에게 귀중한 의견을 제공해 이 기사의 품질을 크게 향상시킨 것에 감사의 말씀을 전합니다.