sungyup's.

Web_Miscellaneous / 기초 개념 / 1.10 자바스크립트 모듈 시스템과 번들러

1.10자바스크립트 모듈 시스템과 번들러

모듈 시스템과 번들러의 역사

TL;DR

추억의 쪽지 시험

들어가기에 앞서

이 주제를 공부할 수 있게 해준 두 블로그 포스팅에 우선 감사를 표하고자 합니다. 시작은 김세진님의 Turbopack이 Vite보다 10배 빠르다였습니다. 회사에서 진행하는 프로젝트를 개발하던 중, Turbopack을 쓰라는 Next.js의 권고 문구가 계속 떠서 Vite와 비교하면 어떻지?라는 생각을 하게 되어 검색을 하던 중 좀 정리를 해야겠다는 생각이 들었기 때문입니다.

위 글을 읽던 도중, 재그지그님의 JavaScript 번들러로 본 조선시대 붕당의 이해를 접했습니다. 김세진님의 블로그에 나오는 Vite가 등장하기 전의 자바스크립트 번들러 및 모듈 시스템을 아주 쉽고 재미있게 정리한 글이었는데, 이 글을 보고는 사실 굳이 제가 글을 쓸건 없다는 생각이 들었지만 이 블로그의 글들은 다른 누구보다도 제 스스로의 공부를 위한 것이기에 그냥 쓰기로 했습니다. 김세진님과 재그지그님께 좋은 글을 써주셔서 감사하다는 말씀을 우선 전합니다.

자바스크립트의 모듈

자바스크립트의 모듈은 프로그램의 특정 기능이나 데이터를 하나의 독립된 단위로 분리한 것이다. 예를 들면, 아래의 <script>태그 안의 파일들은 해당 웹 애플리케이션을 구성하는 각각의 모듈들로, 각자 분리될 만한 기능이나 데이터를 가지고 있다.

html
<html> <script src="/src/foo.js"></script> <script src="/src/bar.js"></script> <script src="/src/baz.js"></script> </html>

브라우저는 HTML 파싱을 진행하다가, <script> 태그를 만나면 HTML 파서를 멈추고 해당 스크립트를 다운로드하여 자바스크립트 엔진으로 넘긴다. 자바스크립트 엔진은 완전히 하나의 스크립트를 파싱 및 실행 한 후 다음 스크립트로 넘어간다.

이 방식은 웹 애플리케이션이 아주 단순할 때는 크게 문제 없었지만, 여러 개의 스크립트가 들어가면 문제가 있었다.

  1. 블로킹: 각 스크립트가 순차적으로 로드되고 실행되기 때문에(동기적 로딩), 첫 번째 스크립트가 로드 되는 동안 이후 스크립트 로딩이 지연되었다.
  2. 의존성 관리의 어려움: 스크립트 간 서로 의존성이 있을 때, 올바른 실행 순서가 중요했기 때문에 개발자가 <script> 태그의 순서를 조정해야 했다. 따라서 코드를 확장하거나 유지보수 하기가 어려웠다.
  3. 전역 컨텍스트에서 모듈 간의 충돌: 각 스크립트의 변수 선언은 전역 컨텍스트에서 이루어졌다. 즉, 파일이 다르다고 해도 스크립트의 최상위에서 선언된 변수나 함수는 별도의 지역 스코프가 없으면 모두 전역 변수나 전역 함수로 등록되어 서로 네임스페이스에서 충돌할 수 있었다.

    예를 들어, 위의 foo.js에서 선언한 변수와 같은 이름의 변수가 bar.js에도 선언되어 있다면, 스크립트가 실행되며 나중에 호출된 변수가 앞선 변수를 덮어씌우며 앞선 파일이 제대로 작동하지 않는 문제가 발생할 수 있었다. 그렇기에 개발자들은 다른 모듈과 변수 이름이 겹치지 않도록 다른 파일들을 뒤져가며 변수 이름을 고심해야했다.

시간이 흘러, 웹이 단순히 문서를 보여주는걸 넘어서 JavaScript를 활용한 애플리케이션 플랫폼이 되면서 모듈화에 대한 필요성이 더욱 부각되었다.

v8 logo
브라우저 밖에서도 자바스크립트를 실행시킬 수 있게해준 구글의 V8 엔진

2008년에는 Google의 V8 엔진이 공개되었는데, 이 엔진은 브라우저 밖에서도 JavaScript를 실행시킬 수 있었기 때문에 서버 사이드에서 JavaScript를 활용하자는 아이디어가 제시되기 시작했다. 모듈화에 대한 필요성은 더욱 부각되었고, 2009년에는 JavaScript의 모듈을 표준화하기 위한 움직임이 본격적으로 시작되었다. CommonJS와 AMD의 등장 배경이다.

CommonJS

CommonJSCommon이라는 이름에서 볼 수 있듯, JavaScript를 브라우저 뿐 아니라 서버 사이드 애플리케이션이나 데스크톱 애플리케이션 등 좀 더 범용적인 용도로 사용하기 위한 모듈 시스템을 만들고자 한 그룹이다. 이 그룹에서 제안한 방법을 CommonJS 또는 CJS라고 부른다.

사용 문법은 다음과 같다.

javascript
// Common JS // 모듈 정의 module.exports = foo; // 모듈 사용 const foo = require('./foo')

CommonJS는 모든 디펜던시가 로컬 디스크에 존재해서 필요한 모듈을 바로 사용할 수 있는 환경을 전제로 한다. 애초에 CommonJS가 브라우저 외의 환경에서도 동작하는 범용적인 JavaScript를 만들기 위한 모듈 시스템이었기 때문이다. 따라서, CJS는 모듈을 동기적(syncrhonously)으로 호출하는 방식을 선택했다.

CJS에선 requireexports라는 키워드로 간단하게 디펜던시를 내보내고 사용할 수 있다는 장점이 있다. 하지만, 이 방식은 비동기 방식보다 느리고, 트리쉐이킹이 어려운데다가 순환 참조에 취약하다는 등 단점 또한 명확했다.

하지만, 초창기 JavaScript 생태계(JS는 1995년 12월에 나왔다)의 발전을 위해서는, 앞서 언급한 이유들로 모듈 시스템이 반드시 필요했다. 이에 JavaScript 런타임인 Node.js는 CommonJS 방식의 명세를 채택하고 구현했다.

NPM의 성장과 함께 JavaScript 생태계의 규모는 폭발적으로 증가한다. 이에 비례해 CommonJS의 모듈 시스템도 사용 빈도가 높아졌지만, 비동기 로드를 고려하지 않은 설계 때문에 브라우저에서는 사용할 수 없었다.

즉, CommonJS 모듈 시스템은 Node.js라는, JavaScript 코드를 브라우저 밖에서 실행할 수 있게 해주는 런타임 환경을 만드려는 프로젝트에는 적합했지만 정작 브라우저에선 적합하지 않았다. 이 때문에 CommonJS의 모듈 방식을 브라우저에서도 활용하기 위한 빌드 도구 Browserify가 등장한다.

AMD(Asynchronous Module Definition)

역시 이름에서도 볼 수 있듯이, AMD는 비동기(Asynchronous) 상황에서도 JavaScript 모듈을 쓰기 위해 탄생했다. 처음에는 CommonJS와 함께 논의되었지만, 합의점을 이루지 못하고 별개의 그룹으로 떨어져나온 것이 AMD다.

브라우저에서는 로컬 디스크에서 디펜던시를 받는게 아닌, 네트워크를 통해 비동기적으로 다운받는다. 사용 문법은 다음과 같다:

javascript
//AMD // 모듈 정의 define([ 'jquery', 'underscore', // 그 외 디펜던시 모듈들을 나열 ], function($, _){ // 디펜던시 모듈들은 순서대로 매개변수에 담긴다 return { // 외부에 노출할 함수들만 반환 } }); // 모듈 사용 require([ ... // 사용할 모듈들을 배열로 나열 ], function(...){ // 사용할 모듈들이 순서대로 매개변수에 담긴다 })

보다시피 문법이 복잡하다. 하지만 비동기적으로 모듈을 호출하기 때문에, CommonJS보다 나은 성능을 보였다. 또, 브라우저와 서버 사이드 모두에서 호환되었다. AMD 명세로 구현된 대표적인 모듈 로더 라이브러리로 RequireJS가 있다.

UMD

CommonJS와 AMD는 각자 지향하는 목적의 차이로 분리된 것이다. 하지만 이렇게 규격이 통일되지 않으면 호환성 문제가 발생한다.

이런 문제 의식으로 등장한 것이 UMD(Universal Module Definition)으로, CommonJS와 AMD 방식을 모두 호환할 수 있도록 조건문으로 나누고 팩토리 패턴으로 구현했다.

javascript
(function(root, factory){ if(typeof define === 'function' && define.amd){ // AMD 방식 define(['jquery', 'underscore'], factory); } else if (typeof exports === 'object'){ // CommonJS 방식 module.exports = factory(require('jquery'), require('underscore')); } else{ root.foo = factory(root.$, root._); } })(this, function($, _){ // 모듈 정의 var foo = { // ... } return foo; })

이 방법은 CommonJS와 AMD 방식 모두를 호환하면서, window 객체를 통해 전역적으로도 접근할 수 있었다. 따라서 (좀 코드가 보기 싫게 되었지만) 라이브러리를 만들 때 자주 사용하는 패턴이 되었다.

심지어, 이후 등장할 웹팩(Webpack)과 롤업(Rollup) 등 몇몇 JavaScript 번들러들은 (바로 다음에 등장할) ES6 방식으로 모듈 로드에 실패했을 때 fallback으로 UMD 패턴으로 로드하는 방식을 사용할 정도로 안정적인 방식이다.

ES6 Module

es6 logo
2015년, ES6가 나오면서 자바스크립트에 표준 모듈 시스템이 생긴다.

하지만 UMD는 CommonJS와 AMD의 호환성 문제를 해결할 뿐, 모듈 시스템의 부재라는 근본적 문제를 해결한 것이 아니었다. 따라서 JavaScript 언어 자체에서 모듈 시스템을 지원해야한다는 목소리가 커져갔다.

2015년에, ES6이라고도 불리는 ECMAScript 6 사양에서 JavaScript의 표준 모듈 시스템이 명세되었다. 이를 ES6 Module이라고 부른다.

javascript
// ES6 import foo from 'bar'; export default baz;

ES6 Module은 동기/비동기 로드를 모두 지원하고, 문법 또한 간단하다. 또, CommonJS와는 달리 실제 객체/함수를 바인딩하기 때문에 순환 참조 관리도 편하다. 코드를 실행하지 않아도 분석(정적 분석)이 가능하기 때문에, 트리쉐이킹도 쉽게 가능하다.

다만, 비교적 최근에 정의된 문법이다보니 구형 브라우저(이젠 없어진 IE)에서는 제대로 동작하지 않는다는 단점이 있다. 그렇기에 SystemJS와 같이 CommonJS, AMD, 그리고 ES6 모듈 시스템을 모두 지원하는 또 다른 모듈 로더가 나오기도 했다.

모듈 로더란, JavaScript 모듈을 런타임에 로드할 수 있게 만드는 프로그램이다.

예를 들어, AMD의 모듈 로더는 RequireJS고 ES6의 모듈 로더는 브라우저다.

트랜스파일러

표준 모듈 문법이 생겼음에도 불구하고 구형 브라우저에서 호환이 안되어 또다른 라이브러리(앞서 언급한 SystemJS 등)에 의존한다는 것은 바람직하지 못하다.

이런 문제 의식에서 등장한 것이 트랜스파일러(Transpiler), 즉 한번 컴파일하면 구형 브라우저에서도 동작하는 JavaScript 코드가 나오는 도구였다.

babel logo
바벨은 코드 컴파일 시 옛날 브라우저까지 전부 호환되는 자바스크립트 코드로 변환해주는데, 이를 트랜스파일링이라고 한다.

바벨(Babel)이 대표적인 트랜스파일러다. 개발할 때 최신 JavaScript 문법으로 코드를 작성하면, 바벨로 컴파일 할 때 옛날 브라우저까지 전부 호환되는 JavaScript 코드로 변환된다.

슈퍼셋(Superset) 언어들의 등장 또한 호환성 문제에 대한 고민을 덜어주었다. CoffeeScript, TypeScript와 같은 슈퍼셋 언어는 모듈 시스템의 관리를 컴파일러가 대신 해준다.

태스크 러너

모듈 번들러에 대한 얘기를 본격적으로 하기 전에, 잠시 초창기 프론트엔드 생태계를 살펴보자.

모듈화의 필요성이 커지면서 생긴 CommonJS와 AMD 방식은 서버와 브라우저의 관점 차이로 분리되었다. 하지만 이 둘은 공통적으로 스코프가 구분되는 모듈 만들기에 대한 답으로 나온 방식들이다. JavaScript가 보다 복잡한 프로그램을 작성하는데도 쓰이면서, 스코프가 구분된 모듈을 만들어야 중복되는 코드도 줄이고 생산성과 성능도 뛰어난 애플리케이션을 만드는데 보다 유리했기 때문이다.

이렇게 모듈화를 할 때 또 필요한 것이 전처리가 필요한 언어를 컴파일하고, 컨벤션을 유지하기 위해 Lint를 사용하며, 소스 코드를 축소하고 하나의 파일로 묶는(bundle) 과정이다. 이러한 작업들을 자동화하는 도구가 태스크 러너(task runner)다. 즉, 태스크 러너는 개발 과정에서 필요한 린팅, 빌딩, 테스팅 등을 자동화하는 도구다.

태스크 러너는 이미 예전부터 존재했지만, 프론트엔드에서 본격적으로 등장한 것은 Node.js와 웹 생태계의 성장 이후다. GruntGulp는 테스트, 린팅, 번들링, 최적화 플러그인 등을 제공하면서 이 과정들을 자동화할 수 있도록 도와주었다.

이 과정에서, 번들링을 좀 더 전문적으로 도와주는 도구가 필요해졌고 이것이 모듈 번들러의 등장 배경이다.

모듈 번들러

모듈 번들러는 JavaScript 모듈을 브라우저에서 실행할 수 있는 단일 JavaScript 파일로 번들링하는데 사용되는 프론트엔드 개발 도구다. 다만, 번들러는 번들만 할것 같은 이름과는 달리 많이 발전해버려 개발과 빌드, 최적화를 위한 각종 플러그인도 제공하는 역할로 확장되었다. 따라서 태스크 러너나 최적화 도구가 더 이상 필요하지 않게 되었다.(그래서 Grunt나 Gulp등은 이제 별도로 쓰지 않는다)

얼핏 번들러는 모듈 로더와 비슷해보이지만, 모듈 로더는 런타임에 모듈을 가져오기 위한 목적의 프로그램인 반면 번들러는 빌드 시 모듈을 묶어서 단일 번들 파일로 만들기 때문에 런타임에서 모듈 로드를 할 필요를 없앤다.

번들러를 사용하는 이유는 크게 세 가지다.

  1. 아직까지 모든 브라우저가 모듈 시스템을 완전히 지원하지 않는다.
  2. 코드의 종속성 관계를 관리하는데 도움이 된다.
  3. 종속성 순서, 이미지, CSS 애셋 등을 로드하는데 도움이 된다.

현대적인 모듈 번들러로는 웹팩(Webpack), 롤업(Rollup), 파셀(Parcel), 비트(Vite) 그리고 이 포스트 맨 처음에서 언급한 터보팩(Turbopack)이 있다.

이 많은 것 중 뭘 써야 하냐고? 각각의 장단점에 대해 알아보자.

참고로 모든 상황을 해결할 정답은 없다. Tooling.Report 같은 사이트에선 각 빌드 도구간의 장단점을 비교하고 있다.

Webpack

webpack logo
가장 오래되어 안정적이고 매우 유연한 webpack

오래된만큼(2012년 webpack v1.0 발표) 생태계가 풍부하고 안정성이 뛰어나다. 그래서 서드파티 라이브러리 관리나 이미지 애셋 관리, CSS 전처리에서 다른 번들러보다 강점이 있다.

웹팩은 CSS나 이미지 같은 애셋들을 JavaScript 코드로 변환하고 이를 분석해서 번들하는 방식을 사용한다. 또 웹팩은 매우 유연하지만, 유연한 만큼 기본 제공 기능이 적고 설정을 직접 해야 해서 다른 번들러에 비해 설정할게 많고 복잡한 편이다.

예를 들어, JavaScript 외에 SCSS, TypeScript, JSX 등을 쓰고 싶다면 다음과 같이 설정을 해줘야 한다:

javascript
module.exports = { module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/, }, { test: /\.scss$/, use: ['style-loader', 'css-loader', 'sass-loader'], }, ], }, };

이외에도 이미지, 폰트, JSON 등을 불러오려면 file-loader, url-loader, asset modules 같은 것을 써야 한다.

웹팩에서 제공하는 개발 서버는 다른 번들러에 비해 뛰어난 편이다. 개발 중 변경사항을 자동으로 새로고침 해주는 Live Reloading과 새로고침 없이 런타임에 브라우저의 모듈을 업데이트하는 Hot Module Replacement 기능이 webpack-dev-server 플러그인만 설치하면 포함된다. 이후 살펴볼 롤업과 파셀은 별도의 디펜던시와 설정을 추가해야 한다.

또, 웹펙은 코드 스플리팅, 즉 파일들을 여러 번들 파일로 분리해 병렬로 스크립트를 로드해 페이지 로딩 속도를 개선하는데 큰 강점을 지닌다. 초기에 구동될 필요가 없는 코드를 분리해 lazy loading을 통해 페이지 초기 로딩 속도도 개선한다.

다만, 웹팩은 ES6 모듈 형태로 빌드 결과물을 출력할 수 없고 CommonJS 형태로만 번들링이 가능했다. 즉, 번들링 결과물엔 exportimport를 사용한 모듈 형태를 볼 수 없었다.

웹팩 v5부턴 ES6으로 번들할 수 있게 되었다.

Rollup

rollup logo
webpack과 비슷하지만 ES6 모듈 형태로 빌드 결과물을 출력해, 라이브러리 개발에 많이 쓰이는 rollup

롤업은 웹팩과 비슷하지만, ES6 모듈 형태로 빌드 결과물을 출력할 수 있다는 점에서 큰 차이가 있다. ES6 모듈 형태로 빌드 결과물을 출력하는 기능은 라이브러리나 패키지를 개발할 때 유용한데, 라이브러리들이 주로 생산성 높은 ES6으로 개발되는 만큼 ES6 번들에 대한 수요가 높기 때문이다.

또, 트리쉐이킹도 기본적으로 ES6에서 제대로 동작하기 때문(단순히 레퍼런스되지 않은 코드를 제거하는 것이 아닌, 사용되는 모듈만 AST 트리에 포함하는 방식으로 작동한다)에 롤업이 웹팩보다 더 가벼운 번들을 생성한다.

Parcel

parcel logo
별도의 설정 파일 없이도 쉽게 동작하는 parcel

파셀의 가장 큰 특징은 zero config, 즉 별도의 설정 파일 없이도 동작한다는 점이다. 왜냐하면 파셀은 웹팩, 롤업과 달리 JavaScript 엔트리 포인트를 지정하는 것이 아니라 애플리케이션 진입을 위한 HTML 파일 자체를 읽기 때문이다. 웹팩이나 롤업은 일반적으로 진입점을 JavaScript 파일로 지정한다.

javascript
// webpack.config.js entry: './src/index.js'

반면 파셀은 HTML 파일을 먼저 읽고, 그 안에서 <script src="index.js>, <link href="style.css"> 같은 디펜던시를 추적한다. 그래서 설정 없이도 모든 파일 타입이 동작한다.

Vite

vite logo
빠른 개발 환경이 강점인 Vite

Vite는 이름부터 다른 "팩", "롤업", "소포(parcel)"과 달리 "빠르다"라는 불어로, 기존 번들러들보다 상당히 빠른 개발 환경을 제공한다. 이는 Rollup의 번들링과 ESBuild라는, Go로 작성된 번들러의 빌드 시스템을 조합한 덕분이라고 한다.

ESBuild는 2020년도에 등장한 파격적인 번들러였다. Go로 작성되어 JavaScript 기반의 번들러보다 10배에서 100배까지 빠른 퍼포먼스를 보여줬지만, 메이저 버전이 한참동안 릴리즈되지 못하면서 안정성 관련 이슈를 완전히 해결하지 못했다는 평가를 받는다.

Turbopack

turbopack logo
Vercel에서 개발해, Vite보다 훨씬 빠르다고 주장하는 Turbopack

Turbopack은 Next.js를 개발한 Vercel에서 개발한 번들러로, Next.js에 최적화된 구조를 가지고 있다. 성능 좋은 언어인 Rust로 개발되어 매우 빠르지만, 아직은 실험적인 편이다. 이번 포스팅 맨 앞에 언급한, 김세진님의 블로그의 Vite와 Turbopack을 비교한 글의 번역은 Turbopack이 Vite에 비해 10배나 빠르다는 Vercel의 광고는 과장광고라는 내용이다.