Tree shaking은 현대적인 프론트엔드 번들링에서 매우 중요하고 필수적인 부분이 되었습니다. 다양한 번들러들이 적용 가능한 시나리오와 초점 영역이 다르기 때문에, tree shaking의 구현도 다양합니다. 예를 들어, webpack은 주로 프론트엔드 애플리케이션 번들링에 사용되며 정확성을 강조하고, tree shaking은 모듈 간 수준의 최적화에 초점을 맞춥니다. 반면 Rollup은 주로 라이브러리 번들링에 사용되며 최적화 효율을 우선시하여 AST 노드 수준의 granularity로 tree shaking을 수행하므로, 일반적으로 더 작은 번들 크기를 생성합니다. 그러나 특정 edgecases의 실행 정확성에 대한 보장이 부족할 수 있습니다.
본 글에서는 다양한 번들러의 tree shaking 원리와 그들 간의 차이점을 간략히 소개하겠습니다.
Tree shaking이란 무엇인가: 저희는 애플리케이션을 나무로 상상할 수 있습니다. 실제로 사용하는 소스 코드와 라이브러리는 나무의 녹색이고 살아있는 잎을 나타냅니다. 죽은 코드는 가을에 소비되는 나무의 갈색이고 죽은 잎을 나타냅니다. 죽은 잎을 제거하기 위해서는 나무를 흔들어서 떨어뜨려야 합니다. (tree shaking 참고)
webpack / Rspack의 Tree shaking
현재 Rspack (v1.4)은 webpack과 동일한 tree shaking 구현을 따르고 있으므로, 다음 소개에서는 webpack의 구현 방식을 예시로 사용하겠습니다. 동시에 저희는 Rspack의 향후 버전을 위해 더 효율적인 tree shaking 전략을 적극적으로 탐색하고 있습니다.
webpack의 tree shaking은 세 부분으로 구성됩니다:
모듈 수준: optimization.sideEffects 사용되지 않는 export가 없고 side effect가 없는 모듈을 제거합니다.
import "./module"; 어떤 export도 사용하지 않고 side effect가 없으므로, ./module을 안전하게 제거할 수 있습니다.
re-exports.js (barrel file)는 자체적으로 로컬 export가 없고 사용되고 있으며, side effect가 없으므로 re-exports.js를 안전하게 제거할 수 있습니다.
// index.js
import { a } from"./re-exports";
console.log(a);
// re-exports.js
export * from"./module"; // `index -(a)-> re-exports -(a)-> module` 최적화되어 `index -(a)-> module`로, re-exports.js 자체는 로컬 export가 없고 사용되고 있으며, side effect가 없으므로 re-exports.js를 안전하게 제거할 수 있습니다.
// module.js
exportconst a = 42;
export 수준: optimization.providedExports와 optimization.usedExports를 사용하여 사용되지 않는 export를 제거합니다.
optimization.providedExports는 모듈이 어떤 export를 가지고 있는지 분석합니다.
optimization.usedExports는 모듈의 어떤 export가 실제로 사용되는지 분석하고, 사용되지 않는 export는 코드 생성 중에 제거할 수 있습니다: export const a = 42 => const a = 42, 그 후 minifier(예: SWC 또는 Terser)는 변수가 모듈 내에서도 사용되지 않으면 남은 선언을 추가로 제거할 수 있습니다.
코드 수준: optimization.minimize SWC 또는 Terser와 같은 minifier를 사용하여 인라이닝 및 평가와 같은 기법을 통해 코드를 분석하고, 죽은 코드를 제거하며 번들 크기를 최소화하기 위해 압축을 수행합니다.
Minifier는 optimization.minimizer 플러그인을 통해 번들러와 통합되어 번들러의 출력에 대한 후처리를 수행하며, 이는 번들러의 핵심 책임 범위를 벗어납니다.
또한 중요한 부분은 정적 분석입니다. 모듈 수준과 export 수준의 최적화 모두 webpack이 코드에 대한 정적 분석을 수행하여 모듈이 side effect를 가지고 있는지, 어떤 export를 포함하고 있는지, 어떤 export가 실제로 사용되는지를 결정해야 합니다. 이 정보는 이러한 최적화 단계의 입력으로 사용됩니다.
이론적으로, webpack의 JavaScript parser가 정적으로 이 정보를 추출할 수 있는 한, tree shaking을 적용할 수 있습니다. 심지어 본질적으로 동적인 CommonJS와 동적 import에도 적용할 수 있습니다. 이러한 구성이 정적 패턴을 따르고 필요한 정보를 정적으로 추론할 수 있다면, tree shaking은 여전히 가능합니다.
그러나 현재 webpack은 CJS와 동적 import와 관련된 매우 제한된 시나리오에 대해서만 정적 분석을 수행합니다. 많은 분석 가능한 경우가 최적화되지 않은 상태로 남아 있어 개선의 여지가 상당합니다.
이 세 단계가 완료된 후, tree shaking은 이미 기능하지만 특정 경우에 여전히 문제가 있을 수 있습니다:
export a가 다른 모듈의 사용되지 않는 export g에 의해 참조되어 a가 tree-shaken되는 것을 방지합니다. 이 경우, optimization.innerGraph가 필요하여 lib.js의 최상위 문 간의 의존성 관계를 분석합니다. a를 포함하는 최상위 문이 사용될 때만 a가 사용됨으로 표시되고, 그렇지 않으면 사용되지 않음으로 간주됩니다.
// index.js
import { f } from"./lib";
f();
// lib.js
import { a } from"./module";
exportconstf = () => 42;
exportconstg = () => a; // `g`가 사용되지 않으므로, `const g = () => a`를 생성하면 `a`가 참조되어 `a`가 tree-shaken되는 것을 방지합니다.
// module.js
exportconst a = 42;
Minifier가 최적화할 수 있는 일부 경우는 webpack이 각 모듈을 함수로 래핑하기 때문에 최적화할 수 없습니다. 예를 들어, 다음의 경우:
최상위 문을 미리 분할하면 esbuild가 innerGraph 문제를 본질적으로 해결할 수 있습니다. 분할 후 각 최상위 문이 부분이 되어 부분 수준의 granularity로 분석 및 최적화가 가능하며, 이는 모듈 수준의 granularity로 작동하는 webpack과 다릅니다. 이 접근 방식은 다음과 같은 주요 이점을 제공합니다:
import/export된 변수뿐만 아니라 모듈 내의 변수도 분석하여 webpack이 innerGraph로 수행해야 하는 작업을 포함합니다.
번들러 최적화를 모듈 수준에서 모듈 내의 최상위 문(부분 수준)에 적용합니다. 예를 들어, esbuild의 side effect 최적화는 사용되지 않고 side effect가 없는 최상위 문을 제거할 수 있지만, webpack은 모듈 수준의 제거만 수행할 수 있습니다.
초기 esbuild 버전도 이러한 최상위 문이 코드 분할에 참여하도록 허용했으며, 이를 "모듈 분할"이라고 부릅니다. 그러나 esbuild는 webpack의 출력처럼 각 모듈을 함수로 래핑하지 않기 때문에 모듈 로딩과 실행을 분리하지 않아 최상위 await를 제대로 처리하기 어려웠습니다. 결과적으로 esbuild는 나중에 모듈 분할 지원을 중단했습니다.
모듈 분할은 실제로 webpack과 같이 모듈 로딩과 실행을 분리할 수 있는 번들러에 더 적합하며, 모듈 내의 변수 선언과 사용에 대한 추가 처리 없이 모듈 실행의 정확성을 본질적으로 보장합니다.
또한 모듈 내의 최상위 문에 더 많은 모듈 수준의 최적화를 적용할 수 있으므로, esbuild가 부족하거나 성능이 떨어지는 최적화(코드 분할, 런타임 최적화, 청크 분할 등)를 가능하게 하여 추가 최적화를 가능하게 합니다.
그렇다면 webpack과 같은 런타임(모듈 로딩과 실행을 분리)과 모듈 분할 지원을 결합한 번들러가 있을까요? 있습니다: Turbopack입니다.
먼저 Turbopack의 출력 형식이 청크 간에 변수 할당과 선언을 허용하지 않는 문제를 어떻게 해결하는지 esbuild의 번들링 결과 예시를 사용하여 살펴보겠습니다:
// PS: Turbopack의 관련 코드는 단순화되고 수정되었습니다. 현재 Turbopack은 청크 분할 결과를 세밀하게 제어할 수 있는 구성을 아직 제공하지 않습니다.
// entry1.js용 청크
module.exports = {
"entry1.js": (__turbopack_context__) => {
var _data_1__TURBOPACK_MODULE__ = __turbopack_context__.i("data.js <part 1>");
console.log(_data_1__TURBOPACK_MODULE__.data);
},
}
// entry2.js용 청크
module.exports = {
"entry2.js": (__turbopack_context__) => {
var _data_2__TURBOPACK_MODULE__ = __turbopack_context__.i("data.js <part 2>");
_data_2__TURBOPACK_MODULE__.setData(123);
},
"data.js <part 2>": (__turbopack_context__) => {
__turbopack_context__.s({
setData: () => setData,
});
var _data_1__TURBOPACK_MODULE__ = __turbopack_context__.i("data.js <part 1>");
functionsetData(value) {
_data_1__TURBOPACK_MODULE__.data = value; // <--- 이것이 setter를 트리거합니다.
}
},
}
// 공유 코드용 청크
module.exports = {
"data.js <part 1>": (__turbopack_context__) => {
__turbopack_context__.s({
data: [
() => data, // <--- getter
(new_data) => data = new_data, // <--- setter
]
});
let data;
},
}
Turbopack은 webpack과 유사한 출력 형식을 사용하여 각 모듈을 함수로 래핑하여 모듈 로딩과 실행을 분리합니다. 그러나 webpack과 달리, 모듈 export를 정의하는 런타임 __turbopack_context__.s는 export에 대한 getter뿐만 아니라 추가 setter도 제공합니다. 모듈의 다른 부분이 이러한 변수에 대한 할당 작업을 수행할 때, 해당 setter가 트리거되어 값을 업데이트하여 올바른 실행을 보장합니다.
최상위 await의 경우, webpack과 마찬가지로 Turbopack은 최상위 await를 포함하는 모듈과 그들의 import된 의존성의 올바른 실행 순서를 보장하기 위해 런타임을 사용합니다. 예를 들어, data.js의 첫 번째 줄에 await 1;을 추가한 후 번들된 출력은 다음과 같습니다:
await1; // <--- 추가된 `await 1;`은 __turbopack_context__.a 런타임으로 래핑되어 최상위 await가 올바르게 실행되도록 보장합니다.
__turbopack_async_result__();
} catch(e) { __turbopack_async_result__(e) }
}, true);
},
// ...
}
물론 모듈 분할도 단점이 있습니다. 분할 후 출력의 각 최상위 문은 함수로 래핑됩니다. 이는 올바른 실행을 보장하지만 이 래핑 접근 방식의 단점을 증폭시킵니다:
이러한 래퍼 함수의 과도한 중복으로 인해 번들 크기가 증가합니다(gzip이 이 오버헤드를 효과적으로 줄일 수 있음).
더 많은 변수가 _data_0__TURBOPACK_MODULE__.data와 같은 객체 속성을 통해 접근되어야 하므로 런타임 성능이 저하될 수 있습니다(이는 여전히 최신 브라우저 벤치마크가 필요함).
두 문제 모두 scope hoisting에 대한 더 큰 의존성이 필요합니다.
Rollup의 Tree shaking
webpack이 export 문과 모듈에 대해 tree shaking을 수행하고, esbuild가 최상위 문에 대해 수행한다면, Rollup은 모든 문과 심지어 더 세밀한 AST 노드에 대해 위에서 아래로 tree shaking을 수행하며, 더 정확한 side effect 감지를 합니다.
Rollup tree shaking diagram
Rollup은 문과 일부 더 세밀한 AST 노드에 대해 tree shaking을 수행합니다.
Rollup의 tree shaking은 esbuild의 것과 유사하지만 약간의 차이가 있습니다. 프로세스는 다음과 같이 작동합니다:
모듈에서 include()를 호출하여 시작합니다.
최상위 AST 노드에서 시작합니다:
a. AST 노드가 side effect를 가지고 있는지 결정합니다.
b. 그렇다면 include()를 호출합니다.
c. 관련 AST 노드에 대해 side effect 확인과 include()를 계속합니다(단계 a와 b 반복).
순회 후, 새로운 AST 노드가 include()되었는지 확인합니다. 그렇다면 새로운 순회를 트리거합니다(단계 1과 2).
단계 2.c에서 "관련 노드"는 다음을 의미합니다: 특정 노드의 자식 노드, 변수 사용에 해당하는 변수 선언 노드, obj.a.b에 접근할 때 obj의 선언과 속성 a 및 b에 대한 노드.
Rollup은 다음과 같은 이유로 다른 번들러에 비해 더 나은 tree shaking 결과를 달성하는 경우가 많습니다:
더 세밀한 분석: AST 노드 수준에서 코드를 분석하고 제거하는 반면, 다른 번들러는 일반적으로 최상위 문 수준 또는 더 거친 granularity로 작동하여 더 세밀한 dead code elimination(DCE)을 minifier에 맡깁니다.
더 정확한 side effect 분석: Side effect는 AST 노드 수준에서 문맥 인식으로 평가되는 반면, 다른 번들러는 더 거친 granularity의 문맥 독립적 side effect 분석을 사용합니다.
Rollup의 세밀한 접근 방식은 본질적으로 export 문과 최상위 문의 더 거친 분석을 포함합니다. 결과적으로 Rollup은 모듈 간 사용되지 않는 export를 제거할 수 있을 뿐만 아니라 일부 모듈 내 DCE도 처리할 수 있습니다. 다음 논의는 모듈 내 DCE에 초점을 맞추며, 이 두 가지 점을 설명하기 위해 구체적인 경우를 사용합니다.
Rollup의 Define 기능은 다른 번들러와 다르게 구현됩니다. rollup-plugin-replace는 transform 단계에서만 일치하는 Define 노드를 대체하는 반면, webpack과 esbuild는 parse 단계에서 대체를 수행하고 죽은 분기도 분석합니다. 죽은 분기의 코드는 분석 중에 건너뛰어지며, 죽은 분기의 의존성은 모듈 그래프에 포함되지 않습니다. 그러나 parse 단계에서의 이 죽은 분기 분석은 모듈을 넘어 확장될 수 없습니다.
Rollup의 tree shaking이 AST 노드 수준에서 작동하기 때문에, 함수 내의 문 노드도 tree shaking에 대해 분석할 수 있습니다. 따라서 Rollup은 분석의 일부를 tree shaking 단계에 위임하며, 죽은 분기의 제거도 tree shaking에 의존합니다.
이 예시에서, if (DEVELOPMENT)의 DEVELOPMENT 변수에 대해 컴파일 타임 평가를 수행하려고 시도하며, 그 결과는 상수이므로 else 분기를 죽은 분기로 제거할 수 있습니다. 또한 file.js의 DEVELOPMENT 변수가 사용됨으로 표시되지 않아, 최종 tree shaking이 export const DEVELOPMENT 선언과 file.js 모듈을 제거할 수 있습니다.
이 접근 방식의 단점은 죽은 분기에 도입된 의존성이 여전히 모듈 그래프에 추가되어 더 많은 모듈을 가져오고 Rollup이 더 많은 작업을 처리해야 하므로 성능이 저하된다는 것입니다.
장점은 모듈 간 분석과 죽은 분기 제거를 가능하게 하여 더 많은 사용되지 않는 코드를 제거하고 더 작은 출력 번들을 생성한다는 것입니다. 다른 번들러는 모듈을 단일 범위로 병합하기 위해 scope hoisting에 의존하고 minifier가 이러한 모듈 간 죽은 분기를 제거하도록 의존합니다. 그러나 올바른 실행을 보장하기 위해 scope hoisting을 중단해야 하는 모듈의 경우, 이러한 모듈을 최적화할 좋은 솔루션이 없습니다. 향후 최적화가 이를 해결해야 할 수 있습니다.
console.log(obj.a.ab)를 분석할 때, Rollup은 side effect로 인해 이 문을 include()로 표시합니다. include(obj.a.ab) 중에, 관련 노드의 include()를 트리거하며, obj 선언 노드, a: 속성 노드, ab: 속성 노드를 포함합니다. AST 노드 수준의 tree shaking 덕분에 Rollup은 사용된 a:와 ab: 속성만 유지하면서 다른 사용되지 않는 속성을 제거하여 더 작은 출력 번들을 생성할 수 있습니다.
Rollup determines side effects based on reassignment
이 경우, a = {}를 주석 처리하면 모든 코드가 tree-shaken되는 것을 알 수 있습니다. 그러나 주석을 해제하면 tree shaking이 더 이상 발생하지 않습니다. 이는 Rollup이 변수 a가 재할당되는지 여부를 기반으로 a.b = 3이 side effect를 가지고 있는지 결정하기 때문입니다. 이는 Rollup이 일부 문맥 인식 side effect 분석을 수행할 수 있음을 보여주지만, 문맥 독립적 side effect 분석에 비해 특정 성능 오버헤드가 있습니다.
즉, 이 문맥 인식 side effect 분석은 상대적으로 간단하며 일반적으로 특정하고 직관적인 시나리오에서만 작동합니다. 예를 들어, 위의 경우 재할당 발생 여부만 확인하지만 재할당이 실제로 의미 있는 변경을 일으키는지는 분석하지 않습니다. 즉, 지나치게 깊거나 상세한 분석을 피합니다.
Rollup side effect analysis
동일한 변수가 실제 변경 없이 재할당되더라도 a.b = 3은 여전히 side effect를 가진 것으로 간주됩니다.
Rollup v3은 문 수준의 tree shaking만 지원했지만, v4부터는 더 세밀한 AST 노드 수준의 tree shaking(위에서 언급한 객체 속성 tree shaking 등)과 특정 시나리오에서 사용되지 않는 노드 추적 최적화를 실험하기 시작했습니다. 예를 들어:
"상수 매개변수"를 기반으로 함수 내 죽은 분기 tree shaking. 이 기능은 함수 기본 매개변수 tree shaking에서 진화했습니다. 함수가 한 번만 호출되거나 동일한 변수를 사용하여 변경되지 않은 매개변수로 여러 번 호출될 때, 매개변수에서 사용된 변수를 분석하고 그에 따라 특정 최적화를 적용합니다.