Published on

트리쉐이킹이 뭘까

Authors
  • avatar
    Name
    윤영서
    Twitter

트리 쉐이킹(Tree Shaking)이란 애플리케이션에서 사용하지 않는 코드를 제거하는 최적화 기법입니다. 이 기법은 ES2015 모듈 번들러인 Rollup에 의해 처음 구현되고 대중화되었으며, 이후 Webpack 등 다른 번들러들도 이 개념을 도입했습니다. 재밌게도 Webpack에서는 트리 쉐이킹을 언급하며 이 이름과 개념은 ES2015 모듈 번들러 롤업에 의해 대중화되었습니다.라고 언급하고 있습니다.

트리 셰이킹이라는 이름은 이 기법의 작동 방식에서 유래했습니다. 애플리케이션의 export/import 구조를 나무로 시각화했을 때, 사용되는 코드(건강한 나뭇잎)는 남기고 사용되지 않는 코드(죽은 나뭇잎)를 떨어뜨린다는 의미를 담고 있습니다.

모듈(Module)과 번들링(Bundling)

트리 쉐이킹을 이해하기에 앞서 모듈(Module)번들링(Bundling), 그리고 CJS(CommonJS)ESM(ES6 Module)에 대해 알아보겠습니다.

모듈은 독립적인 기능을 가진 코드 조각입니다.

// math.js 모듈
export const add = (a, b) => a + b;

// main.js에서 임포트해서 사용
import { add } from './math.js';

번들링은 여러 모듈을 하나의 파일로 묶는 과정입니다. 웹 애플리케이션의 다양한 모듈과 그들의 의존성을 하나의 파일로 통합하여 브라우저에서 효율적으로 로드할 수 있게 합니다.

<!-- 번들링 전 -->
<script src="utils.js"></script>
<script src="api.js"></script>
<script src="components.js"></script>

<!-- 번들링 후 -->
<script src="bundle.js"></script>

트리 셰이킹의 실제 적용

트리 셰이킹은 번들링 과정에서 발생할 수 있는 불필요한 코드 포함 문제를 해결합니다.

// utils.js
export const formatDate = (date) => {
  return date.toISOString();
};

export const formatCurrency = (amount) => {
  return `$${amount.toFixed(2)}`;
};

export const formatPercentage = (value) => {
  return `${value * 100}%`;
};

// app.js
import { formatDate } from './utils.js';

const today = new Date();
console.log(formatDate(today));

위 예시에서 formatCurrency와 formatPercentage 함수는 사용되지 않습니다. 트리 셰이킹은 이러한 미사용 코드를 최종 번들에서 제거하여 번들 크기를 최적화합니다.

// 번들링 + 트리 셰이킹 후
const formatDate = (date) => {
return date.toISOString();
};

const today = new Date();
console.log(formatDate(today));

CJS(CommonJS) vs ESM(ES6 Module)

CJS는 Node.js의 기본 모듈 시스템으로 시작되었습니다.

// 내보내기
module.exports = { func };
exports.func = () => {};

// 가져오기
const module = require('./module');
const { func } = require('./module');

require와 module.exports를 사용하여 모듈을 가져오고 내보내며, 동기적으로 동작합니다. CJS는 런타임에 모듈을 해석하기 때문에 동적으로 모듈을 불러올 수 있습니다.

반면 ESM은 자바스크립트 표준의 일부로 설계되었습니다.

// 내보내기
export const func = () => {};
export default func;

// 가져오기
import { func } from './module';
import defaultFunc from './module';

ESM은 import와 export 키워드를 사용하며, 정적 분석이 가능한 구조를 가지는데 이는 트리 쉐이킹에 있어서 정말 중요한 요소로 작용합니다.

트리 셰이킹이 가능한 조건

트리 셰이킹이 가능한 조건들은 모두 코드의 정적 분석 가능성과 밀접한 관련이 있습니다. 번들러가 빌드 타임에 코드의 사용 여부를 정확하게 판단할 수 있어야 하기 때문입니다.

1. Named Exports 사용

Named Exports 사용은 트리 셰이킹의 가장 기본적인 조건입니다. 정적 분석기가 모듈에서 어떤 부분이 사용되는지 명확하게 추적할 수 있기 때문입니다.

// utils.js
export const func1 = () => {};
export const func2 = () => {};

// main.js
import { func1 } from './utils';

이 경우 번들러는 func1만 사용된다는 것을 명확하게 알 수 있습니다. 반면 default export를 사용하면 객체 전체가 임포트되므로 개별 속성의 사용 여부를 추적하기 어렵습니다.

2. 정적 경로의 사용

정적 경로의 사용은 의존성 그래프를 정확하게 구성하는 데 필수적입니다. 의존성 그래프는 다음과 같이 구축됩니다.

// 1. 엔트리 포인트 분석
entry.js
// 2. import/export 추적
entry.js → moduleA.js → moduleB.js
// 3. 의존성 관계 매핑
{
  'entry.js': ['moduleA.js'],
  'moduleA.js': ['moduleB.js']
}

모듈 경로가 문자열 리터럴로 지정되면 번들러가 빌드 타임에 전체 의존성 트리를 구축할 수 있습니다. 동적 경로를 사용하면 런타임까지 실제 모듈을 알 수 없어 트리 셰이킹이 불가능해집니다.

// 정적 경로 - 트리 셰이킹 가능
import { Button } from './components/Button';

// 동적 경로 - 트리 셰이킹 불가능
const componentPath = getComponentPath();
const component = await import(componentPath);

3. 순수 함수의 사용

순수 함수의 사용은 사이드 이펙트 없이 코드를 제거할 수 있게 해줍니다. 순수 함수는 외부 상태를 변경하지 않고 입력에 대해 항상 같은 출력을 반환하므로, 사용되지 않는 경우 안전하게 제거할 수 있습니다.

// 순수 함수 - 안전하게 제거 가능
export const add = (a, b) => a + b;

// 사이드 이펙트 - 제거 시 위험
export const initialize = () => {
  window.globalState = { /* ... */ };
};

4. ESM의 바인딩 특성

ESM의 바인딩 특성도 중요한 요소입니다. 이는 번들러가 모듈 간의 참조를 정확하게 추적할 수 있게 해줍니다.

// CJS - 값 복사
const { count } = require('./module'); // count는 값 0을 복사

// ESM - 메모리 참조
import { count } from './module'; // count는 모듈의 메모리 위치를 참조

5. 모듈의 사이드 이펙트 명시

모듈의 사이드 이펙트를 명시적으로 선언하는 것도 중요합니다. package.json의 sideEffects 필드를 통해 번들러에게 어떤 모듈이 사이드 이펙트를 가지는지 알려줄 수 있습니다.

Webpack과 Rollup에서는 sideEffects 필드를 지원하지만 처리 방식이 조금 다릅니다.

Webpack은 package.json의 sideEffects 필드를 주요 설정 지점으로 사용합니다. 이 필드를 false로 설정하면 해당 패키지의 모든 모듈이 사이드 이펙트가 없다고 간주되어 트리 셰이킹이 가능해집니다. CSS 파일이나 폴리필과 같이 사이드 이펙트가 있는 특정 파일들은 배열로 명시할 수 있습니다.

// webpack.config.js
module.exports = {
  optimization: {
    sideEffects: true,  // package.json의 sideEffects 설정 사용
    usedExports: true   // 사용된 내보내기만 포함
  }
}

반면 Rollup은 더 적극적인 접근 방식을 취합니다. Rollup은 자체적인 정적 분석을 통해 코드의 사이드 이펙트를 판단하며, 이를 통해 더 공격적인 트리 셰이킹을 수행할 수 있습니다. r rollup.config.js에서 moduleSideEffects 옵션을 통해 이를 제어할 수 있습니다.

// rollup.config.js
export default {
  treeshake: {
    moduleSideEffects: false,  // 모든 모듈을 순수하다고 가정
    // 또는
    moduleSideEffects: id => {
      return /.css$/.test(id) || /polyfills.js$/.test(id);
    }
  }
}

Rollup의 이러한 접근은 더 작은 번들 크기를 달성할 수 있지만, 때로는 필요한 코드가 실수로 제거될 위험이 있습니다. 특히 간접적인 사이드 이펙트가 있는 코드의 경우 주의가 필요합니다.

Lodash와 Lodash-es

Lodash는 자바스크립트의 대표적인 유틸리티 라이브러리지만, 번들 크기 최적화 관점에서 일반 Lodash와 Lodash-es는 큰 차이를 보입니다.

일반 Lodash는 CommonJS 모듈 시스템을 사용합니다. CJS 기반의 Lodash를 import할 때는 전체 라이브러리가 번들에 포함되는 문제가 발생할 수 있습니다.

// 일반 Lodash 사용
const _ = require('lodash');
// 또는
import _ from 'lodash';

// 전체 라이브러리가 번들에 포함됨 (약 500KB)
_.map([1, 2, 3], n => n * 2)

이를 개선하기 위해 개별 함수를 import하는 방법이 있습니다.

// 개별 import 방식
import map from 'lodash/map';
// 하지만 이것도 완벽한 해결책은 아님

반면 Lodash-es는 ES 모듈 시스템을 사용하도록 다시 작성된 버전입니다. ESM의 정적 분석 가능성 덕분에 효과적인 트리 셰이킹이 가능합니다.

// Lodash-es 사용
import { map } from 'lodash-es';

// 트리 셰이킹 가능 (약 10KB)
map([1, 2, 3], n => n * 2);

실험을 통한 비교

lodash와 lodash-es의 차이를 간단한 실험을 통해 비교해 보겠습니다. 테스트를 위해 webpack을 설치하고 번들링을 시도합니다. webpack-bundle-analyzer를 사용하면 webpack에서 번들링 결과를 한 눈에 확인할 수 있습니다.

우선 lodash를 설치하고 간단한 코드를 작성해 보겠습니다.

const _ = require("lodash");
const result = _.map([1, 2, 3], (n) => n * 2);
console.log("Lodash Result:", result);

그리고 lodash-es를 설치하고 같은 코드를 작성해 보겠습니다.

import { map } from "lodash-es";
const result = map([1, 2, 3], (n) => n * 2);
console.log("Lodash-es Result:", result);
lodash-bundling

위 사진처럼

lodash의 실제 크기:

  • 543.bundle.js (68.8 KiB)
  • lodash.bundle.js (1.25 KiB)
  • 총 70.1 KiB

lodash-es의 실제 크기:

  • 139.bundle.js (14 KiB)
  • lodashEs.bundle.js (1.11 KiB)
  • 총 15.2 KiB

로 약 55KiB 정도 차이가 납니다.

그렇다면 lodash/map을 임포트 해오는 방식은 어떨까요?

lodash-map-bundling

lodash 개별 import:

  • 378.bundle.js (18.6 KiB)
  • lodash.bundle.js (1.24 KiB)
  • 총 19.8 KiB

lodash-es:

  • 139.bundle.js (14 KiB)
  • lodashEs.bundle.js (1.12 KiB)
  • 총 15.2 KiB

개별 import 방식을 사용하더라도 lodash-es가 여전히 더 작은 번들 크기를 보여줍니다. 이는 앞서 말한 것처럼 ESM의 구조적 장점 때문입니다.

이러한 실험 결과는 트리 셰이킹이 번들 크기 최적화에 실제로 큰 영향을 미친다는 것을 보여줍니다. 특히 ESM을 사용하는 lodash-es가 CJS 기반의 lodash보다 더 효과적인 트리 셰이킹을 가능하게 하며, 이는 최종 번들 크기의 감소로 이어집니다.

따라서 최신 웹 프로젝트에서는 가능한 ESM 기반 라이브러리를 사용하는 것이 번들 최적화에 유리합니다.

✏️ 출처

https://developer.mozilla.org/en-US/docs/Glossary/Tree_shaking
https://webpack.js.org/guides/tree-shaking/
https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/