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


원문 : https://developers.google.com/web/fundamentals/performance/optimizing-javascript/tree-shaking/#go_shake_some_trees

오늘날 웹 애플리케이션들은 굉장히 크고, 대부분 자바스크립트로 만들어진 것이다. 2018년 중순, HTTP Archive가 보여준 모바일 장치에서 자바스크립트의 평균 전송 크기는 약 350 KB 이다. 이것은 단순히 전송 크기이다! 자바스크립트는 네트워크 전송될 때 주로 압축된다. 이는 압축을 풀고 나면 자바스크립트 실제 크기가 더 늘어난다는 것을 의미한다. 중요한 포인트다. 자원 프로세싱이 걱정된다면, 압축은 적절하지 않다. 압축되지 않은 900 KB의 자바스크립트는 파서와 컴파일러에서 여전히 동일한 크기를 가지고, 압축되면 300 KB까지 줄어들게 될 것이다.

그림 1. 자바스크립트 다운로드와 실행 과정. 스크립트의 전송 크기는 300 KB로 압축되지만, 파싱되고 컴파일 및 실행되면 900 KB로 늘어난다.

자바스크립트는 수행하는데 비용이 많이 드는 자원이다. 다운로드하는 동안에만 잠깐 디코딩 시간이 발생하는 이미지와 다르게, 자바스크립트는 파싱되고 컴파일되고 마침내 실행된다. 바이트 단위의 전송은, 다른 종류의 자원보다 자바스크립트에서 더 많은 비용을 발생시킨다.

그림 2. 170 KB 크기의 자바스크립트 파싱/컴파일링 수행 비용 vs 동일한 크기의 JPEG 이미지 디코딩 시간 (출처)

자바스크립트 엔진 효율성을 향상시키기 위한 지속적인 개선이 이루어지는 반면 늘 그렇듯, 자바스크립트 성능 향상은 개발자의 몫이다. 결국, 어플리케이션 설계를 개선하는데 개발자 자신보다 더 나은 사람이 있을까?

마지막은 자바스크립트 성능을 개선하기 위한 기술들이 있다. 코드 스플리팅(Code Splitting)은 자바스크립트 청크로 애플리케이션을 분할하고, 청크를 필요로 하는 애플리케이션의 경로에만 이 청크들을 배분하여 성능을 개선하는 기술이다. 그러나 이 기술을 사용한다고 해서, 사용되지 않는 코드를 포함한 무거운 자바스크립트 애플리케이션의 일반적인 문제를 해결하지는 못한다. 이 문제의 해법을 트리 쉐이킹(Tree Shaking)에서 찾을 수 있다.

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

트리 쉐이킹은 사용하지 않는 코드를 제거하는 방식이다. 이 용어는 Rollup에 의해 인기를 얻게 되었으나, 사용하지 않는 코드 제거에 대한 개념은 이미 존재했었다. 또한 이 개념은 webpack에서도 찾아볼 수 있고, 이번 아티클에서 예제 앱을 통해 설명한다.

"트리 쉐이킹" 용어는 당신이 만든 애플리케이션의 멘탈 모델(mental model)과 디펜던시 트리 구조에서 유래되었다. 트리 내 각 노드들은 앱을 위해 특징적인 기능들을 제공하는 디펜던시들을 나타낸다. 최신 앱에서는 이러한 디펜던시들을 다음과 같이 정적 import 구문으로 가져올 수 있다:

// 모든 배열 유틸리티들을 가져온다.
import arrayUtils from "array-utils";

참고: ES6 모듈이 무엇인지 모른다면, Pony Foo의 훌륭한 설명을 추천한다. 아티클을 읽다가 아무 것도 모르겠다면, 이 가이드는 ES6 모듈 동작에 대한 지식을 얻는데 도움이 될 것이다.

당신의 애플리케이션이 이제 막 만들어졌다면 비교적 적은 양의 디펜던시를 가질 것이다. 또한 추가된 디펜던시 대부분을 사용할 것이다. 하지만 애플리케이션이 오래될수록 더 많은 디펜던시들이 추가될 수 있다. 복잡한 문제들을 위해 오래된 디펜던시들을 빼지만, 코드에서는 제거되지 못할 수 있다. 마지막은 사용하지 않는 대량의 자바스크립트 코드들과 함께 앱이 끝나는 것이다. 트리 쉐이킹은 정적 ES6 모듈의 특정 부분을 가져오는 import 구문의 이점을 사용해 이러한 문제를 해결할 것이다:

// 유틸의 일부만 가져온다.
import { unique, implode, explode } from "array-utils";

이전에 본 import 구문과 차이점은, 이전 예제에서는 "array-utils" 모듈을 모두 가져오지만 이 예제에서는 모듈의 특정 부분만 가져온다. 개발 빌드에서는 어떤 것도 설정하지 않았기 때문에 import 된 것과 상관 없이 전체 모듈을 가져온다. 그러나 프로덕션 빌드에서는 명시적으로 import 되지 않은 ES6 모듈로부터 export를 "떨어버리기(shake)" 위해서 webpack을 설정하고, 빌드 결과물 크기를 더 작게 만들 수 있다. 이번 가이드에서 어떻게 이것을 할 수 있는지 배우게 될 것이다.

트리 쉐이킹 할 수 있는 지점 찾기

이해를 돕기 위해 필자는 예제 앱을 만들었다. 이 예제는 트리 쉐이킹이 어떻게 동작하는지 설명하기 위해 webpack을 사용했다. 당신은 리포지터리를 클론하고 따라할 수 있지만 이 가이드에서 방식의 모든 단계를 함께 다룰 것이기 때문에 클론을 꼭 할 필요는 없다.

예제 앱은 기타 이펙트 페달을 검색 할 수 있는 매우 간단한 데이터베이스이다. 당신이 쿼리를 입력하면 팝업에 이펙트 페달 목록이 나타난다.

그림 3. 예제 앱 스크린샷

예상대로, 앱을 구동하는 동작은 벤더(PreactEmotion 해당)와 앱-특정 코드 번들(webpack에서는 "청크"로 부른다)로 분리되었다.

그림 4. 두 가지 앱 자바스크립트 번들. 이 파일들은 압축되지 않은 크기이다.

위 이미지는 프로덕션 빌드에서 자바스크립트 번들을 보여주고, 이는 어글리파이를 통해서 최적화되었다는 것을 의미한다. 청크 파일이 21.5 KB이면 그리 나쁘지 않다. 그러나! 무엇이든지 간에 트리 쉐이킹이 일어나지 않았다는 것에 주목해야 한다. 앱 코드를 함께 살펴보면서 무엇을 수정해야할지 보자.

참고: 장황한 설명이나 코드를 보길 원하지 않는다면, 앞으로 돌아가 앱 깃헙 리포에서 트리 쉐이킹에 대한 브랜치를 체크아웃 할 수 있다. 또한 트리 쉐이킹이 동작할 때 정확하게 무엇이 변경되었는지 보기 위해 마스터 브랜치와 diff 할 수 있다.

어떤 애플리케이션에서 트리 쉐이킹 할 수 있는 지점을 찾는 것은 정적 import 구문을 찾는 것이다. 메인 컴포넌트 파일의 맨 윗 부분에서 다음과 같이 된 것을 볼 수 있다.

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

아마도 당신은 이전에 비슷한 것을 보았을 것이다. import 될 수 있는 ES6 모듈을 내보내는 방법은 굉장히 많지만, 이와 같은 방식이 당신의 관심을 끌 것이다. 이 특정 라인은 "이봐, utils 모듈로부터 모든 것import 해서 utils 네임스페이스에 넣어라"라고 말한다. 여기서 큰 궁금증이 생길 것이다. "그 모듈 안에 얼마나 많은 들이 있을까?"

자, utils 모듈의 소스 코드를 보면 굉장히 많다. 1,300 줄 정도 된다.

그러나 걱정하지 마라. 아마도 모든 것들이 사용되었을 것이다. 그렇지? 그럼 우리는 이 모든 것들을 필요로 할까? utils 모듈을 import 하는 메인 컴포넌트 파일를 다시 확인하면서, 얼마나 많은 네임스페이스의 인스턴스가 있는지 보자. 확실하게, 우리는 무언가 를 위해 메인 컴포넌트에 있는 것들을 사용해야만 한다.

그림 5. 메인 컴포넌트 파일에서 import 된 수많은 모듈 중에 utils 네임스페이스는 세 번만 호출되었다.

물론 그것은 좋지 않다. 애플리케이션 코드에서 세군데서만 utils 네임스페이스를 사용했다. 근데 어떤 기능을 하는가? 메인 컴포넌트 파일을 다시 보면, 이 코드들은 utils.simpleSort 함수에서만 나타난다. 이 함수는 드롭다운 메뉴가 변경될 때 기준 번호로 검색 결과 목록을 정렬하기 위해 사용된다.

if (this.state.sortBy === "model") {
  // simpleSort는 여기에서 사용된다...
  json = utils.simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  // ..그리고 여기...
  json = utils.simpleSort(json, "type", this.state.sortOrder);
} else {
  // ..그리고 여기.
  json = utils.simpleSort(json, "manufacturer", this.state.sortOrder);
}

그렇다. export 더미들과 함께 1,300줄 중에서, 이 함수만 볼 것이다. 웹 성능에 굉장히 나쁜 것을 밝혀냈다.

참고: 이 프로젝트는 의도적으로 단순하게 작성되었기 때문에, 확장할 곳을 찾기 굉장히 쉽다. 그러나 많은 모듈을 사용하는 큰 프로젝트에서는 얼마나 많은 번들이 import 되었는지 알기 어렵다. webpack 번들 분석기source-map-explorer가 도움이 될 수 있고, 이러한 보조 도구들은 여전히 개발되고 있다.

물론, 지금 이 예제는 아티클을 위해 조금 가공되었다. 그러나 종합적인 시나리오가 실제 앱에서 일어날 수 있는 최적화 기회와 유사하다는 사실은 변하지 않는다. 당신은 유용한 트리 쉐이킹 기회를 찾았고, 실제로는 어떻게 할까?

Babel로 ES6 모듈이 CommonJS 모듈로 변환되는 것 막기

Babel은 많은 앱에서 없어서는 안될 도구이다. 불행하게도, Babel이 하는 어떤 것 때문에 트리 쉐이킹 같은 간단한 작업을 어렵게 만들 수 있다. babel-preset-env를 사용하면, ES6 모듈을 범용적으로 호환되는 CommonJS 모듈(즉, import 대신 require 모듈)로 변환해준다. 이것은 트리 쉐이킹을 하기 전까지는 좋다.

트리 쉐이킹은 CommonJS 모듈에서 하기 어렵고, webpack은 사용하려는 번들에서 무엇을 제거해야할지 모른다. 해결책은 간단하다: ES6 모듈만 남도록 babel-preset-env를 설정한다. Babel을 설정한 곳 어디서든(.babelrc 또는 package.json) 약간의 옵션을 추가해야 한다.

{
  "presets": [
    ["env", {
      "modules": false
    }]
  ]
}

babel-preset-env 설정에서 간단히 "modules": false로 설정하면 Babel은 우리의 바람대로 동작한다. webpack이 디펜던시 트리를 분석해서 사용하지 않는 디펜던시들을 제거한다. 게다가 이 과정은 호환성 문제를 일으키지 않는다. 결국 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 배열을 변경할 때 사이드 이펙트를 발생시킨다. 이 fruits 배열은 addFruit 함수 스코프 밖에 있다. 사이드 이펙트는 ES6 모듈에도 적용되며, 트리 쉐이킹의 컨텍스트에서 문제가 된다. 예측 가능한 입력을 가지고 동일하게 함수의 스코프 밖의 어떤 것도 변경하지 않으면서 예측 가능한 결과를 반환하는 모듈이 안전하게 트리 쉐이킹을 할 수 있는 디펜던시이다. 이것들은 자체적으로 포함된 코드의 모듈식 조각이다. 즉, "모듈들(modules)"이다.

webpack에서 고려해야 할 부분은, 프로젝트의 package.json 파일에서 "sideEffects": false로 설정하면 패키지와 디펜던시들이 사이드 이펙트를 발생하지 않는다는 것이다:

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

선택적으로, 사이드 이펙트의 영향을 받지 않을 특정 파일들을 지정할 수도 있다.

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

아래 예제에서, 지정되지 않은 파일은 사이트 이펙트가 없다고 가정할 것이다. package.json 파일에 이 옵션을 추가하기 원하지 않는다면, webpack 설정 파일에 module.rules 플래그 값을 지정할 수 있다.

원하는 것만 가져오기

우리는 ES6 모듈을 그대로 두려고 Babel을 설정했지만, utils 모듈에서 필요한 함수만 가져오기 위해 import 구문을 조금 수정하려고 한다. 가이드 예제에서 필요로 하는 것은 simpleSort 함수이다:

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

이 구문을 사용하면서 이렇게 말할 것이다. "이봐, utils 모듈에서 simpleSort만 가져와." simpleSort 함수만 가져오고 전역 스코프에 utils 모듈이 없기 떄문에, 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 모듈의 사용하지 않는 부분을 쉐이킹하여, 번들 파일에서 약 60%의 코드를 제거하였다. 이렇게하면 스크립트를 다운로드하는데 드는 시간 뿐만 아니라 처리 시간도 단축되어서 좋다.

잘 되지 않을 때

대부분의 경우, 이런 사소한 변경이 있을 때 트리 쉐이킹은 webpack 최신 버전에서 잘 동작하지만 항상 예외는 있다. 예를 들어, Lodash가 위의 가이드대로 트리 쉐이킹이 잘 동작하지 않는 이상한 경우에 해당된다. Lodash 설계 방식 때문에, a) 오래된 표준 lodash 대신 lodash-es를 설치하고 b) 다른 디펜던시를 쉐이킹하기 위해 조금 다른 구문("cherry-picking"이라고도 한다)을 사용한다.

// 설정이 잘 되어있어도 lodash 모든 것들을 가져온다.
import { sortBy } from "lodash";

// sortBy 경로에서 가져온다.
import sortBy from "lodash-es/sortBy";

일관되게 import 구문을 사용하길 선호한다면, 표준 lodash 패키지를 사용 할 수 있다. babel-plugin-lodash를 설치한다. Babel 설정 파일에 플러그인을 추가하면, import 구문을 사용하면서 사용하지 않는 모듈들을 제거할 수 있다.

실행한 라이브러리가 트리 쉐이킹에 반응하지 않는다면, ES6 구문을 사용하여 메서드를 내보내는지 확인하라. 만약 CommonJS 형식(예: module.exports)으로 내보내고 있다면, 해당 코드는 트리 쉐이킹을 할 수 없다. 몇몇 플러그인들은 CommonJS 모듈을 위한 트리 쉐이킹 기능을 제공한다. (예: webpack-common-shake) 그러나 이 플러그인들은 트리 쉐이킹을 할 수 없는 몇 가지 CommonJS 패턴만큼이나 갈 길이 멀다. 당신의 애플리케이션에서 사용하지 않는 디펜던시들을 확실하게 제거하기를 원한다면, 앞으로 ES6 모듈을 사용해야 할 것이다.

트리 쉐이킹을 해보자!

트리 쉐이킹이 가능한 정도는 앱과 앱에서 사용하고 있는 디펜던시 및 설계 구조에 따라 다르다. 시도해보라! 모듈 번들러에 트리 쉐이킹을 위한 옵션이 설정되지 않았다는 것을 알고 있다면, 애플리케이션에 어떤 이점이 있는지 보고 해보는데 아무런 해가 없다. 번들 파일에서 제거 가능한 사용하지 않는 어떤 코드는 가치 있는 최적화다.

트리 쉐이킹으로부터 많은 이익을 얻거나 그렇지 않을 수도 있다. 그러나 프로덕션 빌드에서 트리 쉐이킹의 이점을 위해 빌드 시스템을 구성하고 애플리케이션에서 필요로 하는 것만 선택적으로 가져오는 것으로써, 당신의 애플리케이션을 가능한 한 가볍게 유지시켜줄 것이다. 트리 쉐이킹은 성능과 확장, 사용자들에게 좋다.

이 아티클을 개선하는데 좋은 피드백을 준 Kristofer Baxter, Jason Miller, Addy Osmani, Jeff Posnick, Sam Saccone 그리고 Philip Walton에게 감사하다.