
개발을 하다보면 애플리케이션의 크기가 커지면서 파일을 분리해야 하는 시점 이 찾아온다. 이 분리된 각각의 파일들을 모듈(module)이라고 부른다.
근데 이 모듈은 언제부터 생겨난 걸까?
현대의 자바스크립트는 개발했을 당시의 목적과는 다르게 아주 복잡하고 다양한 임무를 맡고 있다. 그러나 처음 의도는 이렇게 복잡하게 사용할 것을 생각하지 않았기에 모듈을 분리할 수 있는 표준 문법이 없이 없었고, 그래서 모듈을 구성하기 위한 다양한 시도들이 있었다.
가장 오래된 모듈 시스템 중 하나로 require.js라는 라이브러리를 통해 처음 개발되었다. Asynchronous라는 이름에 걸맞게 비동기적으로 동작한다는 특징이 있고, 웹브라우저에서 사용하는 방식이었다.
CJS는 node.js 환경에서 사용하기 위해 고안된 모듈 시스템이다. 특징으로는 동기적으로 작동한다는 것인데, 이로 인해 모듈을 가져올 때 Blocking이 일어나 모든 동작이 멈추기 때문에 브라우저 환경에서는 적합하지 않다. 그래서 추후 개발된 모듈 번들러를 통해 번들링 과정에서 트랜스파일링을 거쳐 브라우저에서도 사용할 수 있게 된다.
UMD는 앞서 언급한 CJS와 AMD를 구분하지 않기 위한 목적으로 고안된 패턴이다.
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define([], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS
module.exports = factory();
} else {
// browser
root.isDev = factory();
}
})(this, function() {
return process.env.NODE_ENV === 'development';
});
위의 코드에서 살펴볼 수 있듯이 조건에 따라 CJS와 AMD를 제공하는 방식으로 사용한다.
ESM이 등장하기 전, 수많은 개발자들은 다양한 모듈 시스템을 사용했고, 이에 호환성 문제가 대두되었다.
그래서 모듈 번들러는 위와 같이 다양한 형태의 모듈 시스템을 각종 브라우저에 호환 가능하도록 하기 위해 등장했다.
이렇게 다양한 모듈 번들러들은 초기에는 브라우저 호환성 문제를 해결하기 위해 등장했지만, 코드최적화(트리 쉐이킹, 코드 스플리팅), CSS, 이미지 등 다양한 자원의 통합관리, 개발환경 지원, 레거시 브라우저 지원을 위한 트랜스파일링 등 다양한 기능을 지원하며 강력한 도구로 진화해오고 있다.
ESM은 ES6에서 추가된 모듈 시스템으로 드디어 공식적으로 JS에서 모듈을 사용할 수 있도록 추가된 문법이다. import와 export 키워드를 사용하여 모듈을 가져오고 내보낼 수 있다.
// math.js (모듈 내보내기)
export function add(a, b) {
return a + b;
}
// main.js (모듈 가져오기)
import { add } from './math.js';
console.log(add(2, 3)); // 5
또한 번들링 도구를 사용하지 않고도 네이티브로 지원하기 때문에 HTML에 모듈을 직접 로드할 수 있게 되었다.
<script type="module">
import { add } from './math.js';
console.log(add(2, 3));
</script>
오늘날 여전히 많은 프로그래밍에서 사용하고있는 CJS와 ESM은 동기적이냐 비동기적이냐는 특성에서 가장 큰 차이가 난다.
앞서 CJS의 경우 동기적인 방식으로 작동하기 때문에 해당 모듈을 실행할 때면 모든 작업이 Blocking된다는 특징을 언급했었다.
이는 CJS가 처리되는 방식과 관련이 있는데, 기본적으로 CJS는 해당 모듈을 부르는 코드를 만날 때까지, 코드를 실행하며 그 전에는 해당 모듈을 위한 어떠한 작업도 하지 않는다. 이로 인해 얻을 수 있는 장점은 조건부로 사용이 가능하다는 것이고, 이것이 동적 구조를 가진다고 표현하는 것이다.
// CommonJS - 허용됨
const math = require('./math');
// CommonJS - 허용됨 (함수 내부)
function loadMathIfNeeded() {
const math = require('./math');
return math.add(2, 3);
}
// CommonJS - 허용됨 (조건문 내부)
if (needMath) {
const math = require('./math');
console.log(math.add(2, 3));
}
ESM의 경우는 다르다. ESM은 import문이 최상단에 항상 위치해야 하는데 이는 실행 전에 미리 모듈 그래프를 구축하기 때문이다. 이로 인해 기본적으로 필요한 코드만 참조할 수 있게 되고, 이는 트리쉐이킹을 가능하게 한다.
// math.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
export function divide(a, b) { return a / b; }
// app.js
import { add, subtract } from './math.js';
console.log(add(5, 3));
이 예시를 보면 app.js는 결국 math.js에 있는 add와 subtract를 가져와서 번들링을 하게 된다.
하지만 CJS를 사용하게 될 경우 어떻게 될까?
// CommonJS 버전
const math = require('./math'); // 전체 모듈을 가져옴
console.log(math.add(5, 3));
CJS의 경우 모듈이 필요할 때 바로 불러오게 되고, 이는 런타임에서 어떤 함수가 사용될지 미리 알 수 없기 때문에 math.js모듈 전체를 들고올 수 밖에 없다. 이로 인해 불필요한 나머지 메서드가 함께 번들링되어 번들 사이즈가 커진다.
// app.js (CommonJS)
const { add } = require('./math');
console.log(add(5, 3));
간혹 이렇게 사용하면 결국 add만 번들링 되는 것이라고 오해하는 사람들이 있는데, 실제 CJS는 math.js의 다른 함수들이 언제 사용될 것이라는 보장이 없기 때문에 math.js 전체를 함께 번들링하는 방식으로 작동하니 유의하도록 하자.
ESM에서는 import를 함수로 사용할 수 있다. 이는 Promise를 반환하게 되는데, import에 인수로 전달된 경로의 js파일을 비동기적으로 로드하기 때문이다.
번들러는 import()를 번들링 과정에서 만나게 되면 해당 파일을 다른 파일로 분리하게 된다. 이를 코드 스플리팅이라고 한다.
React에서는 이렇게 코드 스플리팅 된 컴포넌트를 지연로딩하기 위해 lazy를 제공한다.
// React Router와 함께 사용하는 코드 분할 예시
import React, { lazy, Suspense } from 'react';
import { Route, Switch } from 'react-router-dom';
// 동적 import를 사용한 지연 로딩
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/dashboard" component={Dashboard} />
<Route path="/settings" component={Settings} />
</Switch>
</Suspense>
);
}
이를 통해 번들링되는 js청크사이즈를 줄일 수 있고, 해당 컴포넌트가 호출되기 전까지 이 내용을 로드하지 않기 때문에 최적화가 가능한 것이다.
비동기적으로 로딩을 할 때는 이전에 ESM에서 불가능했던 조건부 로드가 가능하다.
// ESM - 동적 import 사용
if (needMath) {
import('./math.js')
.then(math => {
console.log(math.add(2, 3));
})
.catch(err => {
console.error('모듈 로드 실패:', err);
});
}
// ESM - async/await 사용
async function loadMathIfNeeded() {
if (needMath) {
const math = await import('./math.js');
return math.add(2, 3);
}
return 0;
}
왜냐하면 코드 스플리팅으로 분리된 파일을 import()함수를 만나는 시점에 평가하게 되고, 이에 대한 처리를 브라우저가 비동기로 진행하기 때문에 가능해 지는 것이다.
이제 CJS와 ESM에 대해서 큰 그림이 그려졌다.
그런데 파일 확장자 중에 .cjs, mjs를 본 적이 있을 것이다. 이것은 무엇일까?
두 확장자 모두 해당 js파일이 어떤 모듈 시스템을 사용하는 것인지 명시적으로 구분하기 위해 사용하는 확장자이다. 각 모듈 시스템을 명시적으로 구분해야 하는 상황은 결국 node.js환경이다.
mjs - .mjs 확장자는 "ECMAScript Module JavaScript"의 약자로, 명시적으로 ES 모듈(ESM) 형식으로 작성된 JavaScript 파일을 나타낸다.
cjs - .cjs 확장자는 명시적으로 CommonJS 모듈임을 나타낸다.
package.json의 "type" : "module" 설정을 본 적이 있을 것이다. 이는.js 확장자를 기본적으로 어떤 모듈 시스템으로 취급할 것인가를 설정하는 옵션이다.
default 옵션 : "type"옵션을 사용하지 않을 때, js는 cjs로 취급된다.
"type" : "module" : 설정을 하게 되면 js는 mjs로 취급을 받는다는 사실을 기억하자.
오늘은 자바스크립트 모듈 시스템에 대해서 알아보았다. 평소에 그냥 코드를 따라치면서 습관처럼 된 모듈 시스템이 어떻게 발전하고, 어떻게 구분되는 지 이제야 이해할 수 있었다. 여기서 더 나아가서 각각 CJS와 ESM이 모듈 간의 변수 스코프를 어떻게 관리하길래 충돌이 나지 않는지, 두 모듈이 서로를 참조하고 있을 때(순환 의존성 - Circular Dependencies) 이를 어떻게 처리하는지에 대한 깊이있는 고민들을 해볼 수 있겠지만, 오늘은 이정도의 배움도 충분한 것 같다!