Published on

mjs와 cjs를 혼용 시 알아야할 것들

Authors
  • avatar
    Name
    sulmo
    Twitter

 

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)를 불러올 때의 한계점은 생각보다 많다. 핵심 한계점과 함께, 왜 그런지, 어떻게 우회할 수 있는지까지 정리 필요.

1. require() 사용 불가 (ESM 내부에서는 금지)

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)

2. CommonJS의 module.exports는 기본적으로 default로 들어옴

// 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

4. __filename, __dirname, require.resolve, require.cache 등 사용 불가

ESM 환경에서는 CJS에서 쓰던 전역 변수들이 없음.

기능CJSESM
__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)

5. CJS 모듈 내 require.extensions핵심 해킹 포인트 사용 불가

require.extensions 등을 활용한 dynamic require 확장은 ESM에서 무력화됨.
일부 레거시 코드나 polyfill 라이브러리에서 문제 생길 수 있음.


CJS → ESM import 시 한계점

CJS → ESM import 시 한계점은 ESM → CJS보다 더 까다롭고 제한적이다. CommonJS는 기본적으로 동기 실행 모델이고, ESM은 비동기 로딩 모델이기 때문.

1. require()로 ESM 모듈 불러오면 에러 발생

// main.cjs
const utils = require('./utils.mjs') // ❌ Error [ERR_REQUIRE_ESM]
  • require()는 CommonJS의 동기 방식
  • .mjs"type": "module"이 적용된 .js 파일은 비동기적으로만 로딩 가능

2. 해결 방법: import()동적 로딩만 가능

// main.cjs
;(async () => {
  const utils = await import('./utils.mjs')
  utils.doSomething()
})()
  • import()는 항상 Promise 반환
  • 즉, 최상위에서 바로 못 씀async IIFE (즉시 실행 함수) 등으로 감싸야 함

3. default 키워드 필요 (ESM은 기본 내보내기 다르게 처리됨)

// 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로 접근해야 함

4. 최상위 await 지원 안됨 (CJS에서는 문법적으로 불가능)

// ❌ CommonJS 파일
const mod = await import('./esm.mjs') // SyntaxError: await is only valid in async functions and the top level bodies of modules

→ 항상 비동기 함수로 감싸야 함

5. Named exports 사용 시에도 default와 병렬됨

// 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 전환이 피할 수 없는 트렌드