Node.js의 CommonJS 모듈 시스템
Node.js는 모듈 관리를 위해 CommonJS 사양을 기반으로 한다. 이 사양은 모듈 식별자, 정의, 참조의 세 가지 요소로 구성된다.
// 모듈 초기 상태
console.log(exports === module.exports); // true
// 두 식별자는 동일한 객체를 가리킨다
exports와 module.exports의 관계
exports는 module.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은 export와 import 키워드를 사용하며, 다음과 같은 특징을 갖는다.
- 정적 분석 가능: 임포트/익스포트 구문은 런타임이 아닌 컴파일 타임에 결정됨
- 참조 기반 전달: 값의 복사가 아니라 실시간 바인딩 유지
- 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;
이러한 트랜스파일링 덕분에 브라우저 호환성과 라이브러리 통합이 용이해진다.