모듈 시스템
자바스크립트는 사실 오늘날처럼 복잡한 어플리케이션을 만들기 위해 설계된 언어가 아니다.
초기에는 웹 페이지의 동적인 요소를 처리하기 위한 목적으로 만들어졌지만, 빠르게 발전하는 웹 생태계에 따라 웹 페이지의 규모도 점차 커지면서 초기 자바스크립트로는 더 이상 따라가기 벅찬 상황이 되었다. 이러한 상황에 따라 다양한 자바스크립트 라이브러리와 프레임워크가 출시되었고, 지속적인 ECMAScript 표준 명세의 업데이트가 이루어지고 있는 것이다.
모듈 번들러를 설명하기에 앞서, 먼저 '모듈'이 무엇인지에 대해 알아야 한다. '모듈'은 **기능에 따라 분리된 ‘코드와 데이터의 묶음’**을 의미한다.
모듈 시스템의 탄생 전
이러한 '모듈' 시스템이 없다는 것은, 쉽게 말해 코드와 데이터들이 기능이나 역할에 따라 분리되지 않고 모두 섞여있다 라는 뜻이 된다.
실제로, 자바스크립트 파일은 HTML의 script
태그로부터 불러와지는데 이때 우리가 아무리 자바스크립트 파일을 기능별로 분리해서 개발하고, script
태그 내에서 자바스크립트 파일을 불러오는 순서를 조정한다 하더라도, 모두 하나의 파일에 동작하는 것처럼 같은 스코프를 공유한다.
이렇게 모든 모듈이 같은 스코프에서 같은 전역 객체를 공유하게 되면, 변수명 충돌로 인한 오버라이딩이나, 의존성 관리의 어려움 등 여러 문제가 발생하게 된다.
- 변수명 충돌로 인한 오버라이딩
쉽게 예를 들어, A.js 파일과 B.js 파일이 각각 다음과 같은 코드를 가지고 있다고 가정해보자.
A.jsvar a = 1; // B.js var a = 2;
이렇게 두 파일이 각각 다른 파일에서 같은 변수명인 a
을 사용하고 있을 때, 이 두 파일을 모두 script
태그로 불러온다면, 먼저 정의된 변수 a
는 뒤에 정의된 변수 a
에 의해 오버라이딩되는 문제가 발생한다.
즉, 모듈 간 스코프의 구분이 되지 않아 다른 파일의 변수나 함수를 조작할 수 있게 되고, 이는 예상치 못한 오류나, 코드의 유지보수에 치명적인 이슈가 될 수 있다.
- 의존성 관리의 어려움
다음과 같은 세 개의 모듈이 있다고 가정해보자.
<script> src="moduleA.js"> </script> <script> src="moduleB.js"> </script> <script> src="moduleC.js"> </script>
만약 논리적으로 모듈 C
가 모듈 A
, 모듈 B
에 의존성이 있고, 모듈 B
가 모듈 A
에 의존성이 있다면, 이 순서는 정확하게 지켜져야 할 것이다. 하지만 프로젝트의 규모가 점점 커질수록 어떤 스크립트가 어떤 다른 스크립트에 의존하는지 추적하기가 어려워지기 때문에 예상치 못한 에러가 발생하거나, 코드를 유지보수하기 어려워진다.
모듈 시스템의 탄생과 발전
웹 생태계가 엄청난 성장을 하면서 '자바스크립트가 클라이언트 사이드 뿐만 아니라 서버 사이드에서도 동작해야 한다' 라는 의견이 나오기 시작했다. 클라이언트와 서버를 같은 언어로 통합한다면 리소스 측면에서 많은 이점이 있기 때문이었다. 하지만 당시 자바스크립트는 브라우저 위에서만 동작했고, 서버 사이드에서 사용할 수 있을 정도의 기능이나 표준이 마련되어 있지 않은 상태였다. 서버 사이드에 대한 니즈가 점차 확산됨에 따라 서버 사이드 동작에 있어 가장 골칫덩이인 모듈 시스템의 부재를 해결하기 위해 여러 개발자들이 모여 모듈 표준을 만들기 시작했고,
그 첫 탄생이 바로 CommonJS이다.
CommonJS
CommonJS
는 말그대로 범용 자바스크립트라는 뜻으로, 자바스크립트를 브라우저 뿐만 아니라 범용적인 환경에서 사용할 수 있도록 모듈화 명세를 만든 자발적 그룹이다. 이들은 모듈을 정의하는 방법과 모듈을 불러오는 방법을 정의했고 이러한 명세는 Node.js
의 표준이 되었다.
CommonJS의 문법은 매우 간단한데, module.exports
, 숏컷인 exports
를 사용하여 모듈을 정의하고, require
를 사용하여 모듈을 불러올 수 있다.
moduleA.jsmodule.exports = { a: 1, b: 2, }; // moduleB.js const moduleA = require("./moduleA"); console.log(moduleA.a); // 1 console.log(moduleA.b); // 2
이처럼 module.exports
라는 전역 객체에 값을 할당한 뒤, 다른 파일에서 require
로 해당 모듈을 불러와 module.exports
전역 객체에 접근하여 값을 가져올 수 있다.
CommonJS의 경우 동기적으로 모듈을 불러오는 방식을 사용한다. 즉, require
로 모듈을 불러올 때, 해당 모듈이 로드될 때까지 다음 코드로 넘어가지 않는다. (require()
함수가 동기적으로 작동한다.)
따라서 비동기적으로 동작하는 브라우저 환경에서 사용하기에는 CommonJS는 적합하지 않았다.
💡 왜 브라우저 환경에서는 CommonJS가 적합하지 않을까?
서버에서는 파일 시스템에 직접 접근할 수 있고, 필요한 모듈이나 데이터를 로컬에서 네트워크 지연 없이 빠르게 로드할 수 있는 반면에, 브라우저 환경에서는 네트워크를 통해 모듈이나 라이브러리를 로드해야 하기 때문에 브라우저 환경에서 동기적 로딩은 웹 페이지의 로딩 시간을 크게 증가시키는 문제점이 있기 때문이다.
AMD (Asynchronous Module Definition)
CommonJS에서 브라우저 환경을 위해 비동기적인 동작도 지원해야 한다는 의견이 있던 구성원들이 독립하여 AMD를 만들었다.
AMD는 비동기적으로 모듈을 로드하는 방식을 사용한다. AMD는 define
과 require
를 사용하여 모듈을 정의하고 불러올 수 있다.
moduleA.jsdefine([], function () { return { a: 1, b: 2, }; }); // moduleB.js require(["moduleA"], function (moduleA) { console.log(moduleA.a); // 1 console.log(moduleA.b); // 2 });
UMD (Universal Module Definition)
자바스크립트의 생태계가 점점 넓어지면서 AMD와 CommonJS의 모듈 시스템을 모두 지원해야 하는 상황이 생기게 되었고, 이러한 모듈 방식을 모두 지원하는 UMD가 등장했다.
ESM (ECMAScript Module)
이렇게 각종 표준안들이 발표되는 가운데, 드디어 ECMAScript2015 표준 명세에서 ECMAScript Module이 등장하며 자바스크립트에 대한 자체 모듈 시스템이 발표되었고, ESM으로 인해 자바스크립트는 자체적으로 모듈 시스템을 지원하게 되었다.
ESM은 import
와 export
를 사용하여 모듈을 정의하고 불러올 수 있다.
moduleA.jsexport const a = 1; export const b = 2; // moduleB.js import { a, b } from "./moduleA"; console.log(a); // 1 console.log(b); // 2
ESM이 등장함으로 인해 import/export 구문을 사용하여 모듈을 정의하고 정확히 어떤 변수, 함수, 객체를 내보내고 가져올지 명시적으로 정의할 수 있게 되었고, 그에 따라 모듈 간 의존성 파악이 명확해졌다.
그리고 이 때부터 <script>
태그에 type="module"
을 추가하여 모듈 형태로 자바스크립트를 불러올 수 있게 되었다.
<html> <body> <script src="./index.js" type="module"></script> </body> </html>
위와 같이 모듈 형태로 자바스크립트를 불러오면 해당 모듈에서 import 하고 있는 모듈들은 자동적으로 불러올 수 있게 되었다.
📦 모듈 번들러
모듈 번들러의 탄생 전
웹 생태계가 점점 커지면서 웹 애플리케이션의 규모도 점점 커지고, 다루어야 하는 모듈이 점점 많아지기 시작했다.
브라우저는 필요한 리소스를 불러오기 위해 네트워크를 통해 서버에 요청을 보내야 하는데, 이러한 리소스들이 많아질 수록 많은 네트워크 요청이 발생하게 되고, 이는 응답 지연, 동시 전송 문제 등 다양한 문제로 인해 웹 페이지의 로딩 속도를 느리게 만드는 원인이 되었다.
또한 모듈 간 실행 우선순위가 존재함에도 이러한 실행 순서를 제어할 수 없었기 때문에 의존성 문제가 발생하기도 했다.
특히, 모듈 번들러가 탄생하기 이전에는 HTTP 표준 스펙이 HTTP/1.1로 무거운 헤더와 HOLB(Head-Of-Line Blocking) 이슈가 있었다.
HOLB는 다중 요청은 가능하지만, 요청에 대한 응답이 끝나야만 다음 응답을 받을 수 있는 문제인데, 만약 앞에 요청한 파일이 늦게 응답을 받는다면, 뒤에 있는 파일의 응답들이 Blocking이 되기 때문에 웹 페이지의 로딩 속도가 느려지는 문제가 발생했다.
추가로 각 요청은 무거운 헤더를 가지고 있었기 때문에, 최대한 네트워크 요청을 줄이기 위해 파일을 하나로 합치는 방법이 필요했다.
모듈 번들러의 탄생
'그럼 필요한 리소스들을 모두 하나로 합쳐서 한 번만 보내면 무거운 헤더로 인한 HOLB 문제도, 의존성 문제도 없겠네'
이러한 생각에서 모듈, CSS, 이미지, 폰트 등을 정적인 파일로 변환하고 합쳐주는 모듈 번들러가 탄생하게 된다.
모듈 번들러의 특징
사실 모듈 번들러의 공통된 특징을 정의하기는 어렵다. 왜냐하면 시대에 따라 많은 빌드 도구들이 당시의 문제점을 해결하기 위해 새롭게 등장했고, 각각 장단점을 가지고 있기 때문이다.
따라서 각각의 모듈 번들러들이 어떠한 문제 상황에서 등장했고, 어떤 특징을 가지고 해당 문제를 해결하려 했는지 에 초점을 맞춰보자.
용어 정리
그럼에도 불구하고, 여러 모듈 번들러에서 자주 사용되는 기술들이 있다. 먼저 이러한 기술들을 정리해보자.
-
트리 쉐이킹(Tree-Shaking)
사용하지 않는 코드를 제거하는 기술로, 번들러가 사용하지 않는 코드를 제거하여 번들의 크기를 줄이는 기술이다.
-
코드 스플리팅(Code Splitting)
번들러가 모든 코드를 하나로 통합하는 것이 아닌, Lazy-loading을 통해 필요한 시점에 해당 번들을 요청하는 식으로 동작하여 번들을 나누고, 개별 번들 사이즈를 줄이는 방식이다.
-
HMR (Hot Module Replacement)
코드가 수정될 때마다 전체 페이지를 새로고침하지 않고, 수정된 부분만 교체하는 기술로, 개발자가 코드를 수정하고 저장할 때마다 번들을 다시 빌드하고 페이지를 새로고침하는 번거로움을 줄여준다.
모듈 번들러의 발전

Webpack (2014)
Webpack은 위 그림에서 볼 수 있다시피, 2015년 ESM 모듈 시스템이 등장하기 전에 출시되었다.
따라서 초기 Webpack은 ESM 모듈 시스템이 채택되기 전으로, CommonJS인 cjs 포맷만을 지원했다. (동기적인 방식으로 모듈을 불러왔다.)
2020년 10월 릴리즈된 Webpack v5부터 ESM 포맷도 지원하게 되며 현재 가장 널리 사용되는 정적 모듈 번들러가 되었다.
Webpack은 많은 기능과 확장성으로 파일 통합 뿐만 아니라 개발 편의 기능까지 제공하는데, 앞서 설명한 트리 쉐이킹, 코드 스플리팅, HMR 등의 기능을 모두 제공하며, **로더(loader)**와 **플러그인(plugin)**을 통해 다양한 기능을 확장할 수 있다.
Webpack은 자바스크립트로 구현되어 있으며, 규모가 크고 복잡한 애플리케이션을 관리하는 것에 중점을 두고 설계되었다.
또한 내부적으로 Babel 로더를 사용하여 ES6 이상의 자바스크립트 문법을 ES5로 변환할 수 있다.
-
Webpack의 문제
하지만 Webpack은 초기 설정이 복잡하고 러닝커브가 높다는는 단점도 존재한다. 특히 큰 프로젝트에서는 구성 설정 파일이 더욱 방대해질 수 있는데, 그로 인해 빌드 시간이 길어지는 이슈가 발생한다.
그렇지만 만약 우리가 React 프로젝트를 CRA
를 통해 생성하면, Webpack 설정을 건드릴 일이 거의 없다. CRA는 복잡한 Webpack 설정을 추상화하고 미리 템플릿으로 만들어 두어 사용자가 설정을 건드릴 필요가 없게끔 만들어주기 때문이다.
Rollup.js - 2015
Rollup은 ESM 모듈 시스템이 자리잡은 뒤 등장한 경량화와 번들 최적화를 중점에 둔 ESM 지원 모듈 번들러다.
당시 초기 웹팩에는 ESM 지원이 없었기 때문에 의존성 파악이 어려웠는데, Rollup은 ESM 지원을 통해 의존성 파악을 명확하게 수행하고, 이를 기반으로 사용하지 않는 코드를 제거하는 트리 쉐이킹을 지원하기 시작했다.
Rollup은 다양한 번들 포맷과 코드 스플리팅을 제공하지만, 자체적으로 HMR 기능이 구현되어 있지는 않다. 하지만 rollup-plugin-hot
플러그인을 통해 HMR을 사용할 수 있다.
Rollup은 현재 라이브러리를 구현하는 데에 많이 쓰이며 React 빌드 도구인 Vite 번들러의 내장 번들러로서 사용되고 있다.
Parcel - 2017
Parcel은 Webpack과 Rollup과는 다르게 복잡한 설정 없이 최소한의 설정으로 번들링을 제공하는 초보자 친화적인 번들러를 목표로 개발되었다.
Zero configuration을 목표로 하고 있어, 별도의 설정 없이 바로 사용할 수 있으며, 멀티 코어 처리와 파일 시스템 캐시를 통해 빠른 빌드와 재빌드 시간을 제공한다.
이렇듯 Parcel은 사용법이 간단하고, 빠른 성능을 갖고 있지만 그만큼 복잡한 프로젝트에서의 세밀한 최적화나 커스터마이징에 한계가 존재하며, 프로젝트의 규모가 커질 수록 파일 수와 의존성이 급격히 증가하여 캐시 관리와 멀티 코어 리소스를 효율적으로 사용하는 데에 어려움이 발생한다는 단점이 있다.
esbuild - 2020
An extremely fast bundler for the web The main goal of the esbuild bundler project is to bring about a new era of build toll performance, and create an easy-to-use modern bundler along the way
esbuild는 빌드 도구 성능에 새 시대를 열고, 사용하기 쉬운 현대적인 번들러를 만드는 것을 목표로 하는 프로젝트다.
esbuild는 Go 언어로 구현되어 있어, 빠른 빌드 속도를 자랑한다. 그 외에도 타입스크립트, JSX가 내장되어 있어 별도의 설정 없이 사용할 수 있다.
하지만 esbuild는 다른 번들러와는 다르게 HMR을 지원하지 않는다.

Snowpack - 2019
Snowpack은 Unbundled Development을 핵심 가치로하는 ESM 기반의 빌드 도구다.
기존의 Webpack과 Rollup과 같은 번들러들은 코드의 수정이 발생하면 변경된 파일을 다시 빌드하고, 전체 파일에 대한 번들링을 진행했다.
Snowpack은 ESM이 널리 사용됨에 따라 어차피 파일 간 의존성을 명명백백하게 알 수 있기 때문에 개발할 때도 이렇게 매번 번들링을 하는 과정은 불필요한 과정이라고 판단했다.
따라서 개발할 때에는 코드의 변경이 발생해도 해당 파일만 리빌드하고 캐싱하는 방식으로 빠른 개발 속도를 제공하며, 배포할 때에만 번들링을 하는 방식으로 동작하도록 설계했다.

Snowpack은 내장 빌드 도구로 esbuild를 사용했고, 실제 배포를 위한 빌드 도구로는 웹팩, 롤업과 같은 번들러를 사용할 수 있도록 했다.
Vite - 2020
Vite는 ESM을 이용한 개발서버와 배포를 위한 Rollup 최적화 빌드 커맨드를 제공하는 프론트엔드 빌드 도구이다.
Vite는 Snowpack의 배경을 그대로 이어받아 빠른 개발 서버 구동을 위한 Unbundled Development에 초점을 맞췄다.
Vite는 느린 개발 서버 문제를 해결하기 위해 소스 코드를 두 가지 영역으로 나누어 처리하도록 구현했다.
-
의존성 (Dependencies)
패키지의 디펜던시들은 개발 도중 변경이 일어나지 않는데, Vite는 이러한 의존성 모듈들을 효과적으로 관리하기 위해 Dependency pre-bundling 개념을 적용한다. 의존성들을 미리 esbuild를 통해 단일 모듈로 번들링하여 캐시 상태로
/node_modules/.vite
경로에 저장하고 이후로 캐싱된 모듈을 사용할 수 있도록 한다. -
소스코드 (Source Code)
소스 코드는 개발자가 작성하는 코드로, 개발 중에 변경이 빈번하게 일어나는 영역을 말한다. Vite는 ESM을 통해 개별 파일 단위로 번들링을 하고, 변경된 파일만 다시 빌드하여 빠른 개발 서버 구동을 제공한다. 또한 브라우저가 모듈을 요청하면 그때 해당 모듈을 변환하는 'On-demand' 방식을 사용하여 필요한 모듈만 번들링하게 된다.