CommonJS와 ES Module, 무엇이 어떻게 다를까?
"type": "module"이 미치는 영향에 대해 이야기하기 전에, 먼저 Node.js의 전통적인 모듈 시스템인 CommonJS의 특징을 짚고 넘어가겠습니다. CommonJS는 오늘날의 트렌드와는 다소 거리가 있지만, 수많은 레거시 코드와 라이브러리에서 여전히 사용되고 있기에 그 특징을 이해하는 것은 중요합니다.
CommonJS의 특징: 모듈 래퍼와 동적 실행
(function (exports, require, module, __filename, __dirname) {
// 우리가 작성한 모듈 코드는 실제로 여기에 들어갑니다.
});
CommonJS는 위와 같은 모듈 래퍼(module wrapper) 함수를 사용합니다. 우리가 작성한 모듈 코드는 이 함수로 감싸져 개별적인 함수 스코프(클로저) 내에서 실행됩니다. 이로 인해 모듈 간의 변수 충돌이 방지됩니다.
이러한 실행 방식이 어떤 특징을 갖는지 보여주는 예시는 다음과 같습니다.
// test.js
module.exports = {
[globalThis.hello]: 'world',
};
// index.js
const hello = 'hello';
globalThis[hello] = hello;
const test = require('./test.js');
console.log(test[hello]); // 무엇이 출력될까요?
결과는 'world'가 출력됩니다. require('./test.js')가 호출되는 시점(런타임)에 test.js 코드가 즉시 실행되기 때문입니다. 이때 globalThis.hello의 값은 이미 'hello'로 설정되어 있으므로, test.js는 {'hello': 'world'} 객체를 생성하여 내보내게 됩니다. 이처럼 CommonJS는 코드가 실행되는 흐름에 따라 동적으로 모듈을 불러오고 평가합니다.
또한 module.exports에 할당되는 변수나 값들은 하나의 객체로 다루어져 외부로 내보내집니다.
// message.js
const greeting = 'Welcome!';
module.exports = { content: greeting }; // 객체를 내보냅니다.
// main.js
const msg = require('./message');
console.log(msg.content); // 출력: Welcome!
앞서 설명한 CommonJS의 특징, 특히 런타임에 동적으로 모듈을 평가하는 방식은 성능 최적화에 한계를 만듭니다. 빌드 도구가 코드를 실행해보기 전까지는 어떤 모듈이 필요한지, 또 모듈의 어떤 부분만 사용되는지 정확히 알 수 없어 트리 쉐이킹(Tree Shaking) 같은 최신 최적화 기법을 적용하기 어렵습니다.
ES Module과 CommonJS의 결정적 차이점
이제 ECMAScript 표준 모듈 시스템인 ES Module(ESM)을 CommonJS와 비교하며 그 차이를 자세히 알아보겠습니다.
// CommonJS
const fs = require("fs");
module.exports = { /* ... */ };
// ES Module
import fs from "fs";
export { /* ... */ };
가장 많이 언급되는 문법의 차이를 넘어, 두 시스템의 본질적인 차이는 '언제, 그리고 어떻게 모듈을 해석하는가' 에 있습니다. 결론부터 말하자면 CommonJS는 동기적/런타임 방식으로, ES Module은 정적/파싱 타임 방식으로 동작하며, 이것이 모든 차이를 만들어 냅니다.
핵심 차이점 1: 모듈 로딩 시점
1. CommonJS: 동적, 런타임(Runtime) 로딩
require()는 함수 호출입니다. 코드가 실행되다가 require() 라인을 만나면, 해당 함수가 동기적으로 실행되어 모듈을 불러온 뒤 다음 코드를 실행합니다. 이는 require()가 끝날 때까지 코드 실행이 멈춘다는 것을 의미합니다.
아래 예제를 통해 확인해 보겠습니다.
// module1.cjs
console.log('모듈1 로드');
setTimeout(() => console.log('모듈1 작업 시작'), 2000);
console.log('모듈1 종료');
// main.cjs
console.log('시작');
const module1 = require('./module1.cjs');
console.log('모듈들 실행 중');
const module2 = require('./module2.cjs'); // module1과 내용 동일
console.log('모듈들 모두 실행 완료');
require는 동기적으로 실행되므로, 결과는 코드의 흐름에 맞춰 순차적으로 나타납니다.
# 실행 결과
시작
모듈1 로드
모듈1 종료
모듈들 실행 중
모듈2 로드
모듈2 종료
모듈들 모두 실행 완료
# (2초 후)
모듈1 작업 시작
모듈2 작업 시작
2. ES Module: 정적, 파싱 타임(Parse-time) 로딩
반면, import는 함수가 아닌 특별한 키워드(문법)입니다. 자바스크립트 엔진은 코드를 실행하기 전, 파싱 단계에서 import와 export 구문을 먼저 찾아 모듈 간의 의존성 관계를 파악합니다. 이 '정적 분석' 과정을 통해 모든 모듈의 관계도가 완성된 후에야 실제 코드 실행을 시작합니다.
이 때문에 import는 조건문이나 함수 내에서 사용할 수 없으며, 반드시 모듈의 최상위 레벨에 작성되어야 합니다.
// esmodule.mjs (내용은 cjs 버전과 동일)
console.log('모듈1 로드');
setTimeout(() => console.log('모듈1 작업 시작'), 2000);
console.log('모듈1 종료');
// main.mjs
console.log('시작');
import './esmodule1.mjs';
console.log('Hello World');
import './esmodule2.mjs'; // esmodule1과 내용 동일
위 코드를 실행하면 결과는 CommonJS와 전혀 다릅니다.
# 실행 결과
모듈1 로드
모듈1 종료
모듈2 로드
모듈2 종료
시작
Hello World
# (2초 후)
모듈1 작업 시작
모듈2 작업 시작
엔진이 코드를 한 줄씩 실행하기 전에 import 문을 모두 먼저 처리하여 모듈 코드를 로드하고, 그 후에 main.mjs의 실행 코드(console.log)를 처리하기 때문입니다.
핵심 차이점 2: 값의 참조 방식 - 복사 vs. 라이브 바인딩
이 정적 특성은 값을 가져오는 방식에도 큰 차이를 만듭니다. CommonJS는 '값의 복사' 입니다. require를 통해 가져온 값은 원본 모듈의 값을 복사한 것으로, 가져온 이후에 원본 값이 변경되어도 반영되지 않습니다.
// counter.cjs
let count = 1;
function increment() { count++; }
module.exports = { count, increment };
// main.cjs
const { count, increment } = require('./counter.cjs');
console.log(count); // 1
increment();
console.log(count); // 여전히 1 (원본의 count는 2가 되었지만, 복사해온 값은 그대로)
ES Module은 '라이브 바인딩(Live Binding)'입니다. import로 가져온 값은 원본 모듈의 값에 대한 실시간 참조입니다. 따라서 원본 값이 변경되면 가져온 값도 즉시 반영됩니다. 이것이 앞서 언급된 '연결(Linking)' 의 실체입니다.
// counter.mjs
export let count = 1;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 1
increment();
console.log(count); // 2 (원본 값이 바뀌자 참조하고 있던 값도 함께 변경됨)
CommonJS와 ES Module은 단순히 문법만 다른 것이 아니라, 모듈을 해석하고 로드하는 근본적인 철학에서 차이를 보입니다. CommonJS는 런타임에 동기적으로 동작하여 유연성을 갖지만 최적화에 한계가 있으며, ES Module은 파싱 타임에 정적으로 동작하여 문법적 제약이 있지만 이를 통해 강력한 코드 최적화(트리 쉐이킹 등)와 실시간 참조(라이브 바인딩)를 가능하게 합니다. 이러한 이유로 ES Module이 현대 자바스크립트 개발의 표준으로 자리 잡게 되었습니다.
Tree Shaking
트리 셰이킹은 실제 코드 실행에 필요한 부분만을 번들에 포함시키고, 사용되지 않는 코드는 제거하여 최종 번들 크기를 최적화하는 과정을 의미합니다. 이는 데드 코드 제거(Dead Code Elimination) 의 한 형태로 볼 수 있습니다.
module.exports의 객체라는 특성 때문에, 빌드 타임에서는 모듈에서 어떠한 값을 불러와 어떻게 사용될 수 있을지를 가늠할 수 없습니다. 동적으로 실행이 되며 객체로 불러온 값이 언제 사용할 것인지 알 수 없기 때문입니다.
트리 셰이킹을 통해서 불필요한 코드를 덜어내서 성능을 향상 시키기 위해서는 전체 모듈을 import 하는 것이 아닌 필요한 기능의 일부분을 로드하는 것이 중요합니다.
// my-commonjs-module.cjs (CommonJS)
module.exports = {
valueA: 10,
valueB: 'hello',
myFunction: () => console.log('From CommonJS')
};
// main.mjs (ES Module)
import { valueA } from './my-commonjs-module.cjs';
console.log(valueA);
위의 코드는 CommonJS의 일부분을 명시적으로 불러오는 것 같지만 실제로는 모듈의 전체를 로드하고 있습니다. 이는 CommonJS의 동적인 특성 때문에 특정 부분만 선택적으로 사용하지 않고 전체 모듈을 불러오고 있습니다.
// moduleA.mjs (ESM)
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function subtract(a, b) { return a - b; }
// main.mjs (ESM)
import { PI, add } from './moduleA.mjs';
console.log(PI);
console.log(add(5, 2));
반면 ESM은 일부 모듈만 명시적으로 불러오는 것이 가능합니다. 이는 ESM이 정적 분석을 속성으로 갖는 덕분입니다. ESM은 모듈의 로딩(다운로드 및 파싱)과 실행을 분리할 수 있습니다. 런타임은 의존성 그래프를 따라 필요한 모듈을 먼저 로드하고 파싱하지만, 모든 모듈을 즉시 실행하는 것이 아니라 연결 과정에서 필요한 부분만 활성화합니다.
여기서 중요한 부분은 코드를 실행하기 이전, 파싱 시점에 분석 하여 번들러나 런타임은 이 import 선언과 해당 모듈의 export 선언을 매칭시켜 필요한 부분만 연결(linking) 한다는 것이 가장 중요한 부분입니다. 이러한 것이 가능한 것은 모듈의 일반적인 생명 주기와도 연관이 있습니다.
- Parsing : 소스 코드를 읽어 구문 분석 트리를 생성하고 모듈의
import및export구문을 식별합니다. - Loading : 파싱 단계에서 식별된 모든 종속성 모듈(ModuleRequest Record) 를 가져옵니다.
- Linking : 로드된 모든 모듈 간의
import및export바인딩을 해석하고 연결합니다. - Evaluation : 모듈의 실제 코드를 실행하고,
export된 값을 최종적으로 바인딩 합니다.
이러한 생명주기 중 Linking에 대해서 조금 더 자세하게 설명 해보겠습니다. 이 단계의 주된 목적은 모듈 그래프 내의 모든 import 구문을 해당 export 구문과 연결하는 것입니다.
Linking 과정에서 모듈은 자신의 Module Environment Record 를 생성하고 초기화 합니다. 이 환경 레코드는 모듈 내의 모든 변수, 함수, 클래스 선언 및 import 된 바인딩을 관리하는 역할을 합니다.
이 과정에서 JavaScript 엔진은 추상 연산을 사용하여 해당 importName이 어떤 모듈의 어떤 BindingName에 연결되어야 하는지를 확인합니다. 이 과정에서 실제로 값을 복사하는 것이 아니라, 원본 내보내기 모듈의 환경 레코드에 있는 대상 바인딩을 참조합니다
링킹이 진행됨에 따라 모듈의 [[Status]] 필드는 unlinked에서 linking으로, 그리고 성공적으로 완료되면 linked로 전환됩니다.
unlinked 상태는 모듈의 import 구문이 해당 export 구문과 성공적으로 연결되지 못했음을 의미합니다. 이러한 모듈은 모듈 그래프의 일부로 완전히 통합되지 못했기 때문에, 자바스크립트 엔진은 이 모듈이 애플리케이션의 최종 번들에 필요하지 않다고 판단하여 Tree Shaking 의 대상이 되어 최종 번들에서 제외되게 됩니다.
결론적으로, ESM의 정적인 Linking 단계는 CommonJS의 동적인 module.exports 방식과 명확히 차별화됩니다. 코드를 실행하기 전에 필요한 부분만을 정확히 식별하고 연결하는 이 능력은 번들러가 불필요한 코드를 효과적으로 제거(Tree Shaking)할 수 있도록 하며, 이는 곧 애플리케이션의 번들 크기 감소와 로딩 성능 향상으로 이어집니다. 따라서 ESM의 import/export 구문은 단순히 모듈을 가져오는 문법을 넘어, 웹 애플리케이션의 효율성을 극대화하는 핵심적인 기반을 제공한다고 할 수 있습니다.
Top-Level-Await
Top-Level-Await은 ES2022 부터 사용이 가능해진 기능입니다. 이 기능이 의미하는 것이 무엇인지에 대해서 간단한 상황과 함께 이야기를 해보겠습니다.
우리는 코드에서 특정 API를 사용하여 생성된 데이터를 export 하고자 합니다. 예를 들어 가장 유명한 API인JSONPlaceholder에서 todo 데이터를 읽어오고 해당 todolist를 export 하려고 한다고 가정 해봅시다. 물론 Top-Level-Await 가 도입되기 이전의 상황에서 생각을 해보겠습니다.
let todolist;
(async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos/1')
const json = await response.json();
todolist = json;
})();
export { todolist };
JSONPlaceholder의 API 결과인 todolist 를 export 하기 위해서는 위와 같이 IIFE 를 활용하여 값을 todolist가 호출되는 즉시 API를 호출하고 값을 업데이트하는 방법이 있을 것입니다. 하지만 이 코드를 import 하여 결과를 출력하면 어떤 값이 출력이 될까요?
import { todolist } from "./todolist";
console.log(todolist); //undefined
위의 todolist의 출력값은 undefined 가 출력이 됩니다. 이러한 이유는 무엇일까요? 순차적으로 자세하게 알아 보겠습니다.
import { todolist } from "./todolist" 구문이 가장 먼저 실행이 됩니다. 구문이 실행이 되면 JavaScript 엔진은 파일을 즉시 평가하기 시작합니다. 이 초기 평가 단계에서 todolist 변수는 선언되었지만 어떠한 값도 할당 되지 않기 때문에 이 시점에서는undefined 로 평가가 이루어집니다.
export { todolist } 는 현재의 바인딩을 export 합니다. 즉 undefined를 내보내게 됩니다. 아무리 IIFE로 비동기 작업을 통해서 API 의 결과 값을 todolist 의 값으로 업데이트하더라도 평가 단계에서 이 값은 반영이 되지 않습니다.
비동기 함수로 작성이된 IIFE는 export 이후에 EventLoop의 Job Queue에서 스케줄링 처리가 되기 때문에 타이밍 문제가 발생하여 위와 같은 방법을 사용한다면 undefined 가 아닌 todolist 값은 import 할 수 없습니다.
import { todolist } from "./todolist';
setTimeout(() => {
console.log(todolist);
}, 1000);
물론 위와 같이 타이밍 문제를 해결하기 위해 지연하여 값을 불러올 수 있겠지만 이는 조금의 네트워크 지연문제가 발생하면 금방 무용지물이 되는 단편적인 해결책이기 때문에 제외하겠습니다. 그렇다면 이 문제는 어떻게 해결해야 할까요?
이 문제를 가장 우아하게 해결하는 방법은 Top-Level-Await 을 사용하는 것입니다!
const response = await fetch("https://jsonplaceholder.typicode.com/todos/1");
const todoList = await response.json();
export { todoList };
기존의 코드에 비해 가독성이 훨씬 향상 되었고 어떤 값이 export 가 될지 예상할 수 있게끔 코드가 변경 되었습니다. 이제 왜 Top-Level-Await 가 앞선 문제의 해결책이 될 수 있는지에 대해서 이야기 해보겠습니다.
기존의 await 은 반드시 async 함수 내부에서만 유용하다는 엄격한 규칙이 있었습니다. 하지만 ECMAScript 2022부터는 await 키워드가 Module의 최상위 레벨에서도 파싱될 수 있도록 허용되었습니다. 이는 모듈 코드 자체가 비동기 작업을 직접 기다릴수 있음을 의미합니다.
이러한 동작이 가능해진 이유는 ECMAScript의 사양은 모듈의 메타 데이터를 추상화한 Module Record를 정의하는데 그 중에서도 Source Text Module Record는 JavaScript 코드로부터 파싱된 모듈을 나타냅니다.
이러한 Source Text Module Record 에 Top-Level-await을 위한 내부 슬롯인 [[HasTLA]] 추가가 되었고 Top-Level-await 표현식이 존재할 경우 이 필드 값이 true로 설정이 됩니다.
[[HasTLA]]가 설정이 된 모듈은 비동기적으로 평가됩니다. 이 후 비동기 평가를 위한 추상 연산이 수행되어 모듈의 코드 실행과 Promise의 resolve/reject를 연동시킵니다. 이로 인해 Top-Level await이 Promise의 결과를 기다리는 동안 모듈의 실행이 일시 중지될 수 있습니다.
그렇기 때문에 Top-Level-Await 이 있는 모듈을 다른 모듈이 import할 경우 가져오는(importing) 모듈은 가져오는(imported) 모듈의 비동기 평가가 완료될때까지 자동으로 기다립니다.
await가 코드 실행을 일시 중지할 때, 이는 메인 스레드를 차단하지 않습니다. 대신, Promise가 해결될 때까지 기다리는 동안 해당 작업의 나머지 부분은 Job Queue로 스케줄링되어 이벤트 루프를 통해 비동기적으로 처리됩니다. 이로써 Top-Level await은 전체 애플리케이션의 시작을 멈추지 않고 비동기 작업을 수행할 수 있습니다.
클로저와 Binding으로 다시 정리해보기
여기서 CommonJS와 ES Module의 차이를 더 직관적으로 이해하려면 클로저(Closure) 와 바인딩(Binding) 을 함께 떠올리면 좋습니다. Node.js의 CommonJS 모듈은 실행 전에 함수 래퍼로 감싸지기 때문에, 각 파일은 고유한 함수 스코프를 갖습니다. 따라서 모듈 내부 변수는 외부에서 직접 접근할 수 없지만, 그 변수를 참조하는 함수를 module.exports로 내보내면 클로저를 통해 내부 상태를 계속 읽거나 변경할 수 있습니다.
// counter.cjs
let count = 0;
function increment() {
count++;
}
function getCount() {
return count;
}
module.exports = { increment, getCount };
// main.cjs
const { increment, getCount } = require('./counter.cjs');
console.log(getCount()); // 0
increment();
console.log(getCount()); // 1
이 예제에서 외부 모듈이 최신 count 값을 읽을 수 있는 이유는 getCount가 값을 복사해서 들고 있기 때문이 아니라, 자신이 선언될 당시의 렉시컬 환경을 클로저로 붙잡고 있기 때문입니다. 즉 CommonJS에서도 상태 공유는 가능하지만, 그것은 import와 export가 바인딩 자체를 연결해서가 아니라 클로저나 공유 객체 참조를 통해 간접적으로 연결되는 방식에 가깝습니다.
반면 ES Module은 언어 차원에서 바인딩을 직접 연결합니다. 정적 import는 다른 모듈이 export한 값을 단순히 한 번 복사해 오는 것이 아니라, 읽기 전용 라이브 바인딩으로 받아옵니다. 사양 관점에서도 링크 과정에서 각 import는 대상 모듈의 실제 바인딩과 연결되며, 가져오는 쪽에서는 그 이름을 다시 할당할 수 없지만 내보내는 쪽의 변경은 그대로 관찰할 수 있습니다.
// counter.mjs
export let count = 0;
export function increment() {
count++;
}
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1
흥미로운 점은 ES Module에서는 클로저가 라이브 바인딩을 닫을 수도 있다는 것입니다. 어떤 모듈에서 import { count }를 한 뒤 () => count 같은 함수를 반환하면, 이 함수는 과거의 값을 저장한 것이 아니라 원본 모듈의 현재 바인딩을 계속 참조합니다. 결국 CommonJS는 “클로저를 통해 상태를 노출하는 방식”에 가깝고, ES Module은 “바인딩 자체를 언어 차원에서 연결하는 방식”에 가깝다고 정리할 수 있습니다.
이 차이는 앞서 본 Top-Level Await의 동작과도 자연스럽게 이어집니다. ES Module은 모듈 그래프와 바인딩 관계를 실행 전에 이미 파악하고 있기 때문에, 어떤 모듈에 top-level await가 존재하면 그 모듈을 비동기적으로 평가해야 한다는 사실도 그래프 수준에서 관리할 수 있습니다. ECMAScript 사양은 이를 [[HasTLA]] 같은 내부 슬롯으로 추적하며, 비동기 평가가 끝날 때까지 상위 모듈의 실행을 지연시킵니다. 그래서 export const todoList = await ... 같은 표현은 단순한 문법 편의가 아니라, 모듈 시스템 자체가 비동기 초기화를 받아들인 결과라고 볼 수 있습니다.
마무리
지금까지 살펴본 내용을 하나로 묶어보면, "type": "module"은 단순히 require를 import로 바꾸는 선언이 아닙니다. 그것은 Node.js에게 해당 파일을 CommonJS의 함수 래퍼 기반 모듈이 아니라 ECMAScript 표준 모듈로 해석하라고 지시하는 설정입니다. 이 선언 하나로 파일의 로딩 시점, 의존성 해석 방식, 값의 연결 방식, 그리고 비동기 초기화 전략까지 함께 달라집니다.
CommonJS는 require()가 실행되는 시점에 모듈을 동기적으로 평가하고, module.exports를 통해 결과를 외부에 노출합니다. 그래서 실행 흐름을 따라 이해하기 쉽고 유연하지만, 모듈 관계를 실행 전에 완전히 알기 어렵기 때문에 정적 분석 기반 최적화에는 한계가 있습니다. 반면 ES Module은 import와 export를 파싱 단계에서 식별하고, 로딩과 링킹을 거쳐 바인딩을 미리 연결합니다. 이 구조 덕분에 라이브 바인딩과 Top-Level Await 같은 현대적인 기능이 성립할 수 있습니다.
결국 "type": "module"이 바꾸는 핵심은 “파일을 어떻게 불러오느냐”보다 더 근본적인 곳에 있습니다. CommonJS가 실행 중에 필요한 모듈을 불러오는 방식이라면, ES Module은 실행 전에 의존성 그래프와 바인딩 관계를 먼저 확정하는 방식입니다. 그리고 바로 이 차이 때문에 import/export는 단순한 문법 차이를 넘어, 현대 JavaScript 애플리케이션의 구조와 성능 최적화, 그리고 비동기 초기화 방식 전체를 바꾸는 출발점이 됩니다.