트리 셰이킹
고친 사람 github-actions[bot]
트리 셰이킹은 웹 앱을 배포 파일로 묶을 때 어디서도 안 쓰는 코드를 빼는 일입니다. 앱이 불러 쓰는 함수만 남기고 나머지는 버립니다. 라이브러리를 가져다 써도 쓰는 조각만 브라우저로 실려 갑니다.
쉽고 빠른 이해
트리 셰이킹은 「가져다 놓고 안 쓰는 코드를 배포 파일에서 덜어 내기」입니다. 함수 백 개짜리 라이브러리에서 날짜 함수 하나만 쓰면 그 하나만 남깁니다.
웹 앱의 코드는 사용자가 페이지를 열 때마다 브라우저로 내려갑니다. 안 쓰는 함수 아흔아홉 개도 덜어 내지 않으면 같이 내려갑니다. 그만큼 화면이 늦게 뜹니다.
도는 순서는 이렇습니다.
- 앱이 처음 실행하는 파일에서 출발합니다
- 그 파일이 가져다 쓰는 함수를 따라가며 쓰인 것에 표시합니다
- 표시가 안 붙은 함수를 결과 파일에서 뺍니다
대가도 있습니다. 불러오기만 해도 무슨 일을 하는 코드는 함부로 못 뺍니다. 도구는 확신하지 못하면 남기는 쪽을 고릅니다. 그래서 기대보다 덜 줄기도 합니다.
트리 셰이킹은 배포용 웹 앱 빌드에서 켭니다. 사용자에게 내려가지 않는 서버 코드에는 얻는 것이 적습니다.
상세
가을 나무를 흔드는 장면을 떠올려 보겠습니다. 가지를 잡고 흔들면 말라붙은 잎이 후드득 떨어집니다. 가지에 단단히 붙은 푸른 잎은 그대로 남습니다. 흔들고 나면 나무에는 살아 있는 잎만 매달려 있습니다. 이 장면이 왜 이 기법의 이름이 됐는지는 모듈 그래프를 본 뒤에 다시 봅니다.
안 쓰는 코드가 네트워크를 건너는 문제
서버 애플리케이션은 의존하는 라이브러리가 아무리 커도 서버 디스크에 놓일 뿐입니다. 자바 프로젝트가 라이브러리 하나에서 클래스 하나만 써도 사용자는 그 크기를 모릅니다. 사용자가 받는 것은 응답뿐입니다.
웹 앱은 다릅니다. 브라우저에서 도는 자바스크립트 코드는 사용자가 페이지를 열 때마다 서버에서 내려갑니다. 라이브러리 전체가 실려 가면 쓰지도 않는 코드를 받느라 기다립니다. 받은 뒤에도 브라우저는 그 코드를 읽고 해석하느라 시간을 씁니다.
그래서 배포 전에 코드를 정리하는 단계가 있습니다. 번들러는 앱을 이루는 여러 파일을 브라우저에 보낼 몇 개의 파일로 묶는 도구입니다. 묶여서 나온 파일을 번들이라고 부릅니다. 트리 셰이킹은 번들러가 묶는 길에 안 쓰는 코드를 덜어 내는 일입니다.
모듈과 쓰인 이름 표시
자바스크립트 코드는 모듈 단위로 나뉩니다. 모듈은 파일 하나입니다. 밖에 내놓을 이름은 export 로 적습니다. 다른 파일은 그 이름을 import 로 가져다 씁니다. 자바의 public 과 import 를 파일 단위로 하는 셈입니다.
덧셈과 뺄셈 함수를 내놓는 모듈 하나로 따라가 보겠습니다. math.js 는 함수 둘을 내놓습니다. 오른쪽 주석은 트리 셰이킹 뒤에 그 함수가 어떻게 되는지입니다.
// math.js
export function add(a, b) { // 남는다
return a + b;
}
export function sub(a, b) { // 빠진다
return a - b;
}
앱을 여는 파일 main.js 는 add 하나만 가져다 씁니다. 번들러가 맨 처음 읽기 시작하는 이런 파일을 진입점이라고 부릅니다.
// main.js
import { add } from './math.js';
console.log(add(1, 2)); // 3
번들러는 진입점에서 출발해 import 를 따라갑니다. main.js 가 add 를 가져가니 add 에 쓰였다는 표시를 붙입니다. sub 는 어느 파일도 가져가지 않으니 표시가 없습니다. 결과 번들에는 add 만 들어갑니다.
// bundle.js
function add(a, b) {
return a + b;
}
console.log(add(1, 2)); // 3
표시는 한 겹에서 멈추지 않습니다. add 가 안에서 util.js 라는 다른 모듈의 함수 round 를 부른다면 round 에도 표시가 붙습니다. 파일이 이렇게 서로 가져다 쓰는 관계를 그래프로 그린 것을 모듈 그래프라고 합니다. 번들러는 파일 사이의 관계를 따라가되, 표시는 파일 안의 이름 하나하나에 붙입니다.
아래 그림에서는 진입점부터 화살표가 닿는 이름만 남습니다.
flowchart TD
M["main.js · 진입점"] --> A["math.js 의 add · 남는다"]
A --> R["util.js 의 round · 남는다"]
subgraph U["어느 화살표도 닿지 않는 이름"]
S["math.js 의 sub · 빠진다"]
end
트리 셰이킹이라는 이름도 이 모습에서 왔습니다. 모듈 그래프를 나무로 보고 흔들면 어디에도 매달리지 않은 코드가 마른 잎처럼 떨어져 나갑니다.
출발점에서 닿는 것만 살리는 방식은 백엔드 개발자에게 낯설지 않습니다. 가비지 컬렉션도 루트에서 참조를 따라가 닿는 객체만 살리고 나머지 메모리를 거둡니다. 트리 셰이킹은 같은 생각을 메모리 대신 코드에 씁니다.
실행하지 않고 읽어 내는 문법
번들러는 코드를 실행하지 않습니다. 파일을 글로 읽기만 해서 무엇이 무엇을 쓰는지 알아내야 합니다. 이렇게 실행하지 않고 코드를 읽어 성질을 알아내는 일을 정적 분석이라고 합니다.
트리 셰이킹은 ES 모듈(ECMAScript Modules, 자바스크립트 표준이 정한 모듈 문법)에서 잘 듣습니다. ES 모듈의 import 와 export 는 파일 맨 위에 놓입니다. 가져오는 이름과 파일 경로도 글자로 박혀 있습니다. 그래서 코드를 읽기만 해도 누가 무엇을 가져가는지 다 보입니다.
그보다 먼저 Node.js 가 오래 써 온 모듈 방식은 CommonJS 입니다. CommonJS 는 require() 라는 함수를 불러 모듈을 가져옵니다. 함수 호출이라서 경로를 변수로 넘기거나 조건문 안에서 부를 수 있습니다.
아래 세 줄 가운데 읽기만 해서 무엇을 가져오는지 알 수 있는 것은 첫 줄뿐입니다.
import { add } from './math.js'; // 보인다
const m = require(name); // 모른다
if (debug) require('./log.js'); // 모른다
둘째 줄은 name 에 무엇이 들지 실행해 봐야 압니다. 셋째 줄은 debug 가 참인지에 따라 가져오기도 하고 안 가져오기도 합니다. 번들러는 이런 코드를 만나면 안전하게 전부 남깁니다. CommonJS 로 짠 라이브러리가 트리 셰이킹으로 잘 안 줄어드는 까닭입니다.
부수 효과가 막는 경우
어떤 모듈은 불러오기만 해도 무슨 일을 합니다. 전역 객체에 함수를 덧붙이거나, 화면에 스타일을 입히거나, 설정 값을 바꿉니다. 이렇게 값을 돌려주는 것 말고 바깥 상태를 바꾸는 일을 부수 효과라고 합니다.
아래 모듈은 모든 배열에 last 라는 메서드를 덧붙입니다. 내놓는 이름이 하나도 없습니다.
// last.js
Array.prototype.last = function () {
return this[this.length - 1];
};
// main.js
import './last.js'; // 가져가는 이름 없음
[1, 2, 3].last(); // 3
main.js 는 last.js 에서 이름을 하나도 가져가지 않습니다. 이름 표시만 보면 last.js 는 통째로 빼도 될 것 같습니다. 하지만 빼면 last() 를 부르는 줄이 실패합니다. 그래서 번들러는 쓰인 이름이 없는 모듈도 부수 효과가 있을 수 있으면 남깁니다.
번들러가 모듈 하나를 두고 내리는 판단은 아래와 같습니다. 문제는 둘째 물음입니다. 정적 분석으로는 부수 효과가 없다고 확신하기 어려운 코드가 많습니다.
flowchart TD
Q1{"이 모듈에서 쓰인 이름이 있나"}
Q1 -->|있다| K1["쓰인 이름을 남긴다"]
Q1 -->|없다| Q2{"불러오기만 해도 하는 일이 있나"}
Q2 -->|있다 또는 모르겠다| K2["모듈 코드를 남긴다"]
Q2 -->|없다고 확신한다| D["모듈을 통째로 뺀다"]
이 빈틈을 사람이 메워 줄 수 있습니다. 라이브러리 작성자는 패키지 설명 파일인 package.json 의 sideEffects 항목에 「이 패키지는 불러오기만으로 하는 일이 없다」고 적어 둡니다. 번들러는 이 표시를 믿고 쓰인 이름이 없는 모듈을 뺍니다. 표시를 잘못 달면 필요한 코드까지 빠져서 배포한 뒤에야 오류가 납니다.
데드 코드 제거와 나누는 일
데드 코드 제거는 실행될 일이 없는 코드를 지우는 일입니다. if (false) { ... } 안의 코드나 return 뒤에 적힌 줄이 그런 코드입니다. 이름이 비슷해서 트리 셰이킹과 자주 섞어 부릅니다.
둘은 보는 범위가 다릅니다. 트리 셰이킹은 모듈 사이를 봅니다. 진입점에서 출발해 다른 파일이 가져다 쓰는 이름을 모읍니다. 데드 코드 제거는 주로 한 함수 안을 봅니다. 실행이 닿을 수 없는 줄을 찾아 지웁니다.
미니파이는 코드에서 빈칸과 주석을 지우고 이름을 짧게 바꿔 파일 크기를 줄이는 일입니다. 번들러는 트리 셰이킹으로 쓰는 모듈과 함수를 먼저 고릅니다. 그다음 남은 코드에 미니파이를 겁니다. 이때 데드 코드 제거를 함께 돌리는 것이 흔합니다.
같은 생각은 웹 밖에도 있습니다. Dart 컴파일러는 앱을 빌드할 때 안 쓰는 코드를 뺍니다. 이 일도 트리 셰이킹이라고 부릅니다.
C 같은 컴파일 언어에서는 링커가 같은 일을 맡기도 합니다. 링커는 컴파일된 조각을 실행 파일 하나로 잇는 도구입니다. 이쪽에서는 흔히 「안 쓰는 코드 제거」라고 부릅니다.
잘 듣는 때와 안 듣는 때
트리 셰이킹은 배포용 빌드에서 켭니다. 웹 프론트엔드의 번들러는 대개 배포용 빌드에서 이 일을 기본으로 합니다. 개발 중에는 빌드를 빠르게 하려고 건너뛰는 일이 많습니다.
서버에서 도는 코드에는 얻는 것이 적습니다. Node.js 로 짠 백엔드 코드는 네트워크를 건너 사용자에게 가지 않습니다. 줄여도 아끼는 것이 디스크 몇 바이트뿐입니다. 서버 코드를 함수 하나로 올리는 서버리스 환경처럼 파일 크기가 시작 시간에 걸리는 경우에만 따져 볼 만합니다.
켜 두어도 기대만큼 안 줄 때가 있습니다. 흔한 경우는 아래 넷입니다.
| 경우 | 왜 못 빼나 | 덜어 내는 법 |
|---|---|---|
| CommonJS 로 짠 라이브러리 | 가져가는 것을 실행해야 안다 | ES 모듈판이 있으면 그것을 쓴다 |
| 부수 효과 표시가 없는 패키지 | 불러오기만으로 하는 일이 있을지 모른다 | 작성자가 sideEffects 를 적는다 |
| 모듈을 객체 하나로 받아 이름을 변수로 부르기 | 어느 함수를 부를지 실행해야 안다 | 쓰는 이름을 하나씩 가져온다 |
| 클래스의 안 쓰는 메서드 | 클래스는 통째로 한 덩어리로 다룬다 | 기능을 따로 내놓는 함수로 나눈다 |
셋째 경우를 코드로 보면 아래와 같습니다. 모듈 전체를 math 라는 객체 하나로 받은 뒤, 부를 함수 이름을 변수 op 로 고릅니다.
import * as math from './math.js';
math[op](1, 2); // add 일지 sub 일지 모른다
op 에 무엇이 들지는 실행해 봐야 압니다. 번들러는 add 와 sub 를 둘 다 남깁니다. import { add } 처럼 쓰는 이름을 하나씩 적으면 이 문제가 사라집니다.
관련 항목
트리 셰이킹이 돌아가는 빌드 도구
번들러 · webpack · Rollup · Vite · esbuild · Parcel · Turbopack
트리 셰이킹과 함께 거치는 빌드 단계
트랜스파일 · 미니파이 · 데드 코드 제거 · 코드 분할 · 스코프 호이스팅 · 소스맵 · 청크
트리 셰이킹이 기대는 모듈 문법
모듈 · ES 모듈 · CommonJS · 동적 import · 모듈 그래프 · 진입점 · package.json
트리 셰이킹이 속으로 쓰는 기법
정적 분석 · 추상 구문 트리 · 도달 가능성 분석 · 부수 효과 · 순수 함수 · 가비지 컬렉션
같은 일을 다른 생태계에서 맡는 도구
링커 · 정적 링크 · Dart · ProGuard · R8 · GraalVM 네이티브 이미지
트리 셰이킹으로 줄이려는 비용
번들 크기 · 대역폭 · 페이지 로드 · Core Web Vitals · 콜드 스타트 · 서버리스
트리 셰이킹이 속하는 상위 분류
다른 이름: tree shaking · tree-shaking · 트리쉐이킹 · 트리 쉐이킹 · 트리셰이킹