본문 바로가기
프론트엔드

ESM CommonJS 차이, 배포 후에야 드러나는 7가지 함정 (Node 24 실습)

by 수목원 2026. 10. 8.

Node.js에서 다른 파일의 코드를 가져오는 방법은 크게 두 가지입니다.

import/export를 쓰는 ESM(ECMAScript Modules)은 자바스크립트 표준 모듈 방식이고,

require()/module.exports를 쓰는 CommonJS(CJS)는 Node가 처음부터 써 온 방식입니다.

 

새로 짜는 코드는 대부분 ESM이지만,

npm에는 아직 CommonJS로 배포된 패키지가 많고 오래된 서버 코드에도 require가 남아 있어서,

실제 프로젝트에서는 두 방식이 한 프로세스 안에 섞이게 됩니다.

 

문제는 두 방식이 "값을 가져온다"는 겉모습만 같고 속 동작은 다르다는 점입니다.

예를 들어 ESM에서 export default로 내보낸 함수를 CommonJS에서 require()로 받으면,

함수가 아니라 모듈 객체가 돌아와서 TypeError: format is not a function이 납니다.

(.default 한 줄로 급한 불을 끄고 넘어가는 경우가 생각보다 많습니다...)

 

이런 차이는 로컬에서는 잘 드러나지 않다가 배포한 뒤에야 터지는 경우가 많아서,

작은 예제로 7가지 함정을 하나씩 재현해 보았습니다.

Gabor Koos의 글에서 문제의식을 얻었고, 아래 출력은 모두 Node v24.21.0에서 직접 돌려본 결과를 그대로 붙였습니다.

(Node v20.19.2에서도 결과는 거의 같았고, 에러 메시지 속 /workspace/... 경로는 제 실습 환경 경로입니다)

1. live binding과 구조분해 copy의 차이

state.cjs에는 status = 'starting'과,

이 값을 'ready'로 바꾸는 start() 함수가 들어 있습니다.

CommonJS에서 이 둘을 구조분해로 받아 start()를 호출해 보면 다음과 같습니다.

 

01-binding/app-destructure.cjs

const { status, start } = require('./state.cjs')
start()
console.log('[CJS destructure]', status)
$ node app-destructure.cjs
[CJS destructure] starting

start()는 정상적으로 실행됐지만,

구조분해로 받은 지역 변수 status에는 그 시점의 값 starting이 이미 복사되어 있어서 바뀌지 않습니다.

반면 같은 구조를 ESM으로 만들면 ready가 출력되는데,

ESM에서 가져온 이름은 복사본이 아니라 원본 변수와 계속 연결되어 있기 때문이고, 이를 live binding이라고 부릅니다.

 

CommonJS에서도 const state = require(...)처럼 객체째 받아 state.status로 읽으면 바뀐 값이 보입니다.

설정이나 인증 상태처럼 나중에 바뀌는 값을 구조분해해 두면,

초기값으로 판단하는 코드가 생길 수 있습니다.

ESM은 바인딩을 읽고, CommonJS 소비자는 받은 값이나 객체 참조를 보관합니다.

2. import 위치와 실제 실행 순서

02-order/app.mjs

console.log('app: before import')
import './setup.mjs'
console.log('app: after import')
$ node app.mjs
setup
app: before import
app: after import

파일 중간에 적은 import인데도 setup이 가장 먼저 출력됩니다.

Node는 현재 파일을 실행하기 전에 import 문을 먼저 모두 모아서,

가져올 파일들을 먼저 실행한 뒤에야 본문을 위에서부터 실행하기 때문입니다.

(환경변수를 먼저 설정하고 SDK를 import하려는 코드에서 자주 걸리는 부분입니다)

 

require()는 실행이 그 줄에 도착할 때 로딩되므로 순서가 적힌 그대로이고,

ESM에서 실행 시점에 불러오려면 Promise를 반환하는 동적 import()를 써야 합니다.

또 ESM은 파일명이 아니라 URL로 모듈을 구분해서,

캐시를 피하려고 ?v=2 같은 query string을 붙이면 같은 파일이 별도 인스턴스로 다시 실행됩니다.

그 파일이 연결 풀이나 이벤트 리스너를 만든다면,

query string이 바뀔 때마다 초기화가 반복될 수 있습니다.

3. 순환 의존성과 TDZ

a가 b를 가져오고 b가 다시 a를 가져오는 순환 의존성(Circular Dependency)에서,

CommonJS는 아직 실행이 끝나지 않은 a의 exports를 그대로 넘겨줍니다.

에러 없이 끝까지 실행되지만, b는 덜 만들어진 값을 읽은 셈입니다.

 

같은 구조를 ESM으로 만들면 이번에는 에러가 납니다.

 

03-cycle/b.mjs

import { a } from './a.mjs'
console.log('b: a =', a)
export const b = 'b'
$ node a.mjs
file:///workspace/tistory-plan/posts/modules/examples/03-cycle/b.mjs:2
console.log('b: a =', a)
                      ^
ReferenceError: Cannot access 'a' before initialization

a라는 이름은 이미 만들어져 있지만 아직 값이 들어가지 않은 상태라서,

TDZ(Temporal Dead Zone)에 걸려 ReferenceError가 납니다.

(TDZ는 let/const가 선언은 됐지만 값이 들어가기 전의 구간입니다)

 

해결은 타이머로 읽기를 늦추는 것보다,

두 모듈이 함께 쓰는 값을 별도의 shared.mjs로 내려서 서로를 가져오는 연결 자체를 없애는 편이 낫습니다.

반응형

4. ESM에서 CommonJS를 가져올 때 named import

status.cjs는 exports.status = 'starting'으로 시작해,

100ms 뒤에 값을 'ready'로 바꿉니다.

이것을 ESM에서 default와 이름(named) 두 가지로 가져와 200ms 뒤에 읽어 보았습니다.

 

04-cjs-from-esm/app.mjs

import statusModule, { status } from './status.cjs'
setTimeout(() => {
  console.log('default import  :', statusModule.status)
  console.log('named import    :', status)
}, 200)
$ node app.mjs
default import  : ready
named import    : starting

default로 받은 것은 module.exports 객체 자체라서 바뀐 값이 보이지만,

이름으로 가져온 status는 가져온 순간의 값에서 멈춰 있습니다.

CommonJS에서 만든 named export에는 앞에서 본 live binding이 적용되지 않는 것이라,

상태가 바뀌는 CommonJS 라이브러리라면 default로 받은 객체에서 값을 읽는 편이 안전합니다.

 

또 Node는 CommonJS 코드를 실행하지 않고 텍스트만 훑어서(cjs-module-lexer) export 이름을 찾기 때문에,

반복문 등으로 실행 중에 만들어지는 이름은 놓칩니다.

이때 정적 named import는 SyntaxError: Named export 'alpha' not found로 실패하므로,

패키지 업데이트 후 named import가 깨졌다면 함수 삭제만 의심하지 않는 것이 좋겠습니다.

5. CommonJS에서 ESM을 require할 때

도입부의 format is not a function이 바로 이 경우입니다.

 

05-esm-from-cjs/report.cjs

const formatter = require('./formatter.mjs')
console.log('keys      :', Object.keys(formatter))
console.log('version   :', formatter.version)
console.log('default() :', formatter.default('ok'))
const format = require('./formatter.mjs')
try { format('ok') } catch (e) { console.log('direct call ->', e.constructor.name + ':', e.message) }
$ node report.cjs
keys      : [ '__esModule', 'default', 'version' ]
version   : 2.0
default() : OK
direct call -> TypeError: format is not a function

실습한 Node 버전에서는 ESM을 require()로 가져올 수 있지만,

돌아오는 것은 함수가 아니라 모듈이 내보낸 것 전부를 담은 namespace 객체입니다.

그래서 formatter.default('ok')는 성공하고 format('ok')는 실패합니다.

의존성이 네이티브 ESM인지, 변환된 CommonJS인지에 따라 돌아오는 모양이 다르니,

위처럼 Object.keys()로 한 번 찍어 보면 빠르게 확인할 수 있습니다.

 

또 가져오려는 모듈이나 그 의존성에 top-level await(파일 최상위에서 바로 쓰는 await)가 있으면,

require()는 ERR_REQUIRE_ASYNC_MODULE로 실패하고 동적 import()만 동작합니다.

라이브러리에 top-level await 하나를 추가하는 변경이,

CommonJS로 쓰는 쪽에는 깨지는 변경이 될 수 있다는 뜻입니다.

6. 듀얼 패키지 해저드

듀얼 패키지는 하나의 패키지가 import용(ESM)과 require용(CommonJS) 파일을 함께 제공하는 구성이고,

해저드(hazard)는 두 파일이 한 프로세스에서 동시에 로딩되면서 생기는 위험을 말합니다.

아래는 같은 패키지를 import와 require로 각각 가져와 비교한 코드입니다.

 

06-dual/consumer/hazard.mjs

import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)

for (const pkg of ['token-lib', 'token-lib-fixed']) {
  const esm = await import(pkg)
  const cjs = require(pkg)
  const t = new esm.Token('abc')
  esm.registry.set('plugin', true)
  console.log(`[${pkg}] instanceof(require side) =`, t instanceof cjs.Token,
              '| registry shared =', cjs.registry.has('plugin'))
}
$ node hazard.mjs
[token-lib] instanceof(require side) = false | registry shared = false
[token-lib-fixed] instanceof(require side) = true | registry shared = true

token-lib는 두 파일이 각자 클래스와 Map을 만들어서,

instanceof가 실패하고 등록 정보도 공유되지 않습니다.

도입부에서 말한 플러그인 등록 정보가 사라지는 문제가 이 경우입니다.

 

token-lib-fixed는 실제 구현을 CommonJS 파일 한 곳에 두고,

ESM 파일은 그것을 다시 내보내기만 해서 두 검사 모두 통과합니다.

다만 같은 패키지가 node_modules 안에 서로 다른 경로로 중복 설치되면,

이 방법만으로는 해결되지 않습니다.

(Redux 직접 구현해보는 과정의 모듈 수준 store도, 진입점이 둘이면 하나만 공유된다는 보장이 없습니다)

실제 로그에서 바인딩 갱신, 순환 의존성의 중간 상태, 듀얼 패키지의 상태 분리를 함께 확인합니다.

7. npm pack 스모크 테스트로 files 누락 확인

06-dual/token-lib-broken/package.json

{
  "name": "token-lib-broken",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" }
  },
  "files": ["dist/index.js"]
}

exports의 require 경로는 dist/index.cjs를 가리키지만,

배포물에 포함할 파일을 지정하는 files에는 dist/index.js만 들어 있습니다.

저장소에는 두 파일이 모두 있어서 소스 경로로 테스트하면 통과하는데,

npm pack으로 만든 tarball을 설치한 뒤 require('token-lib-broken')을 실행하면 다음처럼 실패합니다.

$ node smoke.cjs
node:internal/modules/cjs/loader:761
      throw e;
      ^
Error: Cannot find module '/workspace/tistory-plan/posts/modules/examples/06-dual/consumer/node_modules/token-lib-broken/dist/index.cjs'
  code: 'MODULE_NOT_FOUND',
  path: '/workspace/tistory-plan/posts/modules/examples/06-dual/consumer/node_modules/token-lib-broken'
}

같은 tarball에서 ESM 쪽 import는 dist/index.js가 들어 있어서 정상 동작하고,

CommonJS 쪽만 진입점 파일을 찾지 못하는 것입니다.

exports에 경로를 적는다고 그 파일이 자동으로 패키징되지는 않습니다.

 

npm pack은 실제로 npm에 올라갈 파일만 묶어 주는 명령이라,

빌드 후 tarball을 별도 프로젝트에 설치해서 import와 require를 모두 실행해 보는 스모크 테스트를 CI에 넣어 두면,

이런 누락을 배포 전에 잡을 수 있습니다.

(저장소 기준 테스트가 초록색이면 안심하기 쉬운 부분입니다)

맺으며

ESM과 CommonJS의 차이를 볼 때는 import냐 require냐 하는 문법보다,

가져오는 쪽이 실제로 무엇을 받는지를 먼저 살펴보는 것이 좋겠습니다.

장애가 나면 Node 버전과 실행 방식을 고정하고,

받은 값이 함수인지 namespace인지, 언제 평가됐는지, 어느 인스턴스를 보고 있는지,

배포물에 진입점 파일이 실제로 들어 있는지 순서대로 확인하면 대부분의 원인이 좁혀집니다.

 

JS에서 이터레이터와 이터러블 차이처럼,

익숙한 이름 뒤의 실제 동작을 정확히 짚는 공부가 이런 디버깅에 도움이 됩니다.

팀에서 “이 패키지는 import로도 require로도 쓸 수 있습니다”라고 말할 때도,

두 경로가 어떤 값을 반환하고, 어떤 상태를 공유하며,

어디까지 동기적으로 초기화되는지를 함께 설명할 수 있으면 좋겠습니다.

 

CommonJS와 ESM이 섞인 Node 서버나 라이브러리를 운영하고,

듀얼 패키지를 배포하거나 가져다 쓰는 분께 도움이 되었으면 합니다.

 

참고한 글: Gabor Koos, Your Modules Are Lying to You (2026.08.14)

 

실습 환경: Node v24.21.0 / v20.19.2, 번들러 없이 Node 네이티브 로더로 실행