자바스크립트의 모듈 시스템과 번들러
1. 개요: 왜 모듈과 번들러를 알아야 하는가?
항상 쓰고 있지만 잘 모르고 있는 번들러에 대해서 학습을 하면서 번들러를 이해하려면 모듈 시스템부터의 이해가 필요하다는 것을 느꼈다.
모듈 시스템의 역사와 발전 과정부터 왜 번들러가 등장했고, 번들러는 어떤 역할을 수행하는지, 그리고 추가적인 궁금증에 대해서 알아보자.
2. 모듈 시스템의 역사: 전역 스코프에서 ESM까지
초창기 전역 스코프
초창기의 자바스크립트는 파일 단위의 독립적인 스코프를 지원하지 않았다. 그래서 모든 변수가 전역 스코프를 공유했다. 이로 인해서 여러 스크립트 파일을 로드할 때 식별자가 충돌하거나 나중에 로드된 파일의 변수나 함수로 이전 파일의 변수나 함수를 덮어쓰는 문제가 존재했었다.
그리고 브라우저에서 <script> 태그를 만날 때마다 네트워크 요청을 보낸다. 즉, <script> 태그가 많을수록 네트워크 요청이 발생하게 되고,
이는 서비스의 지연을 초래한다.
<script>
var a = 3;
const foo = 'aabc';
</script>
<script>
var a = 1;
const foo = 'aabc';
</script>
// `a` 변수는 3 -> 1로 오버라이드되고, `foo` 변수는 충돌하게 된다.
이러한 문제를 해결하기 위해서 자바스크립트의 모듈 시스템이 등장하는데 이때 요구되는 환경에 따라 다른 모듈 시스템들이 생겨난다.
CommonJS (CJS)
서버 사이드 자바스크립트 환경이 Node.js를 위해 설계된 모듈 시스템으로 require()와 module.exports를 사용한다.
모든 파일이 로컬 디스크에 있다는 가정 하에 동작하기 때문에 동기적으로 모듈을 로드한다.
AMD (Asynchronous Module Definition)
브라우저 환경에서의 비동기 로딩을 목적으로 등장했으며 define() 함수를 사용한다.
UMD (Universal Module Definition)
CJS와 AMD 양쪽에서 모두 동작할 수 있도록 설계된 디자인 패턴이다.
ESM (ES Modules)
2015(ES6)에 등장한 자바스크립트 공식 표준 모듈 시스템이다. import, export 구문을 사용한다. 대부분의 브라우저와 최신 Node.js 환경에서 이를 지원한다.
3. CommonJS(CJS)와 ECMAScript Modules(ESM)의 차이
현재 가장 많이 쓰이는 모듈 시스템은 CJS와 ESM이다. 특히 CJS는 Node.js 환경에서 활발하게 쓰인다.
이 두 시스템은 모듈의 로드 방식에서 큰 차이를 보인다.
| 구분 | CommonJS (CJS) | ECMAScript Modules (ESM) |
|---|---|---|
| 동작 방식 | 동기적 (Synchronous)require()는 실행 시점에 즉시 디스크(또는 네트워크)에서 파일을 읽어 실행하는 동기 방식이다. | 비동기적 (Asynchronous) 구성-인스턴스화-평가 3단계를 거치며 각 단계는 비동기 수행이 가능하다. |
| 데이터 전달 | 값의 복사 (Value Copy) CJS는 모듈을 가져올 때 내보낸 값의 복사본을 제공한다. 그래서 내보낸 쪽에서 값이 바뀌어도 가져온 쪽에서는 반영되지 않는다. | 라이브 바인딩 (Live Bindings) ESM은 내보낸 쪽과 가져온 쪽이 메모리의 동일한 위치를 가리키는 라이브 바인딩 방식을 사용하여 값이 변경되어도 양쪽 모두에게 반영된다. |
| 구조 및 최적화 | 동적 구조 CJS는 모듈을 가져올 때 조건문 혹은 반복문 내에서도 모듈을 가져올 수 있다. 따라서 어떤 모듈을 가져올 지는 런타임 시에 결정이 된다. | 정적 구조 ESM은 import와 export가 반드시 최상위 레벨에서 쓰여야 한다. 또한 조건문 내에서 쓰지 못한다. 이 때문에 정적 분석이 가능하여 실제로 코드를 실행하지 않고도 어떤 코드가 사용되는지 파악이 가능하여 Tree-Shaking을 수행할 수 있다. |
4. 번들러의 등장 배경과 필요성
초기에 번들러가 등장한 이유는 모듈 시스템이 등장한 이유와 비슷하다. 자바스크립트가 파일 별 스코프를 제공하지 않기 때문에 처음부터 모든 파일을 하나로 합쳐서 보낸다면 식별자 충돌이나 오버라이딩의 문제를 방지할 수 있을 것이다.
하지만 모듈 시스템이 등장하고 독립적인 스코프를 제공하게 되면서 번들러의 이러한 해결 방법은 사실 의미가 없어졌다.
그런데 왜 아직도 활발하게 번들러를 쓰고 있는 이유는 바로 네트워크 효율성과 성능 최적화때문이다.
- HTTP 요청 최적화: 브라우저가 수백 개의 ESM 모듈을 개별적으로 요청하는 것과 소수의 파일로 묶어 요청하는 것을 생각하면 된다. 여러 파일을 소수의 파일로 묶어 네트워크 요청 횟수를 줄일 수 있다.
- 의존성 해결: 모듈 간 의존성을 파악하고 올바른 실행 순서를 보장한다.
- 환경 호환: 최신 자바스크립트 문법이나 타입스크립트를 구형 브라우저가 이해할 수 있는 ES5 형태의 자바스크립트로 변환을 하는 역할을 수행한다.
5. 번들러의 동작 원리: 의존성 그래프와 최적화
번들러는 생각보다 많은 일을 한다. 단순히 파일을 합치는 것을 넘어, 전체 애플리케이션의 구조를 파악하고 최적화하는 과정을 거친다. 그럼 어떻게 각각의 일들을 수행하는지 알아보자.
의존성 분석 (Dependency Analysis)
번들링의 시작은 진입점(Entry Point) 설정에서 시작된다. 번들러는 설정된 진입 파일(예: index.js)을 시작으로, 해당 파일이 import나 require를 통해 불러오는 다른 모듈들을 재귀적으로 추적한다.
이 과정에서 번들러는 코드를 단순히 텍스트로 읽는 것이 아니라, **추상 구문 트리(AST, Abstract Syntax Tree)**를 생성하여 구문 분석을 수행한다. 이를 통해 정적으로 어떤 모듈이 필요한지 정확하게 파악할 수 있다.
의존성 그래프 구축 (Dependency Graph)
분석된 정보를 바탕으로 번들러는 파일들 간의 관계를 나타내는 **의존성 그래프(Dependency Graph)**를 구축한다. 이 그래프는 애플리케이션이 실행되기 위해 필요한 모든 모듈과 그들 사이의 연결 고리를 보여주는 지도와 같다.
의존성 그래프를 통해 번들러는 다음과 같은 문제를 해결한다.
- 중복 로딩 방지: 여러 곳에서 사용되는 동일한 모듈을 한 번만 포함시켜 중복을 제거한다.
- 실행 순서 보장: 모듈 간의 의존 관계에 따라 올바른 실행 순서를 결정한다.
- 순환 참조 감지: 모듈들이 서로를 무한히 참조하는 문제를 사전에 발견하여 경고한다.
변환 및 번들링 (Transformation & Bundling)
그래프가 완성되면 각 모듈을 브라우저가 이해할 수 있는 형태로 변환하고 하나로 묶는 과정을 거친다. 이때 번들러의 기능을 확장하는 핵심 요소인 로더(Loader)와 플러그인(Plugin)이 각기 다른 시점에서 중요한 역할을 수행한다.
- 로더(Loader): 파일 단위의 변환기 로더는 개별 파일 단위로 동작한다. 자바스크립트가 아닌 자원(TypeScript, CSS, Image 등)을 자바스크립트 모듈로 변환하거나, 최신 문법을 구형 브라우저용으로 트랜스파일링하는 역할을 한다. "이 파일은 이렇게 읽어서 변환해라"라는 지침을 수행하는 실무자와 같다.
- 플러그인(Plugin): 번들 결과물 및 전체 공정의 제어기 로더가 개별 파일 단위로 움직인다면, 플러그인은 번들링된 결과물 전체 혹은 번들링 과정 자체를 조작한다. 최종 번들링된 파일에서 불필요한 공백을 제거하거나(Minification), 빌드 시점에 환경 변수를 주입하고, 번들된 파일을 자동으로 HTML에 삽입하는 등 광범위한 최적화와 후처리를 담당한다.
마지막으로 이러한 과정을 거쳐 최적화된 파일이 생성되며, 이 과정에서 사용하지 않는 코드를 제거하는 트리 쉐이킹(Tree Shaking) 등이 함께 수행되어 최종 결과물의 크기를 최소화한다.