UPDATE: 2023.05.23
최근 저는 JavaScript 의 모듈과 번들러에 대해서 관심을 가지고 학습하고 있어요. 처음으로 NPM 에 배포를 해보았는데, 그동안 모듈이니 번들러니 이름만 많이 들어봤지 제대로 학습해본 적은 없다는 것을 깨닫게 되었어요. 그도 그럴 것이 저는 모듈과 번들러에 대해서 자세히 알지 않아도 개발 입문에는 큰 무리가 없는 시점에 공부를 시작했거든요. 이미 JavaScript 모듈 표준이 잘 쓰이고 있었고, 당시에는 Create-React-App 이 리액트의 표준처럼 사용되고 있었기 때문에 번들러를 직접 구성할 필요가 없었던 것도 한 몫 했죠. 생각해보면 리액트 공식 문서에 번들러에 대한 내용이 있었지만 공부를 시작하는 입장에서 공감되지 않는 내용들 투성이라 기피했던 것도 사실인 것 같아요.
하지만 無 에서부터 package.json , 트랜스파일러, 번들러까지 직접 구성해보았던 경험으로 JavaScript 의 모듈과 번들링이 더이상 기피할 대상이 아니라는 것을 알게 되었어요. 많은 자료들과 오픈소스, 예제들을 참고한다면 쉽게 할 것 같았지만 실제로는 대부분 저에게 없는 지식을 요구하는 작업들이었어요. 그래서 JavaScript 의 모듈과 번들러 대해서 파헤쳐보기로 했죠.
호랑이가 담배 피던 시절, 브라우저에서 JavaScript 는 두 가지 방식으로 작동했어요.
<script src="features.js"></script>
먼저 프로젝트에 대해 하나의 거대한 JavaScript 를 만들어서 사용하는 방식이에요. 이것은 스코프 충돌, 크기, 가독성, 유지보수에 문제를 발생시킬 수 있었어요. 거대한 프로젝트에서 계속 이런 방식으로 일할 수는 없었어요.
...
<script src="feature1.js"></script>
<script src="feature2.js"></script>
<script src="feature3.js"></script>
<script src="feature4.js"></script>
...
두 번째로, 각 기능에 대한 스크립트를 HTML 에 추가하는 방식이에요. 이 방식은 협업 관점에서는 조금 더 나았지만 여전히 스코프 충돌이 발생했고, 너무 많은 스크립트로 인해 네트워크의 병목을 유발하는 원인이 될 수 있었어요. 왜냐하면 HTTP/1.1 에서는 하나의 요청에 대해 응답을 받아야 다음 요청을 할 수 있었거든요.
모듈과 HTTP
HTTP/1.1 은 Keep-Alive 가 기본 설정이라 하나의 TCP 세션 안에서 여러 요청을 할 수는 있었어요. 이걸로 요청마다 TCP 세션을 만들지 않아도 되기 때문에 어느정도의 성능을 보장할 수 있었지만 요청에 대한 응답이 와야 다음 요청을 할 수 있었어요. 여전히 너무 많은 스크립트가 네트워크 병목 현상으로 이어질 수 있다는 것을 의미했죠. 파이프라인이라는 기술로 일부 해결할 수는 있었지만 실전에서 다루기 까다로워 잘 사용하지 않았다고 해요.
HTTP/2 는 multiplex 로 하나의 연결 안에서 하나 이상의 요청을 병렬적으로 처리할수 있도록 했어요. 네트워크 병목 현상은 HTTP/2 출시 이후 '일부' 해결 되었다고 볼 수 있어요. 하지만 성능 최적화와 관련해서 여전히 webpack 과 같은 번들러가 유효해요.
IIFE(Immediately invoked function expression) 는 대규모 프로젝트에서 스코프 충돌 문제를 해결했어요. 스크립트 파일을 즉시 실행 함수로 감싸면 스코프 충돌에 대한 걱정없이 파일을 안전하게 연결하거나 결합할 수 있었어요. 예를 들면 이렇게요.
module.js
var MODULE = (function() {
var message = "Hello, module!";
function sayHelloModule() {
console.log(message);
}
return {
sayHelloModule
};
})();
script.js
(function() {
var message = "Hello, script!";
function sayHelloScript() {
console.log(message);
}
sayHelloScript();
MODULE.sayHelloModule();
})();
index.html
<html>
<head>
<script src="module.js"></script>
<script src="script.js"></script>
</head>
<body>
</body>
</html>
message
라는 변수를 전역 스코프를 오염시키지 않으면서 각각의 파일에서만 사용되는 변수로 사용할 수 있었죠. 이유는 아시다시피 var 는 전역 스코프 혹은 함수 스코프를 갖기 때문이에요.
// 에러가 발생하는 코드
<html>
<head>
<script src="script.js"></script>
<script src="module.js"></script>
</head>
<body>
</body>
</html>
하지만 개발자는 모듈과 스크립트간 의존성 순서를 알고 있어야 했어요. 모듈을 사용하는 스크립트보다 모듈의 정의가 앞서야 했죠. 위와 같은 코드는 에러가 발생해요. 이것은 특히 스크립트가 겹치고 중첩될 수 있는 방식으로 많은 종속성을 갖기 시작하면서 대규모 프로젝트에서 관리하기 어려울 수 있었어요.
음, 결국 즉시 실행 함수로 모듈을 조금 더 잘 활용할 수는 있게 되었지만 조금 더 근본적인 문제를 해결해줄 수 있는 모듈 시스템이 필요했어요.
V8 엔진 출시 이후, CommonJS 가 개발되었어요. 이는 자바스크립트를 브라우저 외부 환경의 서버나 컴퓨터에서 사용하기 위한 모듈 시스템으로 개발되었죠. CommonJS 에서는 별도의 구성 없이 require
및 exports
함수로 필요한 파일에서 모듈을 불러와 사용할 수 있었어요. 스코프 충돌 문제도 해결했죠.
module.js
var message = 'Hello, module!'
function sayHelloModule() {
console.log(message);
}
module.exports = sayHelloModule
script.js
var sayHelloModule = require("./module")
var message = 'Hello, script!'
function sayHelloScript() {
console.log(message);
}
sayHelloScript();
sayHelloModule();
다만, 안타깝게도 CommonJS 은 태생부터가 비동기적 로드를 고려하지 않았기 때문에 브라우저에서 사용할 수 없었어요. 아예 불가능한 것은 아니었지만 제시되는 방법들이 모두 임시방편일 뿐이었고, 오히려 JavaScript 런타임인 Node.js 가 CommonJS 의 명세를 따르면서 NPM 과 함께 파죽지세로 성장해갔죠.
결과적으로 JavaScript 가 가장 일반적으로 사용되는 브라우저에서는 CommonJS 가 최적의 모듈 시스템은 아니었던 거에요. 브라우저에서도 모듈을 사용하기 위한 방법이 필요했고 이때, CommonJS 의 문법을 브라우저가 이해할 수 있도록 번들링해주는 최초의 JavaScript 번들러 Browserify 가 탄생했어요.
CommonJS 와 반대로 AMD(Asynchronous Module Definition) 는 브라우저 환경에서 비동기적으로 모듈을 로드하기 위해서 만들어졌어요. AMD 는 require
및 define
함수로 필요한 파일에서 모듈을 불러와 사용할 수 있었어요. AMD 명세로 구현된 대표적인 모듈 로더는 RequireJS 가 있어요.
AMD 의 문법을 직접 작성할 일은 거의 없을 거 같아 자세히 소개하지는 않을게요. 하지만 Rollup 이라는 번들러는 ECMAScript Module 을 지원하지 않는 브라우저 호환성을 위해 모듈 로더로써 RequireJS 를 지원하기도 해요.
ECMAScript 2015 에서 공식적으로 모듈 시스템이 도입되었어요. ES 모듈은 JavaScript 모듈 시스템의 표준이기 때문에 모든 최신 브라우저에서 지원돼요. 만약 ES 모듈을 지원하는 브라우저라면 이전까지 사용되었던 webpack 이나 RequireJS 와 같은 커스텀 모듈 로더가 필요 없어진 셈이죠. ES 모듈은 import
와 export
키워드를 사용하여 모듈을 정의하고 사용해요. 당연히 모듈 각각은 독립적인 스코프를 가져요.
module.js
const message = 'Hello, module!'
function sayHelloModule() {
console.log(message);
}
export { sayHelloModule }
script.js
import { sayHelloModule } from './module'
const message = 'Hello, script!'
function sayHelloScript() {
console.log(message);
}
sayHelloScript();
sayHelloModule();
index.html
<html>
<head>
<script type="module" src="script.js"></script>
</head>
<body>
</body>
</html>
ES 모듈은 정적인 모듈 구조를 가지고 있어요. 이는 모듈 간의 의존성 관계가 런타임이 아니라 정적으로 분석될 수 있다는 것을 의미해요. 모듈의 의존성을 정적으로 알 수 있기 때문에, 순환 참조와 같은 문제가 발생하면 빌드 단계에서 바로 감지하여 오류를 보고할 수 있어요. 또 정적 분석 될 수 있다는 특성 덕분에 webpack, Rollup, Parcel 과 같은 번들러로 트리 쉐이킹(사용되지 않는 코드를 제거하여 번들 크기를 최소화하는 기술)도 가능해요. 또 특정 모듈을 필요한 시점에 동적으로 로드할 수 있는 지연 로딩이라는 기능도 제공했어요. 이 기능은 Code Splitting 기법과 연관이 있어요.
작성일 기준으로 JavaScript 에서 ECMAScript Module 이 나온지 벌써 8년이 지났어요. ES 모듈 의 등장 이후 JavaScript 진영에서 다양한 번들링 및 빌드 툴들이 탄생했고 파죽지세로 성장하기 시작했어요. webpack, Rollup, Parcel 부터 esbuild, Vite, Snowpack, wmr, Turbopack 까지. 다음 포스트에서는 우선 webpack, Rollup, Parcel 에 대해서 알아볼게요.