CommonJS와 ES Module, 지금 무엇이 다르고 언제 무엇을 써야 할까
빠른 답
- 새 Node.js 애플리케이션은 특별한 이유가 없으면 ESM을 기본값으로 두는 편이 자연스럽다.
- 차이는 문법보다 모듈 해석 방식, export 모델, 정적 분석 가능성에서 더 크게 드러난다.
- Node.js에서는 파일 확장자와 package.json의 type 값이 먼저 모듈 시스템을 결정한다.
- ESM과 CommonJS를 섞을 때는 interop 제약과 실제 오류 메시지를 함께 봐야 판단이 빨라진다.
현재 기준이 중요한 주제라서 Node 공식 문서와 스펙 링크부터 확인하고 있습니다. 오래된 설명에서 자주 섞이는 type, .mjs/.cjs, require(ESM) 동작, 오류 코드 변화를 먼저 검증한 뒤 본문을 정리하겠습니다.공식 문서 기준선은 확인됐습니다. 이제 본문은 현재 지원 버전 기준과 오래된 설명의 차이를 먼저 드러내고, live binding, default/named import, ERR_REQUIRE_ASYNC_MODULE처럼 실제로 헷갈리는 지점을 출력 예시와 함께 정리하겠습니다.실행 예시는 로컬 node v24.14.0으로 맞추고 있습니다. 글에는 명령만 두지 않고 실제 에러 첫 줄과 핵심 메시지가 함께 보이도록 정리하겠습니다.require(ESM)의 현재 동작도 실제로 확인할 수 있는지 한번 더 보고 있습니다. 문서 설명만 둘지, 로컬 출력까지 넣을지 결정하려고 프로세스 치환으로 재현 가능한 범위를 확인하는 중입니다.상태 관찰 예시도 실제 출력으로 맞췄습니다. CommonJS에서 구조 분해한 값이 그대로 남는 경우와, ESM의 바인딩이 값 변경을 따라오는 경우를 출력 기준으로 대비할 수 있습니다.# CommonJS와 ES Module, 지금 무엇이 다르고 언제 무엇을 써야 할까
목차
한눈에 비교
왜 CommonJS와 ESM이 문법 차이 이상으로 헷갈리는가
겉으로 보면 둘 다 “다른 파일의 코드를 가져온다”는 점에서는 비슷합니다. 하지만 엔진이 다루는 모델은 꽤 다릅니다. CommonJS의 require()는 실행 중 호출되는 함수이고, 반환값은 결국 module.exports입니다. 반대로 ESM의 import는 함수 호출이 아니라 선언이어서, 엔진은 파일을 실행하기 전에 어떤 이름이 어디에 연결되는지부터 계산합니다.
이 차이는 상태와 값이 보이는 방식에서 바로 드러납니다. ESM의 named import는 단순 복사보다 바인딩 연결에 가깝습니다. 반대로 CommonJS는 어떤 값을 어떤 시점에 module.exports에 넣었는지가 중요합니다. 이 부분은 ECMAScript module semantics와 import binding 규칙에서 설명하는 쪽에 가깝습니다.
순환 참조에서도 차이가 납니다. CommonJS는 실행 도중 exports 객체를 채워 넣기 때문에 상대 모듈이 부분 초기화된 값을 볼 수 있습니다. ESM은 이름 바인딩을 먼저 연결하지만, 아직 초기화되지 않은 binding에 접근하면 TDZ 때문에 ReferenceError가 드러나는 경우가 있습니다. 그래서 둘을 단순히 “문법만 다른 모듈 시스템”으로 보면 중간에서 자주 헷갈립니다.
오래된 설명의 함정과 현재 Node.js 기준
이 주제는 버전 차이를 먼저 잡아두는 편이 좋습니다. 2026년 4월 5일 기준 Node.js Releases 페이지에서는 v25가 Current, v24가 Active LTS, v22와 v20이 Maintenance LTS입니다. 예전 글에서 자주 보이던 “Node 12부터 ESM이 된다”는 문장은 역사적 출발점으로는 맞지만, 지금의 판단 기준으로 쓰기에는 너무 오래된 설명입니다. v18도 이미 EOL입니다.
현재 Node 기준에서 모듈 판별은 Modules: Packages 문서를 따라 보는 편이 안전합니다.
.mjs는 ESM으로 해석됩니다..cjs는 CommonJS로 해석됩니다..js는 가장 가까운package.json의"type"값에 따라 달라집니다.--eval이나 STDIN은--input-type=module또는--input-type=commonjs로 명시할 수 있습니다.- 최신 Node는 애매한
.js에서 문법 감지를 시도할 수 있지만, 공식 문서도type을 명시하는 쪽을 권장합니다.
설정에서 중요한 필드는 type입니다. module 필드는 번들러나 일부 도구가 참고할 수는 있어도, Node 런타임이 .js를 CommonJS인지 ESM인지 판별하는 기준은 아닙니다. Node 문서의 package.json runtime fields도 main, type, exports, imports 같은 필드를 중심으로 설명합니다.
새 애플리케이션과 라이브러리에서 자주 쓰는 설정은 대체로 아래 형태로 정리됩니다.
// 애플리케이션
{
"name": "demo-app",
"type": "module"
}
// 라이브러리
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
여기서 exports는 main보다 더 엄격한 공개 API를 만들 수 있습니다. 다만 기존 패키지에 뒤늦게 exports를 추가하면, 예전에 열려 있던 내부 경로가 막히면서 ERR_PACKAGE_PATH_NOT_EXPORTED가 생길 수 있습니다. 마이그레이션 포인트를 같이 확인해야 하는 이유입니다.
값과 상태가 다르게 보이는 이유
문법보다 의미 차이가 잘 드러나는 예시는 상태값입니다. 먼저 CommonJS입니다.
// state.cjs
let count = 0;
function inc() {
count += 1;
}
module.exports = { count, inc };
// main.cjs
const { count, inc } = require('./state.cjs');
console.log('before', count);
inc();
console.log('after', count);
// before 0
// after 0
겉으로 보면 inc()가 실행됐는데 after가 1이 아니어서 이상하게 보일 수 있습니다. 이유는 module.exports = { count, inc }에서 count의 현재 값 0이 객체 속성으로 한 번 들어갔기 때문입니다. 그리고 main.cjs의 구조 분해는 그 속성 값을 로컬 상수 count에 다시 담습니다. 이후 바뀌는 것은 모듈 내부 변수 count이지, 이미 꺼내온 로컬 상수는 아닙니다.
같은 CommonJS라도 export 형태를 바꾸면 관찰 결과가 달라집니다. 예를 들어 module.exports = { get count() { return count }, inc }처럼 getter로 내보내고 state.count로 읽으면, 읽는 시점의 최신 값이 보입니다. CommonJS가 상태 공유를 못 하는 것이 아니라, module.exports를 어떤 모양으로 구성했는지가 중요하다는 뜻입니다.
ESM에서는 같은 예제가 다르게 보입니다.
// state.mjs
export let count = 0;
export function inc() {
count += 1;
}
// main.mjs
import { count, inc } from './state.mjs';
console.log('before', count);
inc();
console.log('after', count);
// before 0
// after 1
여기서 import { count }는 값 복사라기보다 export된 binding과의 연결에 가깝습니다. 그래서 inc()가 export 쪽 count를 바꾸면 import 쪽에서도 바뀐 값이 관찰됩니다. 다만 import한 쪽이 그 이름에 다시 대입하는 구조는 아닙니다. 읽기는 따라오지만, 소유권은 export한 모듈에 남아 있다고 보면 이해가 쉬운 편입니다.
상호 운용에서 더 헷갈리는 지점
ESM에서 CommonJS를 읽을 때는 default와 named import를 같은 의미로 보면 자주 어긋납니다. Node의 ESM 문서는 CommonJS를 가져올 때 module.exports 객체가 default export로 제공된다고 설명합니다. named export는 정적 분석으로 “보일 수도 있는” 편의 기능에 가깝습니다.
아래 코드는 그 차이를 그대로 보여줍니다.
// cjs.cjs
exports.count = 0;
exports.inc = function () {
exports.count += 1;
};
// main.mjs
import cjs, { count, inc } from './cjs.cjs';
console.log('before', count, cjs.count);
inc();
console.log('after', count, cjs.count);
// before 0 0
// after 0 1
여기서 count는 CommonJS 쪽 exports.count를 정적 분석으로 꺼내온 named import라서 값이 따라오지 않습니다. 반면 cjs는 module.exports 자체를 가리키는 default 쪽이어서 cjs.count는 변경 후 값을 봅니다. Node 문서도 CommonJS named export는 정적 분석 결과이며, module.exports의 이후 변경이나 live update를 추적하지 않는다고 설명합니다.
이 차이 때문에 CommonJS를 ESM에서 읽을 때는 다음 정도로 정리해두면 덜 헷갈립니다.
- CommonJS를 가져올 때 가장 예측 가능한 기본값은
default import입니다. - named import는 작동할 수 있지만, 항상 안정적인 계약으로 보기는 어렵습니다.
exports.foo = ...같은 흔한 패턴은 잡히는 경우가 많아도,module.exports = someFactory()같은 패턴은 named export 추론이 되지 않을 수 있습니다.- 최근 Node에서는 namespace 출력에
'module.exports'항목이 함께 보일 수도 있는데, 이것도 interop 계층을 드러내는 신호로 이해하면 됩니다.
흔한 오해 세 가지
ESM은 비동기 모듈이고 CommonJS는 동기 모듈이다: 이렇게만 외우면 절반만 맞습니다. CommonJS의require()는 동기 함수가 맞지만, ESM의 staticimport를 단순한 비동기 함수 호출처럼 이해하면 어긋납니다. ESM은 먼저 그래프를 연결하고,top-level await가 있을 때 비동기성이 노출됩니다.import { x }는 항상 값 복사다`: ESM에서는 그렇지 않습니다. 다만 CommonJS에서 추론된 named export를 ESM에서 가져오면 결과가 “복사된 값처럼” 보일 수 있습니다. 같은 문법처럼 보여도 출처가 다르면 의미도 달라집니다.package.json의module필드가 Node 모듈 방식을 정한다: Node 런타임 기준은type, 확장자,exports,imports`입니다. 오래된 번들러 글을 읽다가 이 부분이 섞이는 경우가 많습니다.
자주 만나는 오류 출력으로 이해하는 디버깅 포인트
아래 출력은 로컬 Node.js v24.14.0에서 확인한 예시입니다. 이 주제는 명령만 보는 것보다, 실제 오류 첫 줄과 핵심 메시지를 같이 보는 편이 이해가 빠릅니다.
$ node --input-type=commonjs --eval "import fs from 'node:fs'"
[eval]:1
import fs from 'node:fs'
^^^^^^
SyntaxError: Cannot use import statement outside a module
...
Node.js v24.14.0
$ node --input-type=module --eval "require('node:fs')"
file:///Users/sejiwork/workspace/codex/[eval1]:1
require('node:fs')
^
ReferenceError: require is not defined in ES module scope, you can use import instead
...
Node.js v24.14.0
$ zsh -lc 'node --input-type=commonjs --eval "const p=process.argv[1]; console.log(require(p))" <(printf "export default 1\nexport const x = 2\n")'
[Module: null prototype] { __esModule: true, default: 1, x: 2 }
$ zsh -lc 'node --input-type=commonjs --eval "const p=process.argv[1]; console.log(require(p))" <(printf "await 1\nexport default 1\n")'
ERR_REQUIRE_ASYNC_MODULE require() cannot be used on an ESM graph with top-level await. Use import() instead.
From /Users/sejiwork/workspace/codex/[eval]
Requiring /dev/fd/11
첫 번째 오류는 코드가 CommonJS로 해석된 상태에서 import 문법을 썼다는 뜻입니다. 이 경우 문법 자체보다 파일 확장자와 package.json의 type부터 확인하는 편이 빠릅니다. 두 번째 오류는 반대로 ESM 스코프 안에서 CommonJS 전용 전역인 require를 직접 쓴 경우입니다. 이때는 import로 바꾸거나, 경계 지점에서만 module.createRequire()를 쓰는 쪽이 보통 덜 복잡합니다.
세 번째와 네 번째 출력은 오래된 설명과 현재 Node 기준이 달라진 지점을 보여줍니다. 예전에는 “CommonJS에서 ESM은 require()로 못 읽는다”는 설명이 일반적이었지만, 현재 Node에서는 동기적인 ESM이면 require()가 읽을 수 있습니다. 다만 반환값은 보통 모듈 namespace object이고, top-level await가 있는 ESM 그래프는 ERR_REQUIRE_ASYNC_MODULE로 실패합니다.
여기서 함께 봐둘 만한 변화가 하나 더 있습니다. Node errors 문서에 따르면 ERR_REQUIRE_ESM은 v23.0.0, v22.12.0, v20.19.0부터 deprecated입니다. 그래서 예전 글에서 ERR_REQUIRE_ESM을 대표 오류처럼 설명해도, 현재 지원 버전에서는 ERR_REQUIRE_ASYNC_MODULE처럼 더 구체적인 오류를 먼저 보게 될 수 있습니다.
언제 무엇을 기본값으로 둘까
새 Node.js 애플리케이션은 ESM을 기본값으로 두고 필요한 일부 파일만 .cjs로 남기는 구성이 대체로 관리하기 편합니다. package.json에 "type": "module"을 두고, 상대 import 경로에 확장자를 명시하고, 오래된 도구 설정 파일만 .cjs로 분리하면 경계가 비교적 선명해집니다.
기존 CommonJS 코드베이스는 한 번에 전환하지 않아도 됩니다. 이미 require(), 테스트 유틸, 배포 스크립트, 설정 파일이 맞물려 있다면 패키지 기본값은 유지하고 새 파일이나 leaf module부터 나누는 편이 부담이 덜합니다. 특히 설정 파일과 빌드 스크립트는 애플리케이션 코드보다 보수적으로 가져가는 경우가 많습니다.
라이브러리는 선택 기준이 조금 더 까다롭습니다. 소비자가 모두 최신 Node와 최신 번들러라면 ESM 중심 배포가 단순합니다. 반대로 아직 require() 소비자가 많다면 exports에서 import와 require를 나누는 이중 엔트리가 필요할 수 있습니다. 다만 이 경우 같은 패키지가 import 엔트리와 require 엔트리로 각각 로드되면서 상태가 둘로 갈라질 수 있습니다. 싱글턴 상태, 캐시, 인스턴스 비교가 중요한 라이브러리라면 이 부분을 테스트로 확인해두는 편이 좋습니다.
결국 선택 기준은 문법 취향보다 런타임 경계와 상태 소유권에 더 가깝습니다. 한 패키지 안에서 기본 모듈 시스템을 먼저 정하고, 다른 시스템은 필요한 경계 파일에서만 제한적으로 섞는 구성이 유지보수에서 더 읽히는 경우가 많습니다.
관련 문서를 같이 보면 판단이 더 쉬워집니다.
- Node.js Packages: https://nodejs.org/api/packages.html
- Node.js ESM: https://nodejs.org/api/esm.html
- Node.js CommonJS: https://nodejs.org/api/modules.html
- Node.js Errors: https://nodejs.org/dist/latest/docs/api/errors.html
- ECMAScript Spec: https://tc39.es/ecma262/#sec-module-semantics
원문 참고
https://www.maeil-mail.kr/question/38
댓글
댓글 쓰기