- Published on
mjs와 cjs를 혼용 시 알아야할 것들
- Authors
- Name
- sulmo
mjs와 cjs가 공존 시, 코드로딩 플로우
ESM (.mjs
)과 CommonJS (.cjs
또는 .js
) 모듈이 혼용될 때 Node.js가 어떻게 처리하는지에 대해 이해하려면, 다음 몇 가지 핵심 개념을 알 필요가 있음.
1. Node.js는 "빌드"가 아니라 "런타임 로딩" 모델이다
Node.js는 컴파일러처럼 전체 코드를 미리 "빌드"하지 않는다. 대신 진입점부터 시작해서 필요한 모듈을 하나씩 런타임에 로딩하면서 실행한다.
2. 모듈 해석 순서 (when ESM + CJS are mixed)
Node.js는 해석 방식이 다르기 때문에 ESM과 CJS를 구분해서 처리해:
구분 | .js 확장자 | .mjs 확장자 | .cjs 확장자 |
---|---|---|---|
해석 방식 | package.json 의 "type" 에 따라 CJS or ESM | 무조건 ESM | 무조건 CJS |
요약:
- 진입점이
.mjs
또는"type": "module"
이면, ESM 모드에서 시작 - ESM에서 CJS를
import
하려면 동적 import (await import()
) 사용 - CJS에서 ESM을
require()
사용 불가 (ESM은 동적으로만 로딩 가능)
3. 혼용 시 로딩 방식 (순서보단 "방식"이 중요)
예시 구조:
project/
├─ index.mjs (ESM)
├─ legacy.cjs (CJS)
└─ utils/
├─ helper.mjs
└─ converter.cjs
동작 흐름:
index.mjs
가 진입점이면 ESM 모드에서 시작import './utils/helper.mjs'
→ 정적 import (즉시 분석 및 로딩)import('./legacy.cjs')
→ 동적 import (Promise 기반 로딩), 런타임 시점에서 수행됨
→ 즉, ESM 모듈은 "정적 분석" 대상이 되고,
CJS 모듈은 런타임에 해석되는 방식
정리
개념 | 설명 |
---|---|
🔍 정적 분석 | ESM 모듈은 실행 전 import/export를 분석함 (호이스팅처럼) |
🕐 런타임 해석 | CJS는 require() 되는 순간 실행 및 평가 |
⚠️ 혼용 주의 | CJS → ESM import는 매우 제한적 (동적 import만 가능) |
🧩 종속성 그래프 | 진입점 기준으로 ESM/CJS를 구분하여 혼합된 그래프를 구성함 |
ESM → CJS import 시 한계점
ESM에서 CJS(CommonJS)를 불러올 때의 한계점은 생각보다 많다. 핵심 한계점과 함께, 왜 그런지, 어떻게 우회할 수 있는지까지 정리 필요.
require()
사용 불가 (ESM 내부에서는 금지)
1. ESM 모듈 안에서는 require()
함수가 정의되지 않음.
// some-esm.mjs
const legacy = require('./legacy.cjs') // ❌ ReferenceError: require is not defined
해결 방법
ESM에서는 동적 import()
를 사용해야 함:
// some-esm.mjs
const legacy = await import('./legacy.cjs') // ✅ 가능 (Promise)
module.exports
는 기본적으로 default
로 들어옴
2. CommonJS의 // legacy.cjs
module.exports = {
sayHello: () => console.log('hi'),
}
ESM에서 아래와 같이 사용 불가
const legacy = await import('./legacy.cjs')
legacy.sayHello() // ❌ TypeError: legacy.sayHello is not a function
console.log(legacy)
// {
// default: { sayHello: [Function] }
// }
해결 방법
const { default: legacy } = await import('./legacy.cjs')
legacy.sayHello() // ✅ 작동함
3. ESM은 동기적으로 CJS를 import할 수 없음
CommonJS의 require()
는 동기인데, ESM의 import()
는 비동기 Promise라서
코드 흐름상 동기 import가 필요하면 구조 자체를 바꿔야 함
// ❌ 안 되는 방식
import legacy from './legacy.cjs' // TypeError: Cannot use import statement to import CommonJS
__filename
, __dirname
, require.resolve
, require.cache
등 사용 불가
4. ESM 환경에서는 CJS에서 쓰던 전역 변수들이 없음.
기능 | CJS | ESM |
---|---|---|
__dirname | ✅ | ❌ |
__filename | ✅ | ❌ |
require.resolve() | ✅ | ❌ |
require.cache | ✅ | ❌ |
__dirname
구현)
해결 방법 (예시: import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
require.extensions
등 핵심 해킹 포인트 사용 불가
5. CJS 모듈 내 require.extensions
등을 활용한 dynamic require 확장은 ESM에서 무력화됨.
일부 레거시 코드나 polyfill 라이브러리에서 문제 생길 수 있음.
CJS → ESM import 시 한계점
CJS → ESM import 시 한계점은 ESM → CJS보다 더 까다롭고 제한적이다. CommonJS는 기본적으로 동기 실행 모델이고, ESM은 비동기 로딩 모델이기 때문.
require()
로 ESM 모듈 불러오면 에러 발생
1. // main.cjs
const utils = require('./utils.mjs') // ❌ Error [ERR_REQUIRE_ESM]
require()
는 CommonJS의 동기 방식.mjs
나"type": "module"
이 적용된.js
파일은 비동기적으로만 로딩 가능
import()
로 동적 로딩만 가능
2. 해결 방법: // main.cjs
;(async () => {
const utils = await import('./utils.mjs')
utils.doSomething()
})()
import()
는 항상 Promise 반환- 즉, 최상위에서 바로 못 씀 → async IIFE (즉시 실행 함수) 등으로 감싸야 함
default
키워드 필요 (ESM은 기본 내보내기 다르게 처리됨)
3. // utils.mjs
export default function greet() {
console.log('Hello from ESM!')
}
// main.cjs
;(async () => {
const mod = await import('./utils.mjs')
mod.default() // ✅
})()
CJS에서 ESM을 불러오면, ESM의 default export는
.default
로 접근해야 함
await
지원 안됨 (CJS에서는 문법적으로 불가능)
4. 최상위 // ❌ CommonJS 파일
const mod = await import('./esm.mjs') // SyntaxError: await is only valid in async functions and the top level bodies of modules
→ 항상 비동기 함수로 감싸야 함
default
와 병렬됨
5. Named exports 사용 시에도 // esm.mjs
export const a = 1
export const b = 2
// main.cjs
;(async () => {
const mod = await import('./esm.mjs')
console.log(mod.a) // ✅
})()
→ Named export는 잘 들어오긴 하는데, 여전히 Promise
로 비동기 처리 필요
정리
제한 | 설명 | 해결 방법 |
---|---|---|
require() 로 ESM 불가 | ERR_REQUIRE_ESM 발생 | import() 사용 |
동기 import 불가 | CJS는 동기, ESM은 비동기 | async 함수로 감싸기 |
최상위 await 불가 | CJS는 top-level await 미지원 | IIFE or 함수 안에서 사용 |
export 구조 차이 | default export는 .default 필요 | 구조 분해 또는 접근 명시 |
비동기 전환 필요 | 전체 모듈 로딩 흐름 바뀜 | CJS 모듈도 async 흐름 고려 필요 |
실무 팁
- 가능하면 하나의 모듈 시스템만 사용하는 것이 좋음
- 새로운 코드는 ESM으로 작성하고, 기존 CJS 코드는 점진적으로 마이그레이션
- 번들러(예: Vite, Webpack)는 별도 처리 (자체 방식으로 종속성 해석함)
- CJS가 진입점인 앱에서 ESM을 쓰고 싶다면:
- 꼭 필요한 경우에만
import()
로 호출 - 또는 전체 프로젝트를 ESM으로 마이그레이션 고려
- 꼭 필요한 경우에만
- 최근엔 대부분의 라이브러리들이 ESM-only로 배포되는 경우도 있어서, → CJS → ESM 전환이 피할 수 없는 트렌드