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

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

JavaScript의 다운로드, 압축 해제, 파싱, 컴파일 및 실행 과정을 보여주는 다이어그램
자바스크립트를 다운로드하고 실행하는 프로세스입니다. 스크립트의 전송 크기는 300KB 압축이지만 파싱, 컴파일 및 실행되어야 하는 JavaScript는 여전히 900KB에 달합니다.

JavaScript는 처리 비용이 많이 드는 리소스입니다. 다운로드 후 디코딩 시간이 비교적 적은 이미지와 달리 JavaScript는 파싱, 컴파일한 후 최종적으로 실행해야 합니다. 바이트의 바이트입니다. 이로 인해 JavaScript는 다른 유형의 리소스보다 비용이 많이 듭니다.

자바스크립트의 170KB 처리 시간을 동일한 크기의 JPEG 이미지와 비교한 다이어그램. JavaScript 리소스는 JPEG보다 바이트당 훨씬 리소스 집약적인 바이트입니다.
170KB의 JavaScript를 파싱/컴파일하는 데 드는 처리 비용과 동일한 크기의 JPEG의 디코딩 시간을 비교한 비용입니다. (출처)

JavaScript 엔진의 효율성을 개선하기 위해 계속 개선되고 있지만, 언제나 그렇듯이 개발자는 JavaScript의 성능을 향상시켜야 합니다.

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

이 기법은 효과가 있기는 하지만, 전혀 사용되지 않는 코드를 포함하는 자바스크립트를 많이 사용하는 애플리케이션의 일반적인 문제는 해결되지 않습니다. 나무 흔드는 이 문제를 해결하기 위한 시도입니다.

나무 흔들림이란 무엇인가요?

트리 쉐이킹은 데드 코드 제거의 한 형태입니다. 이 용어는 Rollup에 의해 널리 알려졌지만 데드 코드 제거라는 개념은 오래 전부터 존재했습니다. 이 개념은 이 도움말에서 샘플 앱을 통해 시연하는 webpack에서도 구매 가능합니다.

'트리 쉐이킹'이라는 용어는 애플리케이션의 정신 모델과 트리 같은 구조인 종속 항목에서 비롯되었습니다. 트리의 각 노드는 앱에 고유한 기능을 제공하는 종속 항목을 나타냅니다. 최신 앱에서 이러한 종속 항목은 다음과 같이 정적 import을 통해 가져옵니다.

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

앱이 젊을 때는 종속 항목이 거의 없을 수 있습니다. 또한 추가하는 종속 항목 대부분(전부는 아님)을 사용합니다. 그러나 앱이 성장함에 따라 더 많은 종속 항목이 추가될 수 있습니다. 더 복잡하게 하기 위해, 이전 종속 항목은 더 이상 사용되지 않지만 코드베이스에서 프루닝되지 않을 수도 있습니다. 결과적으로 앱은 사용되지 않는 자바스크립트가 많이 포함된 상태로 제공됩니다. 트리 쉐이킹은 정적 import 문이 ES6 모듈의 특정 부분을 가져오는 방식을 활용하여 이 문제를 해결합니다.

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

import 예와 이전 예의 차이점은 "array-utils" 모듈에서 모든 항목(코드가 많을 수 있음)을 가져오는 대신 이 예에서는 특정 부분만 가져온다는 점입니다. 개발 빌드에서는 아무 것도 변경되지 않습니다. 모듈 전체를 가져오기 때문입니다. 프로덕션 빌드에서는 명시적으로 가져오지 않은 ES6 모듈의 내보내기를 '흔들지 않도록' webpack을 구성하여 프로덕션 빌드 크기를 줄일 수 있습니다. 이 가이드에서는 그 방법을 설명합니다.

나무를 흔들기 위한 기회 찾기

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

샘플 앱은 기타 효과 페달을 검색할 수 있는 데이터베이스입니다. 검색어를 입력하면 효과 페달 목록이 표시됩니다.

기타 효과 페달 데이터베이스를 검색하는 1페이지 애플리케이션 샘플의 스크린샷
샘플 앱의 스크린샷

이 앱을 구동하는 동작은 공급업체 (즉, Preact, Emotion), 앱별 코드 번들 (또는 webpack에서 이를 '청크'라고 함)은 다음과 같습니다.

Chrome DevTools의 네트워크 패널에 표시된 두 개의 애플리케이션 코드 번들 (또는 청크) 스크린샷
앱의 두 자바스크립트 번들 압축되지 않은 크기입니다.

위 그림에 표시된 JavaScript 번들은 프로덕션 빌드로, 비글화를 통해 최적화됩니다. 앱별 번들의 21.1KB는 나쁘지는 않지만 트리 쉐이킹이 전혀 발생하지 않는다는 점에 유의해야 합니다. 앱 코드를 살펴보고 이를 해결하기 위해 무엇을 할 수 있는지 살펴보겠습니다.

모든 애플리케이션에서는 정적 import 문을 찾아야만 트리 쉐이킹 기회를 찾을 수 있습니다. 기본 구성요소 파일의 상단 근처에 다음과 같은 줄이 표시됩니다.

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

다양한 방법으로 ES6 모듈을 가져올 수 있지만 이와 같은 모듈은 주의가 필요합니다. 이 특정 줄은 'utils 모듈의 모든 항목import하고 utils라는 네임스페이스에 넣습니다'입니다. 여기서 해야 할 중요한 질문은 '해당 모듈에 항목이 얼마나 있는가?'입니다.

utils 모듈 소스 코드를 살펴보면 약 1,300줄의 코드를 확인할 수 있습니다.

그 모든 것이 필요한가요? utils 모듈을 가져오는 기본 구성요소 파일을 검색하여 이 네임스페이스에 얼마나 많은 인스턴스가 나타나는지 다시 확인해 보겠습니다.

텍스트 편집기에서 'utils.'를 검색하여 결과 3개만 반환하는 스크린샷
수많은 모듈을 가져온 utils 네임스페이스는 기본 구성요소 파일 내에서 3회만 호출됩니다.

알 수 있듯이 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에 지시한 후, utils 모듈에서 필요한 함수만 가져오려면 import 문법을 약간 조정해야 합니다. 이 가이드의 예에서는 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% 축소됩니다. 스크립트를 다운로드하는 데 걸리는 시간뿐만 아니라 처리 시간도 단축됩니다.

가서 나무 좀 흔드세요!

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

트리 쉐이킹으로 상당한 성능 향상을 실현할 수도 있고 그다지 많지 않을 수도 있습니다. 그러나 프로덕션 빌드에서 이러한 최적화 기능을 활용하도록 빌드 시스템을 구성하고 애플리케이션에 필요한 항목만 선별적으로 가져오면 애플리케이션 번들을 가능한 한 작게 미리 유지할 수 있습니다.

크리스토퍼 백스터, 제이슨 밀러, 애디 오스마니, 제프 포스닉, 샘 사콘, 필립 월튼에게 소중한 의견을 주신 데 대해 감사의 말씀을 전합니다. 이 자료의 품질이 크게 향상되었습니다.