About Tree Shaking

강은비·2025년 1월 23일
post-thumbnail

최근 동아리 프로젝트에서 번들 사이즈 최적화 작업을 진행하며, Tree shaking을 활용해 페이지마다 번들 사이즈를 최소 20%에서 최대 60%까지 줄일 수 있었다. 😲

Tree shaking의 개념과 동작 원리, 그리고 실제 적용 방법을 사례와 함께 알아보자.

BeforeAfter

Tree shaking의 동작원리를 깊게 알아보는 것보다 Tree shaking 최적화를 이용한 문제 해결 과정에 중점을 두고 작성되었습니다.

Tree Shaking이란?

MDN 문서에 따르면, 트리 쉐이킹이란 자바스크립트 컨텍스트에서 사용되지 않은 코드를 제거하는 것을 말한다.

트리 쉐이킹은 주로 ESModule 기반으로 동작하며, Webpack이나 Rollup 같은 모듈 번들러를 사용해 사용하지 않는 코드를 자동으로 제거할 수 있다.

Tree shaking의 목적은 자바스크립트 번들 사이즈를 줄이고 로드 시간을 단축해 사용자 경험을 향상시키는 것이다.

동작원리

번들러마다 트리 쉐이킹 세부 로직은 다르지만 기본적으로 다음 과정을 거쳐 동작한다.

  1. 의존성 파악: Entry point에서 시작하여 모듈 간의 의존 관계를 분석한다.
  2. 사용하지 않는 코드 식별: 정적 분석을 통해 사용되지 않는 코드를 식별한다. 정적 분석은 코드를 실행하지 않고, 코드의 구조를 기반으로 분석하는 기법을 말한다.
  3. 코드 제거: 불필요한 코드를 최종 번들에서 제거한다.

핵심은 "정적분석"이다. 정적 분석이 가능한 구조에서 트리 쉐이킹을 더 잘 지원할 수 있다. 따라서 ESM의 정적 가져오기/내보내기 구조가 Tree shaking에 적합하다.

ESM vs CommonJS

ESM의 가져오기/내보내기는 정적인 반면, CJS의 가져오기/내보내기는 동적일 수 있다.

CommonJS

if (someCondition) {
    const { userAccount } = require("./userAccount");
    module.exports = /* ... */;
}

CommonJS는 동적 가져오기를 지원하기 때문에 번들러가 빌드 시점에 모듈의 사용 여부를 예측하기 어렵다. 따라서 사용되지 않는 코드도 번들에 포함된다.

ESM

import util from `./utils/${utilName}.js`; // 불가능

import { add } from "./utils/math.js"; // 가능

function foo() {
  export const value = "foo"; // 불가능
}

export const value = "foo"; // 가능

ESM은 정적인 가져오기/내보내기 방식을 사용하므로, Tree shaking이 훨씬 용이하다.

Tree Shaking 활용

Tree Shaking을 효과적으로 활용하려면 적합한 환경을 구축해야 한다.

ESM 사용하기

트리쉐이킹은 정적 분석을 기반으로 동작하기 때문에 CJS 보다는 ESM을 사용하는 것이 적합하다.

모듈 시스템 결정

JS 파일의 모듈 시스템은 package.json의 type 필드 또는 확장자에 따라 결정된다.

File Extensiontype fieldModule System
.jscommonjs (default)CJS
.jsmoduleESM
.cjs-CJS
.mjs-ESM

타입스크립트에서는 tsconfig.jsonmoduleResolution 옵션을 nodenext 또는 node16으로 설정하면 아래 규칙이 적용된다:

File ExtensionEmitted File ExtensionEmitted Module System
.mts.mjsESM
.cts.cjsCJS
.ts.jsDepends on type in package.json

라이브러리가 CommonJS 모듈을 사용하면 어떡하죠??

사례: Next Auth v4

Next Auth v4는 index.js를 CJS 모듈로 제공하며, ESM 환경과의 호환성을 위해 module 필드를 제공한다. 하지만 module 필드에 지정된 파일 역시 CJS 모듈이다. 즉, 해당 모듈은 ESM 환경에서 default/named import를 사용할 수 있도록 구성된 CJS 모듈이다.

{
  "name": "next-auth",
  "version": "4.24.11",
  "main": "index.js",
  "module": "index.js",
}

next-auth/index.js 코드를 보면 조건문에 따라 내보내는 항목이 달라질 수 있다. 이로 인해 번들러가 정적 분석을 통해 실제로 어떤 항목이 사용되는지 판단하기 어려워 Tree Shaking이 제대로 작동하지 않는다.

빌드 설정을 보면, babel-preset-env를 사용해 ES6 모듈을 CJS 모듈로 변환하고 있음을 확인할 수 있다.

"use strict";

var _next = _interopRequireWildcard(require("./next"));
Object.keys(_next).forEach(function (key) {
  if (key === "default" || key === "__esModule") return;
  if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
  if (key in exports && exports[key] === _next[key]) return;
  Object.defineProperty(exports, key, {
    enumerable: true,
    get: function () {
      return _next[key];
    }
  });
});

문제 해결하기

문제: CJS 모듈에서 내보내는 함수들이 클라이언트 번들에 포함된다. 실제로 번들된 코드를 봤을 때 getServerSession 함수 뿐만 아니라 NextAuth 함수 관련 코드도 있었다.

import { getServerSession } from 'next-auth' // ESM Named export를 지원하는 CJS
import { authOptions } from './auth/authOptions'
import { getClientSession } from './auth/getClientSession'

export const auth =
  typeof window === 'undefined'
    ? () => getServerSession(authOptions) // 서버 사이드에서만 실행되지만 클라이언트 번들에 포함됨
    : getClientSession

해결: ESM으로 내보내는 함수를 사용해 클라이언트 단에 불필요한 서버 코드를 제거했다.

export { getServerSession } from 'next-auth' // 정적 내보내기
import { authOptions } from './auth/authOptions'
import { getClientSession } from './auth/getClientSession'
import { getServerSession } from './auth/getServerSession' // ESM으로 대체

// 정적분석이 가능한 구조
export const auth =
  typeof window === 'undefined'
    ? () => getServerSession(authOptions) // 클라이언트 번들에서 제거됨
    : getClientSession

현재 next-auth beta 버전에서는 ESM을 사용해 Tree shaking이 가능합니다.

참고: Plugins for CJS

플러그인을 활용해 CJS 모듈에 트리 쉐이킹을 적용하도록 지원할 수 있다.

  • @rollup/plugin-commonjs: CJS 모듈을 ESModule로 변환한다.
  • webpack-common-shake: CJS 모듈을 위한 트리 쉐이킹 기능을 제공한다. 하지만, 트리 쉐이킹 할 수 없는 CommonJS 패턴이 존재한다.

CJS 모듈을 사용하는 라이브러리를 쓰는 경우 해결책은 다음과 같다.
1. Tree shaking이 가능한 다른 라이브러리를 사용한다.
2. CJS로 작성된 외부 모듈을 ESM으로 변환하는 플러그인을 사용한다.
3. 모듈을 ESM으로 감싼다. (극히 일부 문제에 대해서만 해결이 가능할 것 같다.)

package.json의 sideEffects

sideEffects 필드는 특정 모듈이 부수 효과(side effect)가 있는지 여부를 명시하는 데 사용된다. 주로 Webpack의 트리 쉐이킹 최적화를 위해 사용되며, 번들러에게 부수 효과가 없는 코드를 알려줘서 제거하도록 돕는 설정이다.

  • 부수 효과란, 프로그램에서 함수나 표현식이 외부/전역 상태를 변경하거나 영향을 미치는 것을 말한다. 예를 들어, 모듈 내에서 전역 변수를 수정한다면 부수효과가 있다고 본다.
  • false: 패키지 내의 모든 파일이 부수 효과가 없음을 나타낸다.
  • 파일 배열: 특정 파일이나 패턴만 부수 효과가 있음을 지정할 수 있다. 나머지 파일은 부수 효과가 없다고 간주되기 때문에 해당 파일에 대해서는 트리 쉐이킹이 적용된다.

Webpack은 사이드 이펙트가 존재한다는 전제로 모듈을 순회하며 사이드 이펙트 여부를 확인하지만 완벽하게 수행하기는 어렵다. 개발자만이 모듈의 사이드 이펙트 여부를 완벽하게 판단할 수 있기 때문에 sideEffects 필드를 명시해 트리 쉐이킹을 도울 수 있다.

순수함수를 지향하자!

사례: Barrel file과 Tree shaking

  • 상황: 하나의 컨텍스트에 종속적인 컴포넌트들을 barrel export하여 import문을 간결하게 수정했다.
  • 문제: Barrel export한 컴포넌트들 중 일부만 사용해도 그 외 컴포넌트들까지 JS 번들에 포함돼 번들 사이즈가 불필요하게 커지는 문제가 발생했다.
  • 해결: CSS 파일 외에는 부수효과가 없다는 판단 하에 package.jsonsideEffects 필드를 추가해 트리 쉐이킹을 최적화했다. CSS는 파일이 로드될 때 전역 스타일에 영향을 주기 때문에 사이드 이펙트로 간주된다.
"sideEffects": ["**/*.css"]

Tree shaking 최적화하면서 느낀점

라이브러리가 번들 크기에 미치는 영향은 당연한 이야기일 수 있지만, 이번 Tree shaking 최적화 작업을 통해 그 중요성을 느꼈다. 특히, 라이브러리가 사용하는 모듈 시스템과 빌드 및 패키지 설정이 최적화에 큰 영향을 미쳤다. 단순히 라이브러리를 사용하는 것을 넘어, 내부 구조와 모듈 시스템을 이해해야 한다는 점을 깨달았다.

0개의 댓글