JavaScript 모듈 시스템 알아보기 - CJS vs ESM
Node.js 환경에서 사용할 수 있는 모듈 시스템의 두 가지 선택지로 CJS (CommonJS) 와 ESM (ES Module) 이 있습니다. 더 이른 시기에 등장한 CJS에 비해 이후에 등장한 ESM은 더 성숙해진 체계를 갖추고 있으나, 여전히 하위 호환성 때문에 CJS의 흔적을 완전히 지우기는 어렵습니다. 오늘은 이 두 가지 모듈 시스템을 비교해 보겠습니다.
CJS
CJS의 키워드는 require
와 exports
입니다.
/* user.js */
const firstName = "Ju Ho";
const lastName = "Nam";
exports.firstName = firstName;
exports.lastName = lastName;
/* main.js */
const user = require("./user");
console.log("First Name:", user.firstName); // First Name: Ju Ho
console.log("Last Name:", user.lastName); // Last Name: Nam
require
와 exports
는 특별한 구문이 아니다.
Node.js가 처음 등장했던 시기에는 JavaScript에 자체적인 모듈 시스템이 존재하지 않았기에 특별한 구문이 존재하지 않았습니다. require
와 exports
는 각각 Node.js에서 제공되는 built-in 함수와 객체이며, JavaScript의 일반적인 함수 및 객체와 다르게 취급되지 않습니다.
동작 원리
CJS는 require
함수가 실행되는 시점에서 모듈을 불러오는 동적인 방식을 사용합니다. CJS의 동작 원리는 다음과 같습니다.
- 런타임에서
require
함수가 실행되는 시점에서 해당 경로의 파일을 로드합니다. - 불러온 파일을 실행하고
exports
객체를 return 합니다. 한번 실행된 파일은 자체적으로 캐싱 되며, 이후 동일한 파일을require
하는 경우 한 번 더 실행하지 않고 캐싱 된 값만 return 합니다. - return 된 값을 받아오는 변수에 할당합니다. 이 과정에서 값을 복사합니다.
높은 자유도
일반적인 프로그래밍 언어의 모듈 시스템이라면 모듈 관련 구문은 top-level에서만 허용되는 것이 일반적입니다. 그러나 CJS는 자유도가 상당히 높습니다.
아래는 응용 예시입니다.
// 함수 내에서 모듈 불러오기
function someFunction() {
require("moduleA");
}
// 모듈의 메소드 즉시 실행
const result = require("moduleB").someMethod();
// 동적 경로
require(`./modulePath/${someString}`);
// 특정 조건에서만 내보내기
if (someCondition) {
exports.value = "Some value to be exported";
}
// 내보내는 함수에서 모듈 불러오기
exports.exportedFunction = () => {
return require("moduleC");
};
// 모듈의 메소드 override
require("moduleD").someMethod = (input) => {
// some code
};
지나치게 높은 자유도는 권장되지 않는 여러 기괴한 유형들을 허용한다는 측면에서 장점보다는 단점으로 보입니다.
ESM
ESM은 JavaScript에서 네이티브로 모듈을 지원하기 위해 EcmaScript 6 공식 문법으로 채택되었으며, Node.js에서는 12버전에서부터 공식 지원하기 시작했습니다.
ESM의 키워드는 import
와 export
입니다.
/* user.js */
export const firstName = "Ju Ho";
export const lastName = "Nam";
/* main.js */
import { firstName, lastName } from "./user.js";
console.log("First Name:", firstName); // First Name: Ju Ho
console.log("Last Name:", lastName); // Last Name: Nam
동작 원리
ESM은 CJS와는 다르게 런타임 이전에 파싱 과정에서 모듈의 관계가 미리 구성되는 정적인 방식입니다. ESM의 동작 원리는 다음과 같습니다.
- 파싱 과정에서 Entry 파일을 기준으로
import
문을 따라서 연결된 모듈을 전부 비동기로 로드합니다. - 모든 모듈이 로드되었으면
export
로 명시된 변수에 대해 메모리를 미리 할당하고, 해당 변수를import
하는 변수가 있다면 같은 메모리를 바라보도록 합니다. 이 과정에서 실제 값을 채우지는 않습니다. Hoisting과 같은 원리라고 이해할 수 있습니다. - 런타임으로 돌입하여 순서대로 모듈을 실행하면서 할당된 메모리에 값을 채웁니다. 각 모듈당 단 한 번씩만 실행됩니다.
제한된 규격
ESM의 import
와 export
문은 공식적으로 채택된 문법인 만큼 제약사항도 명확합니다. import
문과 export
문은 top-level에서만 선언이 가능하며, import
문의 모듈 지정자에 유동적인 변수 사용이 불가능합니다.
import module from "./module.js"; // ok
export const someVariable = "A variable to be exported"; // ok
export function someFunction() {} // ok
import module from `${path}/module.js`; // not ok
if (someCondition) {
import module from "./module.js"; // not ok
export const someVariable = "A variable to be exported"; // not ok
}
function someFunction() {
import module from "./module.js"; // not ok
export const someVariable = "A variable to be exported"; // not ok
}
Dynamic Import
ESM에서는 이런 정적 import
와 더불어 CJS처럼 동적 import
또한 제공됩니다.
import("modulePath").then((mod) => {
// some code
});
함수와 유사하게 동작하는
import()
표현식으로 동적으로 모듈을 불러올 수 있습니다.require
와는 다르게 비동기로 불러오며, Promise를 반환합니다.
Dynamic Import에는 다음과 같은 장점이 있습니다.
- Lazy Loading: 초기에 모든 모듈을 미리 불러오지 않고 필요해진 상황에서만 불러오도록 합니다. 브라우저 환경이라면 성능적 이점으로 활용할 수도 있습니다. Node.js 환경에서는 큰 이점이 없어 보이긴 합니다.
- 높은 자유도: CJS의
require
함수와 같이 유연한 형태로 활용이 가능합니다. - 호환성: ESM이 아닌 환경에서도
import()
표현식을 사용할 수 있기에 CJS로 구성된 프로젝트에서 ESM 모듈을 불러오는 것이 가능합니다.
CJS vs ESM 비교하기
CJS
- 런타임에서 파일을 로드
- 높은 자유도
- 불러오는 변수의 값을 복사
ESM
- 파싱 중에 파일을 로드
- 제한된 규격
- 불러오는 변수와 같은 레퍼런스를 할당
댓글남기기