오랜만에 글을 쓰네요~👋 프론트엔드 개발을 수행하다보면 Webpack, Rollup 등 여러 번들러들을 접해보시거나 들어보셨을 것입니다. 번들러 종류는 너무나 다양하고 동작하는 방식도 세세히 다르기도 합니다. 이 도구들의 역할은 무엇이고, JavaScript 모듈 시스템은 어떻게 구성해야 하는지 정리한 내용들을 공유해보도록 하겠습니다.
모듈 시스템
우선 지난번 패키지 매니저 돌아보기 포스팅에서 JavaScript 모듈에 대해서 살짝 언급하고 넘어갔었는데요. 다시 한 번 간략히 알려드리자면 전역 공간을 최소화하기 위해 ESModules, CommonJS라는 방식의 모듈 시스템을 활용하여 독립적으로 스코프를 가지는 영역이라고 생각하시면 됩니다.
CommonJS
CommonJS
방식은 Node.js에서 기존 제공하였던 방식의 모듈 시스템입니다. exports
문법을 사용하여 함수, 객체 등을 내보낼 수 있고 내보내기 된 모듈은 module wrapper
에 의해 함수로 변환되기 때문에 비공개 상태가 됩니다. 내보낸 모듈은 require
로 불러올 수 있습니다. (JSON 파일도 취급 가능)
//calculator.js
exports.sum = (a, b) => a + b;
exports.multiply = (a, b) => a * b;
const calculator = require('./calculator.js');
const employee = require('../sample.json');
calculator.sum(1, 2);
console.log(employee.name);
module wrapper
이 방식은 module wrapper
에 의해 평가되는데 내보내기된 모듈을 실행하기 전에 Node.js에서 다음과 같은 함수로 감싸게 됩니다. 이를 통해 모듈 내 최상위 변수들을 전역 스코프로 전파하는 것을 방지할 수 있습니다.
(function (exports, require, module, __filename, __dirname) {
// Module code actually lives in here
});
CommonJS로 취급할 수 있는 상황
기본적으로 Node.js에서 다음과 같은 상황에서 CommonJS 모듈 방식으로 취급합니다.
- .cjs 확장자를 가진 파일
- 가장 가까운 상위 package.json내 type이
commonjs
값으로 설정되어있는 범위
동기적 특성
CommonJS에서 require
문법은 동기적인 특징을 가지고 있습니다. 모듈 코드를 읽은 다음 즉시 스크립트를 실행하여 module.exports
에 설정된 값을 반환합니다. 따라서 항상 최상위에 위치하지 않아도 됩니다. 또한 모듈은 처음 로드된 이후에 캐싱되기 때문에 내보낸 모듈이 변경되더라도 변경된 값을 항상 참조하지 않습니다.
ESModules
CommonJS는 Node.js 진영에서 사용된 방식입니다. 반면 ESModules는 ECMA Script에서 공식 표준으로 등장한 모듈 방식으로 브라우저에서도 사용 가능하다는 장점이 있고 import
와 export
를 사용하여 모듈을 내보내거나 불러올 수 있습니다.
//calculator.js
export const sum = (a, b) => a + b;
export const multiply => (a, b) => a * b;
import { sum, multiply } from './calculator.js';
sum(1, 2);
ESModules로 취급할 수 있는 상황
- .mjs 확장자를 가진 파일
- 가장 가까운 상위 package.json내 type이
module
값으로 설정되어있는 범위
assertions
ESModules 환경에서 JSON 파일을 불러오려면 모듈 로더에게 JSON
파일임을 알려주어야 하는데 assert
키워드로 명시할 수 있습니다.
import employee from './sample.json' assert { type: 'json' };
비동기적 특성
ESModule에서의 모듈 로더는 비동기 방식으로 실행됩니다. CommonJS와 달리 브라우저에서도 모듈을 이해해야 하기 때문에 별도의 과정이 필요합니다. 불러온 스크립트를 즉시 실행하지 않고 구문 분석을 하여 문제가 있는 경우 예외 처리를 수행합니다.
이후에 종속성 그래프를 작성하고 형제 관계에 있는 모듈들은 기본적으로 병렬로 다운로드합니다. 이 과정은 아무것도 가져오지 않는 import
스크립트를 찾을 때까지 반복하고 최종적으로 브라우저가 이해할 수 있는 **모듈 레코드(module record)**로 변환합니다. 이러한 특성 때문에 import
구문은 항상 코드의 최상위에 위치해야하는 제약 사항이 있습니다.
동적 Import
import()
은 ESModule 환경이 아닌 곳(CommonJS)에서도 모듈을 사용할 수 있도록 하는 함수와 유사한 표현식입니다. 이 함수는 해당 경로의 모든 모듈을 포함한 객체를 내보내는 Promise를 반환합니다. 또한, ESModule 로더가 아니기 때문에 코드 어디에서든 동적으로 사용할 수 있습니다. 이러한 특성을 Dynamic Import(동적 불러오기)라고 표현합니다.
//index.js
const callAdd = async (a, b) => {
const { add } = await import('./calc.mjs');
console.log(add(a, b));
};
const employee = require('../sample.json');
console.log(employee.name);
callAdd(1, 2);
Dual Package
자 사용자의 프로젝트 환경은 CommonJS 방식일수도 있고 ESModules 방식일수도 있습니다. 만약 모듈을 배포하려고 하는데 두 모듈 로더에서 동작하게 하려면 어떻게 해야할까요?
기존에는 개발자가 각 모듈에 해당하는 소스를 모두 포함하는 것이 일반적이었습니다.
//package.json
{
"name": "some-module",
...
"main": "dist/cjs/index.cjs", //CJS Entry
"module": "dist/esm/index.mjs", //ESM Entry
...
}
하지만 현재에는 exports
필드를 명시하여 모듈 로더에 따라 진입점을 분기시켜 이중 패키지 환경을 제공할 수 있게되었습니다.
{
..
"exports": {
"import": "./dist/index.mjs", //ESModule 진입점 파일
"require": "./dist/index.cjs" //CommonJS 진입점 파일
},
}
Dual Package Hazard
모듈 제작시 코드내에서 CommonJS와 ESModules 두 모듈 방식이 포함된 패키지나 모듈을 동일한 런타임 환경에서 로드 되면 특정 버그가 발생하는 위험성이 있습니다. 이를 이중 패키지 위험(Dual Package Hazard)라고 합니다. 이를 방지하려면 설정된 모듈 타입에 따라 단일 방식으로 작성하며 사용하는 측에 두 방식으로 명시적으로 제공 할 필요가 있습니다.
{
..
"type": "module",
"exports": {
"import": "./dist/wrapper.mjs", //ESModule 진입점 경로
"require": "./dist/index.cjs" //CommonJS 진입점 경로
},
}
// 📂 src/index.cjs
const calc = require('./calc.js');
const resultOfAdd = calc.add(1, 2);
const resultOfSubtract = calc.subtract(1, 2);
console.log(resultOfAdd, resultOfSubtract);
// 📂 src/wrapper.mjs
import cjsModule from './index.cjs';
export const calc = cjsModule.calc;
- 만약 기존 패키지 환경이 CommonJS인 경우 위의 코드 처럼 전체 코드를 ESModule 방식으로 변환 시키지 않고 별도의 랩퍼 파일을 통해 내보내면 두 가지 모듈 방식을 사용할 수 있습니다.
- 새로 개발해야하는 상황 일 경우 패키지 내
exports
속성을 통해 명확히 빌드 파일 경로를 입력해주는 것이 중요합니다.
번들러란 무엇인가?
모듈을 만들었으면 사용자들이 사용할 수 있도록 내보내는 과정도 필요합니다. 개발 과정에서 단일 파일로 개발하시는 경우는 극히 드물 것입니다. 그리고 모듈이 아니라 애플리케이션 개발이라면 스크립트 파일 뿐만 아니라 이미지 등 각종 리소스들도 포함시키는 경우도 있습니다. 이를 단일 파일로 합쳐주는 과정이 필요한데 이를 번들러라는 도구가 수행하고 있습니다.
번들러의 역할
Webpack, Rollup, Vite 등 여러 번들러 도구들이 있으며, 이들은 공통적으로 다양한 기능을 제공합니다. 번들링의 기본적인 역할을 소개해 드리겠습니다.
- 분리된 여러 자바스크립트 파일 또는 모듈을 단일 파일로 정리하고 결합 (스크립트 파일을 최대한 압축하고 작은 파일로 통합)
- 페이지 방문시 파일 요청 횟수를 최소화 (웹 페이지에서 필요한 스크립트 파일이 많을수록 응답시간도 비례해서 늘어남)
- 프론트엔드 개발에서 잦은 빌드 결과물 확인이 필요 → 다시 빌드 할 필요 없이 업데이트된 코드들을 즉시 반영
- 실제로 사용하지 않는 코드들을 제거하여 빌드 용량 최적화 (TreeShaking)
- 번들에 포함된 여러 모듈들을 분리하여 실제 사용하는 곳에서만 요청하여 수신 속도와 파싱 시간 최적화 (Code Splitting)
모든 번들러 도구에 대한 사용법을 설명하는 것은 어렵기 때문에, 상대적으로 유명한 Webpack과 Rollup을 통해 이 과정을 소개하겠습니다. 일반적으로 Webpack은 많은 참고 자료가 있으며 프로덕션 빌드에 많이 사용되고, Rollup은 라이브러리 형태의 모듈 제작에 주로 사용됩니다.
Webpack
Entry
웹팩에서 번들링할 애플리케이션을 이해하기 위해서는 필요한 애플리케이션의 진입점을 나타내는 파일이 있어야 합니다. 대개 SPA
방식에서는 진입점을 한 개로 두는 것이 일반적이지만, MPA
환경에서는 특정 페이지에서 내려주는 파일이 다를 수 있기 때문에 여러 개의 진입점을 둘 수 있습니다.
module.exports = {
entry: './src/index.js'
};
entry: {
login: './src/LoginView.js',
main: './src/MainView.js'
}
output
번들을 내보낼 위치와 번들링 후 파일의 이름을 지정합니다.
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
chunkFormat: 'module', //모듈 방식 CommonJS, ESModule(기본: array-push)
path: path.resolve(__dirname, 'dist'), //번들링될 위치
filename: 'webpack-bundle.js', //번들링된 파일의 이름
clean: true //번들링을 수행하기 전에 output 디렉터리를 클린업
}
};
Loader
웹팩은 기본적으로 자바스크립트 파일과 JSON 형식만 번들러 대상으로 인지할 수 있습니다. 이외의 다양한 파일 유형과 환경은 로더를 통해 번들링을 수행할 수 있습니다.
# 만약 css 번들링과 ts 번들링이 필요하다면 다음 두 로더를 설치
npm install -D css-loader ts-loader
module.exports = {
module: {
rules: [
{ test: /\.css$/, use: 'css-loader' }, //.css 확장자 파일은 css-loader를 활용해 번들링 수행
{ test: /\.ts$/, use: 'ts-loader' } //.ts 확장자 파일은 ts-loader를 활용해 번들링 수행
]
}
};
자주 사용하는 로더
- file-loader: 다양한 파일 포맷
- ts-loader: 타입스크립트
- babel-loader: Babel 트랜스파일링
- css-loader: css
💡 중요! 로더 적용 순서는 오른쪽에서 왼쪽 순으로 적용됩니다.
예를들어, 아래와 같이 scss
확장자는 SASS 로더로 css로 변환한다음 변환한 css를 최종적으로 번들링합니다.
module: {
rules: [
{
test: /\.scss$/,
use: ['css-loader', 'sass-loader'] //sass -> css
}
];
}
Plugin
로더는 다양한 파일 유형에 대해 번들링을 제공하는 역할이라면 플러그인은 번들링 결과물에 다양한 후처리를 수행하는 기능입니다. 여러 플러그인이 존재하며 대표적으로 HtmlWebpackPlugin
플러그인이 있습니다. (빌드한 결과물을 HTML에 포함시키는 플러그인)
module.exports = {
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html'
})
]
};
Code Splitting
웹팩에서는 entry
속성 또는 SplitChunkPlugin
을 통해 코드 스플릿팅을 수행할 수 있습니다.
-
entry를 사용한 코드 스플릿팅
export default { mode: 'development', // default: production (생략 가능) entry: { index: './src/index.js', module: './src/module.js' }, output: { filename: '[name].bundle.js', chunkFormat: 'commonjs', path: resolve(__dirname, 'dist'), // __dirname: 현재 디렉토리 clean: true } };
해당 방식에서는 단점이 존재합니다. 분리된 모듈 사이에서 중복된 모듈이 존재한다면, 동시에 번들에 포함되는 문제가 발생할 수 있습니다. 예를 들어, index와 module파일에서 lodash 라이브러리를 사용하고 있다면, 두 파일 모두 lodash 번들링이 포함되어 용량이 늘어나게 됩니다.
-
SplitChunkPlugin을 사용한 코드 스플릿팅
SplitChunkPlugin을 사용하면 자동으로 기본 옵션에 따라 청크들을 분리합니다.
export default { ... optimization: { splitChunks: { chunks: 'all' } } };
SplitChunk 기본 옵션
- 새 청크를 공유 할 수 있거나 모듈이
node_modules
폴더에 있는 경우 - 새 청크가 20kb보다 클 경우
- 요청 시 청크를 로드할 때 최대 병렬 요청 수가 30개 이하일 경우
- 초기 페이지 로드 시 최대 병렬 요청 수가 30개 이하일 경우
//기본 SplitChuckPlugin 설정 값 module.exports = { //... optimization: { splitChunks: { chunks: 'async', //최적화를 수행시킬 청크의 유형 (all, async, sync) minSize: 20000, //생성할 청크의 최소 byte (이 보다 작으면 분할되지 않음) minChunks: 1, //모듈이 분할 전에 청크 간 재사용하는 횟수 (1개 이상 청크에서 재사용) maxAsyncRequests: 30, //비동기로 로드될 수 있는 최대 청크 수 maxInitialRequests: 30, //초기 페이지 로드 시 요청될 수 있는 최대 청크 수 enforceSizeThreshold: 50000, //강제로 분할시키기 위한 최대 byte 임계값 cacheGroups: { defaultVendors: { test: /[\\/]node_modules[\\/]/, //대상 경로 priority: -10, //캐시 그룹간의 우선순위 (값이 높을수록 높은 우선순위) reuseExistingChunk: true //존재하는 청크를 재사용할지에 대한 여부 }, default: { minChunks: 2, //청크 분할에 관한 최소 청크 개수 priority: -20, //우선 순위 설 reuseExistingChunk: true } } } } };
- 새 청크를 공유 할 수 있거나 모듈이
위의 내용을 종합하여 자바스크립트로 기본적인 웹 동작을 수행하는 Webpack 환경을 구성해봅시다.
npm i -D webpack webapck-cli #webpack
npm i -D webapck-dev-server css-loader style-laoder #webpack plugins
npm i -D babel-loader @babel/cli @babel/core @babel/preset-env #transfile
//webpack.config.js
import path, { resolve } from 'path';
import { fileURLToPath } from 'url';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import { CleanWebpackPlugin } from 'clean-webpack-plugin';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: 'development',
entry: './src/app.js',
output: {
filename: 'bundle.js',
path: resolve(__dirname, 'dist'),
clean: true
},
resolve: {
extensions: ['.js', '.css']
},
devServer: {
port: 8088,
open: true,
hot: true,
watchFiles: './src/**/*' //Hot Reload를 위한 감시자 설정
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
]
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
template: './src/assets/index.html'
})
]
};
Rollup
Rollup은 다음과 같은 특징을 가지고 있습니다.
- 편리한 감시자 옵션을 제공합니다.
- ESModules 방식을 사용합니다.
Webpack
에 비해 기본적으로 생성되는 코드의 양이 적습니다.- 독립된 작은 단위로 번들링을 수행하기 쉽고, 다양한 번들 형식을 지원하여 라이브러리 배포에 수월합니다.
//rollup.config.js
import commonjs from '@rollup/plugin-commonjs';
export default {
input: {
module: 'src/module.js',
index: 'src/index.js'
},
output: [
{
dir: 'rollup',
entryFileNames: '[name].bundle.mjs',
format: 'es' //ESM
},
{
dir: 'rollup',
entryFileNames: '[name].bundle.cjs',
format: 'cjs' //CommonJS
}
],
plugins: [commonjs()]
};
Input
번들 대상의 진입점(entry)을 설정합니다. 배열 또는 이름을 맵핑하는 객체를 지정하면 청크로 쪼개어 번들링됩니다.
//rollup.config.js
export default {
input: {
module: 'src/module.js',
index: 'src/index.js'
},
...
};
rollup -c #rollup.config.js
rollup -cw #whatcher
Output
번들링된 결과물에 대한 설정 입니다. 번들 위치와 모듈 형식을 지정할 수 있습니다.
- format: 번들의 파일 형식을 지정할 수 있습니다. (cjs, es, amd, iife, umd, system)
- dir: 생성된 번들의 위치를 지정합니다.
//rollup.config.js
export default {
input: {
module: 'src/module.js',
index: 'src/index.js'
},
output: [
{
dir: 'rollup',
entryFileNames: '[name].bundle.mjs',
format: 'es'
},
{
dir: 'rollup',
entryFileNames: '[name].bundle.cjs',
format: 'cjs'
}
],
plugins: [commonjs()]
};
PreserveModules
각 모듈 파일을 단일로 합치지 않고 개별적으로 보존하는 방식으로 번들링을 수행합니다. 예상된 export 모듈이 누락될 수 있기 때문에 무작정 사용하는 것은 좋지 않습니다.
옵션 활성화시 단일 파일로 빌드하지 않습니다.
옵션 활성화시 단일 파일로 빌드됩니다.
external
모듈이 의존하고 있는 라이브러리등을 지정하여 최종 번들에 포함하지 않는 속성 입니다.
export default {
external: ['lodash'],
input: {
module: 'src/module.js',
index: 'src/index.js'
},
output: [
{
dir: 'rollup',
entryFileNames: '[name].bundle.mjs',
format: 'es'
},
{
dir: 'rollup',
entryFileNames: '[name].bundle.cjs',
format: 'cjs'
}
]
};
Plugins
롤업에서도 다양한 플러그인을 지원합니다.
플러그인 목록 바로가기
- commonjs: CJS 방식으로 작성된 모듈을 ESM 방식으로 번들링
- babel:
Babel
트랜스파일 지원 - delete: 번들링 수행 전 클린업 (output 디렉토리 삭제)
- html:
html
형식으로 번들링 - postcss: CSS 변환 역할을 수행하는
PostCSS
지원 - serve: 로컬 서버 환경을 지원
- terser:
terser
압축기를 사용하여 번들 사이즈 최적화
CodeSplitting
Rollup에서는 기본적으로 다중 진입 파일 또는 동적 로딩 상황에서 자동으로 모듈 코드를 여러 청크로 분할합니다. 수동으로 지정하고 싶다면 output.manualChunks
옵션을 통해 모듈을 분리시킬 수 있습니다.
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
export default {
input: {
module: 'src/module.js',
index: 'src/index.js'
},
output: {
dir: 'rollup',
entryFileNames: '[name].bundle.js',
format: 'es',
manualChunks: {
lodash: ['lodash']
}
},
plugins: [nodeResolve(), commonjs()]
};
마찬가지로 Rollup에서도 기본적인 웹 페이지를 구성하는 환경 설정을 작성해보았습니다.
//rollup.config.js
import html2 from 'rollup-plugin-html2';
import serve from 'rollup-plugin-serve';
import copy from 'rollup-plugin-copy-assets';
import postcss from 'rollup-plugin-postcss';
export default {
input: './src/app.js',
output: {
dir: 'rollup',
entryFileNames: 'bundle.js',
format: 'iife',
sourcemap: true
},
plugins: [
html2({
template: './src/assets/index.html'
}),
postcss({
extensions: ['.css']
}),
copy({
assets: ['src/constants']
}),
serve({
open: true,
openPage: '/rollup/index.html',
port: 9000,
contentBase: ['rollup', '.']
})
]
};
TreeShaking
트리쉐이킹은 완성된 코드에서 필요하지 않은 부분을 삭제하는 것이 아니라, 실제 사용되는 코드만 포함시켜 논리적으로 사용하지 않는 코드를 포함시키지 않는 것을 말합니다. 이는 마치 나무를 흔들어 죽은 나뭇잎을 떨어트리는 행위에 비유되어 ‘TreeShaking’이라고 불립니다.
TreeShaking이 ESModules에 의존적인 이유
Webpack에서는 트리쉐이킹이 ESModules 모듈 로더에 의존한다고 설명하고 있습니다.
Tree shaking is a term commonly used in the JavaScript context for dead-code elimination. It relies on the static structure of ES2015 module syntax, i.e. import and export. Webpack
이유를 설명하자면 ESModules 방식은 컴파일러가 사용자의 코드를 더 잘 이해할 수 있는 환경이며, 정적인 특성을 가진 덕분에 구문 분석이 용이하기 때문입니다.
Strict Mode
ESModule 시스템에서는 기본적으로 strict
모드가 활성화되어있습니다. strict
모드를 간단하게 말씀드리면 컴파일러가 사용자가 작성한 코드를 잘 이해할 수 있게 비논리적인 동작을 막는 역할입니다. 스코프에 'use strict'
을 사용하면 strict 모드가 활성화되며 ES6에서는 기본적으로 사용하고 있습니다.
'use strict';
이 규칙을 간단하게 설명해보자면..
- 변수는 반드시 선언되어야한다.
- 각 함수 매개변수는 고유한 이름을 가져야한다. (그렇지 않으면 문법 오류로 간주)
- JavaScript의 고유한 예약어에 바인딩할 수 없음 (protected, static, interface..)
with
명령어 사용 제한
정적인 특성을 갖는 ESModules
ESModules의 import, export구문은 최상위 스코프에만 배치될 수 있습니다. 이 규칙으로 인해 컴파일러가 모듈을 더 쉽게 분석할 수 있습니다.
export const foo = () => {
export default 'bar'; // SyntaxError
};
foo();
import 'lodash';
반면 CommonJS 방식은 동적으로 표현식 내부에 내보낼 모듈을 할당할 수 있어 빌드 환경에서 예측하기 어렵습니다. 런타임 환경에서에만 사용할 수 있는 정보이기 때문에 빌드 단계에서 정확히 해당 컨텍스트에서 어떤 종속성을 가졌는지 파악하기가 어려워 번들러가 제대로 최적화시킬 수 없습니다.
const propertyName = 'name';
module.exports[propertyName] = () => { //module code };
Webpack TreeShaking
ModuleConcatenationPlugin
Webpack에서는 production
모드에서 ModuleConcatenationPlugin
기능이 활성화되면서 트리쉐이킹이 자동으로 수행됩니다. 이 기능은 클로저를 활용해 모듈 범위를 동일한 스코프로 감싸고 내보낸 모듈에 동일한 함수명이 존재하는 경우 중복되지 않는 이름으로 변경합니다.
//index.js
import { add } from './calc.js';
const substract = (a, b) => a - b;
console.log(add(1, 2));
console.log(substract(1, 2));
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
export default {
mode: 'production',
entry: {
index: './src/index.js'
},
output: {
filename: 'output.js',
path: resolve(__dirname, 'dist'), // __dirname: 현재 디렉토리
clean: true
}
};
/******/ (() => {
// webpackBootstrap
/******/ 'use strict';
var __webpack_exports__ = {}; // CONCATENATED MODULE: ./src/cjs/calc.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;
const max = (arr) => maxBy(arr); // CONCATENATED MODULE: ./src/cjs/index.js
// index.js
const cjs_subtract = (a, b) => a - b;
console.log(add(1, 2));
console.log(cjs_subtract(1, 2));
/******/
})();
또한 minimize
속성을 활용하면 사용하지 않는 함수 및 주석&공백이 모두 제거 됩니다.
//output.js
(() => {
'use strict';
console.log(3), console.log(-1);
})();
SideEffects와 useExports
트리쉐이킹 작업에서 단순히 사용하지 않는 모듈을 기준으로 제거한다면 애플리케이션 전역에서 영향을 주는 모듈들이 사라지게 되므로 서비스에 큰 영향을 줄 수 있습니다.(css, plugin과 같이 import만 선언한 모듈 등) 이를 위해서 개발자가 해당 프로젝트 또는 특정 모듈이 SideEffect가 있음을 알려주어야합니다.
Webpack 4부터 package.json
내 sideEffects에 false
값을 부여하면, 컴파일러에게 해당 프로젝트 내 사용하지 않는 export된 모듈을 모두 제거해도 상관 없다는 것을 알릴 수 있습니다. (코드 단위에 사이드 이펙트가 존재한다면 배열로 소스 경로를 입력할 수 있습니다.)
{
"name": "bundlers",
"sideEffects": false,
"version": "1.0.0",
...
}
useExports
속성을 활용하면 terser를 사용하여 사이드이펙트를 감지할 수 있습니다. 하지만 문서에서는 SideEffects
속성을 권장하고 있습니다. (React 고차 컴포넌트 관련 문제 및 성능 사항)
//webpack.config.js
export default {
mode: 'development',
entry: './src/index.js',
optimization: {
usedExports: true
},
output: {
filename: 'test.bundle.js',
chunkFormat: 'module',
path: resolve(__dirname, 'dist'), // __dirname: 현재 디렉토리
clean: true
}
};
SideEffects와 useExports
- sideEffects: 전체 프로젝트 내 모듈 및 하위 트리를 감시하지 않기때문에 효율적입니다. (명시적으로 사이드이펙트가 없음을 웹팩에게 제공)
- useExports:
terser
를 사용하여 사이드이펙트를 감지하기 때문에 성능에 제약이 있습니다.
Pure Annotation
PURE 어노테이션을 사용해 함수 단위로 순수한 함수임을 명시할 수 있습니다. sideEffects
와 목적은 동일하지만 sideEffects와 달리 코드 구문 단위로 사용할 수 있습니다.
//modue.js
/*#__PURE__*/ calculator(input);
CommonJS TreeShaking
만약 사용 중인 모듈이 CommonJS인 경우, 플러그인(webpack-common-shake)을 사용하여 트리 쉐이킹을 수행할 수 있다는 사실을 발견했습니다. 🥲 하지만 직접 시도한 결과, Webpack5를 지원하지 않는 치명적인 단점 때문에 레거시 환경에서 사용해야 하며, ESModule을 사용하는 환경에서의 TreeShaking을 동일하게 보장하지는 않는다고 합니다.
const ShakePlugin = require('webpack-common-shake').Plugin;
const path = require('path');
module.exports = {
mode: 'production',
entry: {
index: './src/cjs/index.js'
},
optimization: {
minimize: false
},
output: {
filename: 'output.js',
path: path.resolve(__dirname, 'dist'),
clean: true
},
plugins: [new ShakePlugin()]
};
Rollup TreeShking
Rollup에서는 기본적으로 ESModules 환경으로 모듈을 구성하면 별도의 설정 없이 트리 쉐이킹을 수행할 수 있습니다. 만일 CommonJS 방식의 모듈을 가져온다면 rollup/plugin-commonjs
플러그인을 통해 쉽게 ES6으로 변환시킬 수 있습니다.
import del from 'rollup-plugin-delete';
import analyzer from 'rollup-plugin-analyzer';
import { nodeResolve } from '@rollup/plugin-node-resolve';
export default {
input: {
index: 'src/cjs/index.js'
},
output: [
{
dir: 'rollup',
entryFileNames: 'output.js',
format: 'esm'
}
],
plugins: [
del({ targets: 'rollup/*' }),
analyzer({
summaryOnly: true
}),
nodeResolve()
]
};
참고 자료
모듈 시스템
- Node.js 공식 문서 - CommonJS modules
- Node.js 공식 문서 - ECMAScript modules
- Node.js 공식 문서 - Packages
- hacks.mozilla.org - ES modules: A cartoon deep-dive
- Dan Fabulich Blog - Node Modules at War: Why CommonJS and ES Modules Can’t Get Along
번들러
- TOAST UI - 번들러
- Next.JS Docs - What is Bundlings?
- TOAST UI - 트리쉐이킹으로 자바스크립트 페이로드 줄이기
- Rollup 공식 문서
- Webpack 공식 문서
TreeShaking
- SOSOLOG - TreeShaking과 ModuleSystem
- web.dev - CommonJS가 번들을 더 크게 만드는 방법 (Minko Gechev)
- codefeetime.com - Tree-Shaking a React Component Library in Rollup
- Rich Harris Blog - Tree-shaking versus dead code elimination
- web.dev - Reduce JavaScript payloads with tree shaking
- Pony Foo - ES6 Modules in Depth