JavaScript Module System(2) - UMD와 ESM
JavaScript 모듈 시스템의 발전 과정을 이어서 살펴보면, AMD와 CommonJS는 각각 브라우저와 서버 측 환경에 최적화된 솔루션을 제공했지만, 두 환경을 모두 지원하는 모듈을 작성하는 데에는 어려움이 있었습니다.
UMD는 이러한 문제를 해결하고자 등장했습니다.
UMD (Universal Module Definition)
UMD는 AMD와 CommonJS 모듈 시스템을 결합하여, 동일한 모듈이 브라우저 환경과 Node.js 환경에서 모두 동작할 수 있도록 하기 위해 만들어졌습니다.
이를 위해 UMD는 실행 환경을 자동으로 감지하고, 해당 환경에 맞는 모듈 정의 방식을 사용합니다. 이를 통해 모듈의 재사용성과 호환성을 높였습니다.
어떻게 AMD와 CommonJS 모듈 시스템을 결합할까?
⇒ 주요 환경을 감지하고 각 환경에 맞는 방식으로 모듈을 정의해서 사용합니다.
- AMD 환경:
define
함수가 정의되어 있는지 확인합니다.- AMD 환경에서는
define
함수를 사용하여 모듈을 정의합니다.
- CommonJS 환경:
module
과module.exports
가 정의되어 있는지 확인합니다.- CommonJS 환경에서는
module.exports
를 사용하여 모듈을 내보냅니다.
- 전역 변수(Global) 환경:
- AMD와 CommonJS 환경이 모두 아닌 경우, 브라우저의 전역 변수를 사용하여 모듈을 정의합니다.
(function (root, factory) { if (typeof define === 'function' && define.amd) { // AMD 환경 define([], factory); } else if (typeof module === 'object' && module.exports) { // CommonJS 환경 module.exports = factory(); } else { // 전역 변수 (브라우저) 환경 root.MyModule = factory(); } }(typeof self !== 'undefined' ? self : this, function () { // 모듈 정의 return { sayHello: function() { return 'Hello, world!'; } }; }));
장점
- 다양한 환경 지원
하나의 모듈을 작성해 여러 환경에서 사용할 수 있게합니다.
단점
- 복잡한 구조
다양한 환경을 지원하기 위해 조건문과 설정이 많아져 코드가 복잡해질 수 있습니다.
환경 감지 순서 - AMD → CJS → 전역 변수 순서로 감지하는 것이 일반적입니다.
- 성능 저하
환경 감지와 다양한 로딩 방식을 처리하기 위한 추가 코드로 인해, 실행 성능이 약간 저하될 수 있습니다.
- 테스트의 복잡성
각 환경마다 다른 테스트 설정이 필요합니다.
*왜 환경을 감지할 때 AMD → CJS → 전역 변수 순서를 따르는게 일반적인가요?
- 브라우저 환경의 우선 순위: AMD는 브라우저 환경에서 비동기 로딩의 이점을 제공하므로 우선순위를 앞에 둡니다.
- 서버 환경의중요성: CommonJS는 서버 측 환경에서 동기 로딩의 장점을 제공하므로 두 번째로 감지합니다.
- 최후의 수단으로 전역 변수: 모듈 로더가 없는 환경에서는 전역 변수를 사용하여 모듈을 정의하며, 이는 네임스페이스 오염을 피하기 위해 최후의 수단으로 사용됩니다.
아쉬운 점은 UMD는 AMD와 CommonJS를 지원하기 위해 복잡한 조건문과 환경 감지가 필요하다는 것입니다.
이때 모듈 시스템의 표준화를 통해 모듈 관리를 개선하고 최적화 하기 위해서 ESM이 등장하게됩니다.
ESM(ECMAScript Modules)
ECMAScript 표준에 따라 JavaScript 모듈을 정의하고 사용하는 방식입니다.
JavaScript 모듈 시스템의 표준으로, 브라우저와 서버 환경 모두에서 사용됩니다.
최신 브라우저와 Node.js에서 네이티브로 지원되기에 추가적인 라이브러리나 도구 없이 모듈을 사용할 수 있습니다.
장점
- 정적 분석과 최적화 import와 export문법을 사용해 모듈간의 의존성을 명확하게 정의하여 컴파일러와 번들러가 모듈의 의존성을 정적으로 분석할 수 있게 합니다. 트리 셰이킹 → 정적 분석을 통해 사용하지 않는 코드를 쉽게 식별하여 제거함으로 번들 크기를 줄이고 애플리케이션의 성능을 최적화할 수 있습니다.
- 비동기 및 동적 로딩
ESM은 비동기와 동적 로딩을 모두 지원합니다. 기본적으로 모듈을 비동기로 로드하며, 필요에 따라
import()
함수를 사용하여 동적으로 모듈을 로드할 수 있습니다. 이는 클라이언트와 서버 측 환경 모두에서 유연한 모듈 로딩을 가능하게 합니다.- 일관된 문법과 사용성
ESM은 간단하고 일관된 문법을 제공합니다.
import
와 export
문법을 사용하여 모듈을 정의하고 로드할 수 있습니다. 이는 개발자가 모듈 시스템을 쉽게 이해하고 사용할 수 있도록 합니다.- 네임스페이스와 코드 분리
각 모듈이 고유한 네임스페이스를 가져서 전역 변수 충돌을 방지하고 기능별로 모듈을 나누어 작성해 필요에 따라 로드해서 사용할 수 있습니다. → 유지보수성과 협업 효율성 향상
- 성능 최적화
JavaScript 엔진과 번들러(Webpack, Rollup 등)가 정적 분석을 바탕으로 최적화를 수행할 수 있습니다. 이를 통해 트리 셰이킹(Tree Shaking)과 같은 최적화 기법을 쉽게 적용할 수 있습니다.
- 브라우저 및 서버 측 지원
ESM은 ECMAScript의 표준으로, 모든 현대 브라우저와 Node.js와 같은 서버 측 환경에서 네이티브로 지원됩니다. 이는 추가적인 라이브러리나 설정 없이도 모듈을 사용할 수 있게 합니다.
- strict mode
ESM 모듈은 자동으로 strict mode에서 실행됩니다. 이는 코딩 실수를 줄이고, 보다 안전한 코드를 작성할 수 있게 합니다.
import를 사용한 동적 임포트 예제
function loadModule() { import('./module.js').then(module => { module.default(); }); } document.getElementById('loadButton').addEventListener('click', loadModule);
*정적 분석이란?
코드 실행 없이 소스 코드를 분석하는 것을 의미합니다. 코드의 구조, 의존성, 사용되지 않는 코드 등을 파악할 수 있습니다.
단점
- 구형 브라우저 호환성
구형 브라우저에서는 폴리필이나 트랜스파일링이 필요합니다. 이를 위해 Babel과 같은 도구를 사용할 수 있습니다.
- 서버 사이드에서는 별도 설정 필요
Node.js에서 ESM을 사용하려면 package.json에 type:”module” 을 추가하거나 .mjs확장자를 사용해야 합니다. 이는 기존 CommonJS 모듈과 호환성 문제를 일으킬 수 있습니다.
ESM에서의 정적 분석 처리 과정
- 모듈 의존성 그래프 생성
JavaScript 엔진이나 번들러(Webpack, Rollup 등)는 소스 코드를 파싱하여, 각 모듈이 어떤 다른 모듈을
import
하는지 분석합니다. 이를 통해 모듈 간의 의존성 그래프를 생성합니다.모듈 간의 관계를 명확히 보여주며, 어떤 모듈이 어떤 다른 모듈에 의존하는지 알 수 있습니다.
// moduleA.js export const foo = 'foo'; // moduleB.js import { foo } from './moduleA.js'; console.log(foo);
위의 코드에서,
moduleB.js
는 moduleA.js
에 의존합니다. 정적 분석을 통해 이 관계가 그래프로 표현됩니다.//그래프를 시각화한 예시 ├── moduleA.js │ └── moduleC.js └── moduleB.js └── moduleC.js
- 트리 셰이킹(Tree Shaking)
정적 분석을 통해 사용되지 않는 코드를 제거하는 최적화 기법입니다. 번들러는 의존성 그래프를 분석하여, 실제로 사용되지 않는
export
항목을 번들에서 제외할 수 있습니다.
이를 통해 번들 크기를 줄이고, 로딩 성능을 최적화할 수 있습니다.// moduleA.js export const foo = 'foo'; export const bar = 'bar'; // 사용되지 않음 // moduleB.js import { foo } from './moduleA.js'; console.log(foo);
위의 코드에서
bar
는 moduleB.js
에서 사용되지 않으므로, 트리 셰이킹을 통해 번들에서 제거될 수 있습니다.- 코드 스플리팅(Code Splitting)
정적 분석을 통해 모듈 간의 의존성을 파악한 후, 이를 바탕으로 코드 스플리팅을 수행할 수 있습니다. 이를 통해 애플리케이션의 초기 로딩 성능을 향상시킬 수 있습니다.
자주 사용되는 모듈과 그렇지 않은 모듈을 분리하여, 초기 로딩 시점에 필요한 코드만 로드하고 나머지는 비동기적으로 로드합니다.
// main.js import { foo } from './moduleA.js'; function loadModule() { import('./moduleB.js').then(moduleB => { moduleB.doSomething(); }); } console.log(foo);
위의 코드에서
moduleB.js
는 동적 import()
를 통해 비동기적으로 로드됩니다. 이를 통해 초기 로딩 성능을 최적화할 수 있습니다.- 빌드 최적화
번들러는 정적 분석을 통해 모듈 간의 관계를 파악하고, 최적의 번들링 전략을 세웁니다. 이를 통해 중복된 코드를 제거하고, 필요한 모듈만 포함하는 최적화된 번들을 생성할 수 있습니다.
// main.js import { foo } from './moduleA.js'; import { bar } from './moduleC.js'; console.log(foo); console.log(bar);
위의 코드에서
moduleA.js
와 moduleC.js
의 의존성을 분석하여, 필요한 모듈만 포함하는 최적화된 번들을 생성합니다.ESM이 도입된 이후 UMD와 CommonJS 모듈 시스템을 사용하는 상황은?
UMD는 라이브러리 배포와 다양한 환경 지원을 위해, CommonJS는 Node.js환경과 레거시 코드베이스 유지보수를 위해 사용됩니다.
UMD를 사용하다가 ESM으로 마이그레이션 하려면 어떤 것들이 고려될까?
- 환경 감지를 위한 조건문들 제거하게되고
- import와 export 문법을 사용해 모듈들을 정의하게되고
- 브라우저인지 Node.js인지에 따라 호환성을 고려해 설정을 수정해주는 작업
이렇게 모듈 시스템의 변천사를 살펴봤습니다.
각 모듈 시스템들은 이전 방식의 아쉬운 점들을 보완하기 위해 계속 발전해왔습니다.
모듈 시스템의 발전은 UX/DX의 엄청난 상승을 가져왔다고 느꼈는데요.
(CJS가 없었더라면 지금의 Framework들도 없었을 것이고 Node.js가 없었으면 npm이 없었으면.. )
위 경우들처럼 개발생태계에 큰 기여를 하는 생에 꼭 경험해보고싶습니다.