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

오늘날의 웹 애플리케이션은 특히 JavaScript 부분이 상당히 커질 수 있습니다. 2018년 중반 기준, HTTP Archive는 휴대기기의 JavaScript 전송 크기 중간값을 약 350KB로 보고 있습니다. 이 정도는 전송 크기에 불과합니다. JavaScript는 네트워크를 통해 전송될 때 압축되는 경우가 많습니다. 즉, 브라우저에서 압축을 해제하면 실제 JavaScript 양이 훨씬 더 많습니다. 이는 리소스 처리와 관련하여 압축은 관련이 없으므로 중요한 사항입니다. 압축 해제된 900KB의 JavaScript는 압축 시 약 300KB일 수 있지만 파서와 컴파일러에는 여전히 900KB입니다.

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

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

170KB의 JavaScript 처리 시간과 동일한 크기의 JPEG 이미지 처리 시간을 비교하는 다이어그램 JavaScript 리소스는 JPEG보다 바이트당 리소스 사용량이 훨씬 더 많습니다.
170KB의 JavaScript 파싱/컴파일 처리 비용과 크기가 비슷한 JPEG의 디코딩 시간 비교 (출처).

JavaScript 엔진의 효율성을 개선하기 위해 계속해서 개선이 이루어지고 있지만 JavaScript 성능을 개선하는 것은 언제나 개발자의 몫입니다.

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

이 기법은 작동하지만, JavaScript가 많은 애플리케이션에서 발생하는 일반적인 문제인 사용되지 않는 코드가 포함되는 문제를 해결하지는 못합니다. 트리 쉐이킹은 이 문제를 해결하려고 시도합니다.

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

트리 쉐이킹은 불필요한 코드 제거의 한 형태입니다. 이 용어는 롤업이 대중화되면서 알려졌지만, 비활성 코드 제거라는 개념은 오래전부터 존재했습니다. 이 개념은 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페이지 앱을 제공합니다. 원하는 경우 클론하여 따라할 수 있지만 이 가이드에서 모든 단계를 함께 다루므로 실습 학습을 선호하지 않는 한 클론할 필요는 없습니다.

샘플 앱은 검색 가능한 기타 이펙트 페달 데이터베이스입니다. 검색어를 입력하면 이펙트 페달 목록이 표시됩니다.

기타 이펙트 페달 데이터베이스를 검색하기 위한 샘플 1페이지 애플리케이션의 스크린샷
샘플 앱의 스크린샷입니다.

이 앱을 구동하는 동작은 공급업체 (예: PreactEmotion) 및 앱별 코드 번들 (또는 webpack에서 부르는 '청크')을 포함합니다.

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

위 그림에 표시된 JavaScript 번들은 프로덕션 빌드입니다. 즉, uglification을 통해 최적화됩니다. 앱별 번들의 크기가 21.1KB는 나쁘지 않지만 트리 셰이킹이 전혀 발생하지 않는다는 점에 유의해야 합니다. 앱 코드를 살펴보고 문제를 해결하기 위해 취할 수 있는 조치를 알아보겠습니다.

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

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

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

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

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

텍스트 편집기에서 'utils.'를 검색한 스크린샷으로, 결과가 3개만 반환됩니다.
수많은 모듈을 가져온 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 모듈에서는 트리 쉐이킹이 더 어렵기 때문에 CommonJS 모듈을 사용하기로 결정한 경우 webpack은 번들에서 무엇을 프루닝해야 할지 알 수 없습니다. 해결 방법은 ES6 모듈을 명시적으로 그대로 두도록 @babel/preset-env를 구성하는 것입니다. Babel을 구성하는 위치(babel.config.js 또는 package.json)와 관계없이 다음과 같이 약간 추가해야 합니다.

// 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 구성에서 이 플래그를 지정할 수도 있습니다.

필요한 항목만 가져오기

Babel에 ES6 모듈을 그대로 두도록 지시한 후 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% 줄어듭니다. 이렇게 하면 스크립트를 다운로드하는 데 걸리는 시간뿐만 아니라 처리 시간도 줄어듭니다.

나무를 흔들어 보세요.

트리 셰이킹에서 얻을 수 있는 이점은 앱과 종속 항목, 아키텍처에 따라 다릅니다. 사용해 보기 이 최적화를 실행하도록 모듈 번들러를 설정하지 않았다는 사실을 확실히 알고 있다면 한 번 시도해 보고 애플리케이션에 어떤 이점이 있는지 확인해 보세요.

트리 셰이킹으로 상당한 성능 향상을 얻을 수도 있고, 그렇지 않을 수도 있습니다. 하지만 프로덕션 빌드에서 이 최적화를 활용하도록 빌드 시스템을 구성하고 애플리케이션에 필요한 것만 선택적으로 가져오면 애플리케이션 번들을 최대한 작게 유지할 수 있습니다.

크리스토퍼 배스터, 제이슨 밀러, 애디 오스마니, 제프 포스닉, 샘 사코네, 필립 월튼님께 감사드립니다. 이 분들의 귀중한 의견이 이 도움말의 품질을 크게 향상하는 데 큰 도움이 되었습니다.