이 글은 아래 원문을 번역한 글로 의역이 있을 수 있습니다. 정확한 의미를 파악하고 싶으신 분은 원문을 참고해주시기 바랍니다.
원문: https://www.smashingmagazine.com/2021/05/tree-shaking-reference-guide/
저작권 정보: https://www.smashingmagazine.com/
※ 글의 원작자인 Átila Fassina 로부터 smashingmagazine의 글은 출처를 밝히면 번역해도 된다는 허가를 받았습니다.
번역에 들어가기 전에...
Tree-shaking 을 대체할 한국말을 찾지 못하여서 그대로 tree-shaking으로 표현하였습니다.
나무를 흔드는 것을 표현한 단어라고 이해하시면 될 것 같습다.
빠른 요약 (Quick Summary) ↬ "Tree-shaking" 은 javascript 번들링 시 반드시 거쳐야 하는 성능 최적화입니다.
본 글에서, 우리는 Tree-shaking 이 어떻게 작동하는지와 번들이 가벼워지고 더 좋은 성능을 낼 수 있도록 스펙들과 practice 가 어떻게 얽혀있는지 깊이 있게 살펴볼 겁니다.
추가적으로, 당신이 프로젝트에서 사용할 tree-shaking 체크리스트도 얻게 될 거예요.
Tree-Shaking 이 무엇인지 배우고, 그것을 성공시키기 위해 준비를 어떻게 하는지에 관한 여정을 시작하기 전에 우리는 어떤 모듈들이 Javascript의 생태계 안에 있는지 이해할 필요가 있습니다.
초창기부터 쭉, Javascript 프로그램들은 점점 복잡해지고 해야 하는 작업의 수가 늘어나고 있습니다.
그런 작업들은 따로 영역을 나눠 구분해야 할 필요성이 있다는 것은 명백해졌습니다.
이렇게 구분해야 할 작업들 또는 value 들을 우리는 모듈(module)이라고 부릅니다.
모듈의 주된 목적은 반복되는 것을 방지하고 재사용성을 높이기 위함입니다.
그래서 아키텍처들은 그들의 value 들과 작업들을 노출시키고, 외부 value 들과 작업들을 사용할 수 있도록 그러한 특별한 종류의 범위(modules)를 허용하도록 고안되었습니다.
module 이 무엇이고 어떻게 동작하는지는 “ES Modules: A Cartoon Deep-Dive” 글을 읽어보는 것을 추천합니다. 하지만 tree-shaking과 모듈 사용의 뉘앙스를 이해하기엔 위 글로도 충분합니다.
Tree-Shaking 이 정확히 무엇을 의미하는 걸까요?
간단히 말하면, tree-shaking 은 닿지 않는(unrechable) 코드(죽은 코드라고도 알려진) 것을 제거하는 작업을 의미합니다.
webpack rsion 3의 문서에 기술된 것을 보면,
당신의 application을 나무라고 상상해보세요. 당신이 사용하고 있는 소스코드와 라이브러리들은 나무의 살아있는 초록색 잎들을 나타냅니다. 죽은 코드들은 가을이 되면서 죽어버린 나무의 갈색 잎들을 나타냅니다. 죽은 잎들을 제거하기 위해서는 나무를 흔들어서 그것들을 떨어뜨려야 합니다.
tree shaking이라는 용어는 Rollup team에 의해 프론트 엔드 커뮤니티에서 처음 대중화되었습니다.
하지만 다이나믹한 언어의 저자들은 훨씬 이전부터 이 문제와 씨름하고 있었습니다.
tree shaking 알고리즘의 아이디어는 적어도 1990 년대 초반으로 거슬러 올라갈 수 있습니다.
Javascript 세계에서는, tree-shaking 은 ES6 이전으로 알려져 있는, ES2015의 ECMAScript 모듈(ESM) 사양 이후부터 사용이 가능했습니다. 그 이후, tree-shaking 은 프로그램의 동작을 바꾸지 않고 결과물 크기를 줄여주기 때문에 대부분의 번들러에서 디폴트로 사용 가능해져 왔습니다.
이것의 주된 이유는 ESMs 가 본질적으로 정적이기 때문입니다. 이게 무엇을 뜻하는지 살펴봅시다.
ES Module VS Common JS
CommonJS는 ESM 규격보다 몇 년 더 앞서 나왔습니다. 그것은 javascript 생태계에서 재사용성이 있는 모듈 지원의 부족에 대한 문제를 해결하려 했었습니다. CommonJS 는 경로 기반으로 제공되는 외부 모듈을 가져오는 require()
함수를 가지고 있습니다. 그리고 그것은 런타임 동안 스코프에 추가합니다.
require는 다른 프로그램의 함수와 마찬가지로 , 컴파일 타임에 결과를 예측하기 힘들게 만드는 함수(function)
입니다. 더한 것은, require
를 코드 안 어디에서나 호출하는 것이 가능하다는 사실입니다 - 다른 함수 호출 문 내부 , if/else 문, switch 문 등등
CommonJS 아키텍처의 넓은 허용으로 인해 습득에 어려움을 겪으면서, ESM 규격이 새로운 아키텍처로 정착했습니다.
ESM 은 module 들을 import
와 export
라는 키워드로 각각 가져오고 노출시킬 수 있습니다. 이로 인해, 더 이상의 함수적인(functional) 콜들은 없습니다. ESM 은 또한 오직 최상위에서의(top-level) 선언만 허용합니다. - 정적(static)인 상태이므로, 다른 구조에 중첩되는 것은 불가능합니다. ESMs 은 런타임 실행에 의존하지 않습니다.
스코프와 사이드 이펙트들 (Scope And Side Effects)
하지만 tree-shaking을 잘 쓰기 위해 반드시 극복해야 할 것(evade bloat)이 있습니다. 바로 사이트 이펙트들입니다.
함수가 실행 범위의 외부 요소들을 변경하거나 의존할 때 사이드 이펙트를 가지는 것으로 여겨집니다.
사이드 이펙트를 가진 함수는 비순수(impure) 한 것으로 여겨집니다.
순수(pure) 한 함수는 함수가 실행되고 있는 환경이나 컨텍스트에 상관없이 항상 똑같은 결과를 냅니다.
const pure = (a:number, b:number) => a + b
const impure = (c:number) => window.foo.number + c
번들러는 모듈이 순수 한지 결정하기 위해 주어진 코드를 평가하는 것을 목적으로 둡니다.
그러나 컴파일 시간이나 번들링 시간 동안의 코드 평가는 멀리까지 진행될 수 있습니다.
따라서, 사이드 이펙트를 가진 패키지는 완전히 도달할 수 없어도(unreachable) 제대로 제거될 수 없다고 가정합니다.
이것 때문에 번들러는 이제 모듈의 package.json
파일 내부에 개발자가 사이드 이펙트를 가지고 있지 않다고 선언해 놓은 키를 참고(accept)합니다. 이렇게 하면 개발자는 코드 평가를 중단하고 번들러에게 힌트를 줄 수 있습니다. ⇒ 특정 패키지 내에 있는 코드로 도달할 수 있는 import 나 require
문이 없으면, 제거될 수 있습니다.
이를 통해 번들을 간소화할 뿐만 아니라 컴파일 시간도 단축시킬 수 있습니다.
{
"name": "my-package",
"sideEffects": false
}
그러니, 당신이 패키지 개발자인 경우, 패키지를 내보내기 전에 sideEffects
를 사용하도록 해야 합니다. 물론, 매번 릴리즈 할 때마다 예기치 않은 변화를 피하기 위해 그것을 수정하세요.
루트 sideEffects
키 외에도, 메서드 호출에 인라인 주석인 /*@__PURE__*/
을 달아 파일별로 순수한(pure) 여부를 확인할 수 있습니다.
const x = */@__PURE__*/eliminated_if_not_called()
나는 이 인라인 주석을 사용자(consumer) 개발자를 위한 탈출구라고 생각합니다. 패키지 내에 sideEffects: false
가 선언되어 있지 않거나 라이브러리가 특정 메서드에 실제로 사이드 이펙트를 나타내는 경우에 한해서요.
웹팩 최적화 (Optimizing Webpack)
버전 4 이후, Webpack은 모범적인(best practice) 동작을 얻기 위해 점진적으로 설정이 더 적게 필요하게 되었습니다. 몇 개의 플러그인에 대한 기능이 코어에 통합되었습니다. 그리고 개발 팀은 번들 크기를 매우 중요하게 생각하기 때문에 tree-shaking을 쉽게 만들 수 있게 되었습니다.
땜장이(tinkerer) (역자주: 뭐라고 번역해야 할지 모르겠습니다...)가 아니거나 당신의 애플리케이션에 특별한 경우들이 없다면, tree-shaking에 의존하는 것은 딱 한 줄의 문제 일 뿐입니다.
webpack.config.js
파일에는 mode
라는 루트 프로퍼티가 있습니다.
이 프로퍼티의 값이 production
일 때, webpack 은 tree-shakeing을 하고 당신의 모듈들을 완전히 최적화합니다.
TerserPlugin
으로 비활성 코드를 제거하는 것 외에도 mode : 'production'
은 모듈 및 청크에 대해 결정적으로 뒤죽박죽 된 이름을 활성화하며 다음 플러그인들을 활성화합니다.
- flag dependency usage,
- flag included chunks,
- module concatenation,
- no emit on errors.
트리거 값이 production
인 것이 우연은 아닙니다.
당신은 개발환경에서도 종속성이 완전히 최적화되는 것을 원하지는 않을 것입니다. 왜냐면 그것은 디버깅하기 어려운 문제를 만들기 때문입니다. 그래서 저는 두 가지 접근 방법 중에 하나를 시도해볼 것을 제안하고 싶습니다.
한 가지 방법은, mode
플래그를 Webpack 커맨드 라인 인터페이스에 전달하는 것입니다.
# This will override the setting in your webpack.config.js
webpack --mode=production
또 다른 방법은, webpack.config.js
내에서 process.env.NODE_ENV
를 사용하는 것입니다.
mode: process.env.NODE_ENV === 'production' ? 'production' : development
이 경우에는, 반드시 배포 파이프라인에 --NODE_ENV=production
를 넘겨주는 것을 기억해야 합니다.
두 가지 접근 방식 모두 웹 팩 3 버전 이하에서 잘 알려진 definePlugin
위에 추상화된 것입니다. 어떤 옵션을 선택하던지 차이는 전혀 없습니다.
Webpack Version 3과 그 이하 (Webpack Version 3 And Below)
이번 섹션의 시나리오와 예는 최신 버전의 웹 팩 및 기타 번들러에는 적용되지 않을 수 있습니다.
이 섹션에서는 Terser 대신, UglifyJS version 2 사용을 고려합니다. UglifyJS는 Terser 가 포크 한 패키지입니다. 그래서 코드 평가(code evaluation)하는 법이 서로 다를 수 있습니다.
웹 팩 버전 3 이하에서는 package.json
의 sideEffects
속성을 지원하지 않기 때문입니다. 코드를 제거하기 전에 모든 패키지를 완전히 평가해야 합니다. 이것만으로도 접근방식의 효율성이 떨어지지만, 몇 가지 주의사항도 고려해야 합니다.
위에서 언급했듯이, 컴파일러는 패키지가 글로벌 스코프를 조작하는지를 스스로 확인할 방법이 없습니다. 하지만 그것이 tree-shaking을 생략하는 유일한 상황은 아닙니다 더 애매한 시나리오가 있습니다.
Webpack 문서에 있는 이 package 예시를 봅시다.
// transform.js
import * as mylib from 'mylib';
export const someVar = mylib.transform({
// ...
});
export const someOtherVar = mylib.transform({
// ...
});
그리고 여기 consumer 번들에서의 entry point 지점입니다.
// index.js
import { someVar } from './transforms.js';
// Use `someVar`...
mylib.transform 이 side effects를 발생시키는지 확인할 방법이 없습니다. 따라서 코드가 제거되지 않습니다.
유사한 결과를 가진 다른 상황은 다음과 같습니다.
- 컴파일러가 검사할 수 없는 써드 파티 모듈의 함수 호출
- 써드 파티 모듈로부터 import 된 함수를 다시 export 하는 것.
컴파일러가 tree-shaking 하는데 도움이 될 수 있는 도구는 babel-plugin-transform-imports입니다. 그러면 모든 멤버와 명명된 내보내기가 기본 내보내기로 분할되어 모듈을 개별적으로 평가할 수 있습니다.
// before transformation
import { Row, Grid as MyGrid } from 'react-bootstrap';
import { merge } from 'lodash';
// after transformation
import Row from 'react-bootstrap/lib/Row';
import MyGrid from 'react-bootstrap/lib/Grid';
import merge from 'lodash/merge';
또한 문제 있는 import 문을 피하도록 개발자에게 경고하는 설정 속성도 있습니다. 웹 팩 버전 3 이상에서 기본 설정으로 실사를 마치고 권장 플러그인을 추가했지만 번들이 여전히 부풀어 있는 것 같다면 이 패키지를 시도해 볼 것을 권장합니다.
스코프 호이스팅과 컴파일 시간 (Scope Hoisting And Compile Times)
CommonJS의 시대에서, 대부분의 번들러들은 단순히 각 모듈을 다른 함수 선언 안으로 감싸서 객체 안에 매핑합니다. 이는 다른 맵 객체(map object)와 다르지 않습니다.
(function (modulesMap, entry) {
// provided CommonJS runtime
})({
"index.js": function (require, module, exports) {
let { foo } = require('./foo.js')
foo.doStuff()
},
"foo.js": function(require, module, exports) {
module.exports.foo = {
doStuff: () => { console.log('I am foo') }
}
}
}, "index.js")
정적으로 분석하기 어렵다는 점 외에도, 근본적으로 ESM 과 호환되지 않습니다. import
와 export
문을 감쌀(wrap) 수 없기 때문입니다. 그래서 오늘날 번들러는 모든 모듈을 최상위로 호이스트(hoist) 합니다.
// moduleA.js
let $moduleA$export$doStuff = () => ({
doStuff: () => {}
})
// index.js
$moduleA$export$doStuff()
이 접근 방식은 ESM과 완벽하게 호환되며, 코드 평가를 통해 호출되지 않는 모듈을 쉽게 찾아 삭제할 수 있습니다. 이 접근법의 주의사항은 컴파일하는 동안 모든 문(statement)에 접촉하고 프로세스 중에 번들을 메모리에 저장하기 때문에 훨씬 더 많은 시간이 걸린다는 것입니다. 그렇기 때문에 번들링 성능이 모든 사람에게 더욱 큰 관심사가 되고 컴파일된 언어가 웹 개발 툴에 활용되고 있는 것입니다. 예를 들어, esbuild는 Go로 작성된 번들러이고, SWC는 Rust로 작성된 TypeScript 컴파일러이며, Spark와 통합됩니다. (Spark 또한 Rust로 작성된 번들러입니다.)
스코프 호이스트를 보다 잘 이해하기 위해 Parcel 버전 2의 문서를 적극 추천합니다.
이른 트랜스파일링 방지 (Avoid Premature Transpiling)
불행하게도 tree-shaking을 하는데 꽤 흔하고 치명적일 수 있는 문제가 있습니다.
짧게 말해, 다른 컴파일러를 번들에 통합하면서 특수 로더를 사용할 때 발생합니다. 흔한 조합은 TypeScript, Babel 그리고 Webpack이며, 이 안에서 가능한 모든 경우입니다.
Babel과 TypeScript 모두 자체 컴파일러를 가지고 있으며, 각각의 로더는 개발자가 쉽게 통합할 수 있도록 지원합니다. 거기에 숨겨진 위협이 있습니다.
이러한 컴파일러는 코드 최적화 전에 사용자의 코드에 도달합니다. 또한 이러한 컴파일러는 기본적으로 또는 잘못된 구성에 관계없이 CommonJS 모듈을 결과(output)로 내는 경우가 많습니다. ESMs 대신에요. 이전 섹션에서 언급했듯이 CommonJS 모듈은 동적이므로 죽은 코드(dead-code) 제거 여부를 제대로 평가할 수 없습니다.
이러한 시나리오는 "isomorphic" 앱(ex. 서버 사이드와 클라이언트 사이드에서 모두 동일한 코드를 실행하는 앱)이 증가하면서 오늘날 더욱 보편화되고 있습니다. Node.js는 ESM에 대한 표준 지원이 아직 없기 때문에, 컴파일러는 node
환경을 대상으로 할 때 CommonJS를 결과(output)로 냅니다.
따라서 최적화 알고리즘이 수신하는 코드를 확인하십시오.
Tree-Shaking 체크리스트 (Tree-Shaking Checklist)
이제 번들링과 tree-shaking의 동작 방법에 대한 자세한 내용을 알았으니, 현재 구현과 코드 기반을 다시 살펴볼 때 편리하게 볼 수 있는 체크리스트를 그려보겠습니다. 이를 통해 시간을 절약하고 코드의 인식 성능뿐만 아니라 파이프라인의 빌드 시간도 최적화할 수 있기를 바랍니다.
- ESM을 사용하세요. 그리고 자체 코드 베이스뿐만 아니라 ESM을 소모품으로 출력하는 패키지를 선호하세요.
- 당신의 의존성(depndencies) 중에
sideEffects
를 선언하지 않은 속성이 있는지 또는sideEffects
를true
로 설정하지 않은 속성이 있는지 확인하세요. - 사이드 이펙트가 있는 패키지를 사용할 때, 인라인 주석을 사용해서 순수(pure) 한 메소드 호출을 선언하세요.
- CommonJS 모듈로 결과로 낸다면, import와 export 문들을 변환하기 전에 번들을 최적화해야 합니다.
패키지 제작 (Package Authoring)
ESM이 JavaScript 생태계에서 발전하는 길이라는 데 모두가 동의하길 바랍니다. 그러나 소프트웨어 개발에서 항상 그렇듯이 전환은 까다로울 수 있습니다. 다행히도 패키지 작성자는 중단 없는 방법을 채택하여 사용자를 위한 신속하고 원활한 마이그레이션을 지원할 수 있습니다.
package.json
에 추가해야 할 작은 사항들이 있습니다. 패키지는 번들러에게 패키지가 지원하는 환경과 가장 잘 지원되는 방식을 알려줄 수 있습니다. 다음은 Skypack의 체크리스트입니다.
- ESM export를 include 하자
"type" : "module"
를 추가하자"module": "./path/entry.js"
를 통해 entry point를 가리키자 (community convention)
다음은 모든 모범 사례(best practice)를 준수하고 web 및 Node.js 환경을 모두 지원하려는 경우의 예입니다.
{
// ...
"main": "./index-cjs.js",
"module": "./index-esm.js",
"exports": {
"require": "./index-cjs.js",
"import": "./index-esm.js"
}
// ...
}
이와 함께 Skypack 팀은 패키지 품질 점수를 벤치마킹으로 도입해 주어진 패키지가 장수 및 모범 사례에 맞게 설정되는지 여부를 판단하고 있습니다. 이 도구는 GitHub에 오픈 소스화 되며 당신의 패키지에 devDependency
로 추가되어 각 릴리즈 전에 쉽게 검사를 수행할 수 있습니다.
마무리하며 (Wrapping Up)
이 글이 유용했길 바랍니다. 만약 그렇다면, 이 글을 당신의 네트워크에 공유하는 것도 고려해주세요. 저는 댓글이나 트위터로 많은 소통을 하고 싶습니다.
유용한 자료들 (Useful Resources)
Article과 Documentation
- “ES Modules: A Cartoon Deep-Dive”, Lin Clark, Mozilla Hacks
- “Tree Shaking”, Webpack
- “Configuration”, Webpack
- “Optimization”, Webpack
- “Scope Hoisting”, Parcel version 2’s documentation
Projects와 Tools
'성능' 카테고리의 다른 글
Towards an animation smoothness metric (한글) (2) | 2022.09.21 |
---|---|
The Complete Guide to Lazy Loading Images - 1 (한글) (0) | 2022.05.18 |
Demistifying webpack's 'import' function: using dynamic arguments (한글) (1) | 2022.02.23 |
Introducing the Memory Inspector(한글) (0) | 2021.07.21 |
Trash talk: the Orinoco garbage collector(한글) (0) | 2021.07.14 |