Node.js 환경에서 사용할 수 있는 모듈 시스템의 두 가지 선택지로 CJS (CommonJS) 와 ESM (ES Module) 이 있습니다. 더 이른 시기에 등장한 CJS에 비해 이후에 등장한 ESM은 더 성숙해진 체계를 갖추고 있으나, 여전히 하위 호환성 때문에 CJS의 흔적을 완전히 지우기는 어렵습니다. 오늘은 이 두 가지 모듈 시스템을 비교해 보겠습니다.

CJS

CJS의 키워드는 requireexports입니다.

/* 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

requireexports는 특별한 구문이 아니다.

Node.js가 처음 등장했던 시기에는 JavaScript에 자체적인 모듈 시스템이 존재하지 않았기에 특별한 구문이 존재하지 않았습니다. requireexports는 각각 Node.js에서 제공되는 built-in 함수와 객체이며, JavaScript의 일반적인 함수 및 객체와 다르게 취급되지 않습니다.

동작 원리

CJS는 require 함수가 실행되는 시점에서 모듈을 불러오는 동적인 방식을 사용합니다. CJS의 동작 원리는 다음과 같습니다.

  1. 런타임에서 require 함수가 실행되는 시점에서 해당 경로의 파일을 로드합니다.
  2. 불러온 파일을 실행하고 exports 객체를 return 합니다. 한번 실행된 파일은 자체적으로 캐싱 되며, 이후 동일한 파일을 require 하는 경우 한 번 더 실행하지 않고 캐싱 된 값만 return 합니다.
  3. 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의 키워드는 importexport입니다.

/* 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의 동작 원리는 다음과 같습니다.

  1. 파싱 과정에서 Entry 파일을 기준으로 import 문을 따라서 연결된 모듈을 전부 비동기로 로드합니다.
  2. 모든 모듈이 로드되었으면 export로 명시된 변수에 대해 메모리를 미리 할당하고, 해당 변수를 import 하는 변수가 있다면 같은 메모리를 바라보도록 합니다. 이 과정에서 실제 값을 채우지는 않습니다. Hoisting과 같은 원리라고 이해할 수 있습니다.
  3. 런타임으로 돌입하여 순서대로 모듈을 실행하면서 할당된 메모리에 값을 채웁니다. 각 모듈당 단 한 번씩만 실행됩니다.

제한된 규격

ESM의 importexport 문은 공식적으로 채택된 문법인 만큼 제약사항도 명확합니다. 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

  • 파싱 중에 파일을 로드
  • 제한된 규격
  • 불러오는 변수와 같은 레퍼런스를 할당

업데이트:

댓글남기기