Node.js와 ES 모듈 시스템의 내부 동작 및 차이점 심층 분석

Node.js의 CommonJS 모듈 시스템

Node.js는 모듈 관리를 위해 CommonJS 사양을 기반으로 한다. 이 사양은 모듈 식별자, 정의, 참조의 세 가지 요소로 구성된다.

// 모듈 초기 상태
console.log(exports === module.exports); // true
// 두 식별자는 동일한 객체를 가리킨다

exports와 module.exports의 관계

exportsmodule.exports에 대한 참조일 뿐이며, 독립된 객체가 아니다. 따라서 exports에 새로운 값을 할당하면 연결이 끊기고 더 이상 module.exports와 동기화되지 않는다.

// mathUtils.js
let value = 42;

console.log(exports);         // {}
console.log(module.exports);  // {}

exports.value = value;        // module.exports에 반영됨

exports = { newValue: 100 };  // exports 재할당 → 연결 해제

// 다른 파일에서 require 사용 시
// const result = require('./mathUtils');
// result는 { value: 42 }만 포함됨

결과적으로 require() 함수는 오직 module.exports가 가리키는 객체만 반환한다. 따라서 명시적인 제어를 위해선 항상 module.exports를 사용하는 것이 안전하다.

ES6 모듈: 정적 구문과 동적 바인딩

브라우저 및 최신 자바스크립트 환경에서는 ES6 모듈(ESM)이 표준이다. ESM은 exportimport 키워드를 사용하며, 다음과 같은 특징을 갖는다.

  • 정적 분석 가능: 임포트/익스포트 구문은 런타임이 아닌 컴파일 타임에 결정됨
  • 참조 기반 전달: 값의 복사가 아니라 실시간 바인딩 유지
  • strict mode 기본 적용
  • 모듈 스코프는 전역 스코프와 분리됨

named export vs default export

여러 개의 명명된 내보내기를 정의할 수 있으며, 각각은 이름을 통해 가져올 수 있다.

// utilities.js
export const PI = 3.14159;

export function calculateArea(radius) {
  return PI * radius ** 2;
}

function validateInput(data) {
  return typeof data === 'number';
}
export { validateInput as validator };

반면 default 키워드는 하나의 모듈 당 단 한 번만 사용 가능하며, 임포트 시 중괄호 없이 이름을 자유롭게 지정할 수 있다.

// mainOperation.js
const operation = () => console.log("기본 동작 수행");

export default operation;

// 또는 리터럴 직접 내보내기
// export default "기본 문자열";

임포트 방식 비교

// named import (중괄호 필요)
import { PI, calculateArea } from './utilities';

// default import (중괄호 불필요)
import execute from './mainOperation';

// 전체 모듈 객체로 임포트
import * as utils from './utilities';
console.log(utils.PI);           // 접근 가능
console.log(utils.default);      // default도 속성으로 존재

// default 내보내기가 있는 경우에도 전체 집합에는 'default' 필드로 포함됨
import * as op from './mainOperation';
op.default(); // 실행됨

CommonJS와 ES 모듈의 핵심 차이점

특성 CommonJS ES Modules
로딩 시점 런타임 동기 로딩 컴파일 타임 정적 해석
값 전달 방식 값의 복사본 실시간 참조
동적 로딩 가능 (require(expression)) Promise 기반 import() 함수 사용
순환 종속성 처리 현재까지 실행된 부분만 노출 최종 상태에 대한 참조 유지
top-level this 모듈 객체 undefined

순환 종속성 예제 비교

CommonJS:

// a.js
console.log('a 시작');
exports.status = 'pending';

const b = require('./b');
console.log('a에서 b.status:', b.status);

exports.status = 'resolved';
console.log('a 끝');
// b.js
console.log('b 시작');
exports.status = 'idle';

const a = require('./a'); // a의 미완성 상태를 받음
console.log('b에서 a.status:', a.status); // pending 출력

exports.status = 'active';
console.log('b 끝');

출력 결과:

a 시작
b 시작
b에서 a.status: pending
b 끝
a에서 b.status: active
a 끝

ES 모듈:

// counterA.js
import { incrementB } from './counterB';

export let count = 0;
export function incrementA() {
  count++;
  incrementB();
}

// counterB.js
import { incrementA } from './counterA';

export let count = 0;
export function incrementB() {
  count++;
  if (count < 3) incrementA(); // 조건부 호출
}

ESM은 실제 접근 시점에 값을 평가하므로, 순환 호출 후에도 최신 상태를 반영한다.

서로 다른 모듈 시스템 간 상호 운용성

Node.js 환경에서는 다음과 같은 규칙이 적용된다:

  • .mjs 확장자는 ESM, .cjs는 CommonJS를 강제함
  • ESM 내에서 require() 사용 불가
  • CommonJS에서는 import 문법 사용 불가 (대신 createRequire 활용 가능)
  • ESM은 CommonJS 모듈을 default 형태로 가져올 수 있음
// CommonJS 모듈 (logger.cjs)
module.exports = { log: (msg) => console.log(msg) };

// ESM에서 가져오기 (app.mjs)
import logger from './logger.cjs'; // 기본 내보내기로 처리
logger.log("안녕하세요");

빌드 도구가 혼용을 가능하게 하는 이유

개발 환경에서 Babel, Webpack 등의 도구는 ES6+ 문법을 CommonJS 형식으로 변환한다. 예를 들어:

// 변환 전 (ESM)
export const name = "Alice";
export default function greet() { return `Hello, ${name}`; }

// 변환 후 (CommonJS)
Object.defineProperty(exports, "__esModule", { value: true });
const name = "Alice";
function greet() { return `Hello, ${name}`; }
exports.name = name;
exports.default = greet;

이러한 트랜스파일링 덕분에 브라우저 호환성과 라이브러리 통합이 용이해진다.

태그: CommonJS ES Modules module.exports exports import

7월 3일 20:21에 게시됨