CommonJS와 ESM을 함께 사용할 때 생기는 경계 문제
CommonJS와 ESM을 함께 사용할 때 생기는 경계 문제
Node.js 프로젝트가 오래되면 require를 쓰는 CommonJS 파일과 import를 쓰는 ESM 파일이 공존하기 쉽다. 문법만 다르다고 생각하면 테스트, 실행 경로, 기본 내보내기에서 예상하지 못한 차이를 만난다.
- CommonJS는 실행 시점에 모듈을 불러오고 ESM은 정적으로 의존성을 분석한다.
- 두 체계를 섞을 때는 파일 확장자, package.json의 type, default export 변환을 함께 확인해야 한다.
- 마이그레이션은 진입점보다 의존성이 적은 leaf 모듈부터 진행하는 편이 안전하다.
목차
- #두 모듈 시스템은 평가 방식이 다르다
- #경계에서 default export가 달라진다
- #점진적으로 전환하는 순서
- #순환 의존성에서 차이가 더 크게 보인다
- #경계별로 무엇이 달라지는지 정리하기
- #마이그레이션할 때 내가 확인할 순서
- #결론
- #관련 노트
두 모듈 시스템은 평가 방식이 다르다
CommonJS의 require는 일반 함수 호출처럼 조건문 안에서도 실행할 수 있다. ESM의 정적 import는 파일을 평가하기 전에 의존성 그래프를 만든다. 이 차이 때문에 순환 참조와 초기화 순서가 서로 다르게 드러난다.
// CommonJS
const config = require("./config.cjs");
// ESM
import config from "./config.js";
package.json에 type이 module이면 .js를 ESM으로 해석한다. 반대로 type이 없거나 commonjs이면 .js를 CommonJS로 본다. 실행 환경이 파일을 어떤 모듈로 해석하는지 먼저 확정해야 한다.
경계에서 default export가 달라진다
CommonJS의 module.exports는 하나의 값이고 ESM의 default export는 모듈 네임스페이스의 한 항목이다. 빌드 도구가 둘 사이를 자동 변환해 주더라도 실행기와 테스트 도구가 같은 규칙을 쓴다는 보장은 없다.
가져온 값이 함수라고 예상했는데 { default: function } 형태로 들어오거나, named export가 undefined가 된다. 이때 호출부를 억지로 고치기 전에 모듈 경계를 확인한다.
점진적으로 전환하는 순서
- package.json의 type과 실행 명령을 기록한다.
- 외부에서 참조하지 않는 작은 모듈부터 ESM으로 바꾼다.
- 경계 파일에는 명시적인 adapter를 둔다.
- 테스트, CLI, 배치 진입점을 각각 실행한다.
- 전체가 전환되면 임시 adapter를 제거한다.
// CommonJS 패키지를 ESM 경계에서 명시적으로 받는다.
import legacyPackage from "legacy-package";
const { createClient } = legacyPackage;
순환 의존성에서 차이가 더 크게 보인다
두 모듈이 서로를 가져오는 순환 의존성이 있으면 로딩 방식의 차이가 눈에 띄게 드러난다. 아래 코드는 설명을 위해 만든 작은 예시다.
// user-service.cjs
const { writeAuditLog } = require("./audit-service.cjs");
exports.createUser = function createUser(name) {
writeAuditLog(`created:${name}`);
};
// audit-service.cjs
const { createUser } = require("./user-service.cjs");
exports.writeAuditLog = function writeAuditLog(message) {
console.log(message);
};
exports.createSystemUser = function createSystemUser() {
return createUser("system");
};
CommonJS는 파일을 실행하면서 module.exports 객체를 채운다. 순환 고리 중간에서 아직 실행이 끝나지 않은 모듈을 가져오면 완성되지 않은 exports 객체를 받을 수 있다. 그래서 어떤 함수는 존재하고 어떤 함수는 undefined인 상태가 실행 순서에 따라 나타난다.
ESM도 순환 참조 자체를 금지하지는 않는다. 다만 export를 단순한 값 복사본이 아니라 live binding으로 연결하고, 모듈 그래프를 먼저 만든 뒤 평가한다. 이 경우에는 초기화 전에 값을 읽으면서 temporal dead zone 오류가 드러날 수 있다.
CommonJS를 ESM으로 바꾸는 것만으로 설계 문제가 사라지지는 않는다. 두 서비스가 함께 필요로 하는 작은 규칙을 세 번째 모듈로 옮기거나, 한쪽이 이벤트 또는 함수 인자를 통해 다른 쪽을 호출하도록 의존 방향을 바꾸는 편이 근본적인 해결이다.
flowchart LR
A[UserService] --> C[AuditPort]
B[AuditService] --> C
D[Application] --> A
D --> B위 구조에서는 UserService와 AuditService가 서로를 직접 가져오지 않는다. 애플리케이션 조립 지점이 두 모듈을 연결한다.
경계별로 무엇이 달라지는지 정리하기
| 확인할 지점 | CommonJS | ESM |
|---|---|---|
| 기본 문법 | require, module.exports |
import, export |
| 파일 해석 | type, .cjs |
type: module, .mjs |
| 의존성 분석 | 실행 중 동적으로 가능 | 정적 import를 먼저 분석 |
최상위 await |
직접 지원하지 않음 | 지원 |
| 현재 파일 URL | __filename, __dirname |
import.meta.url |
| JSON 로딩 | require로 가능 |
런타임별 import attribute 확인 |
| 순환 참조 | 부분 exports가 보일 수 있음 | 초기화 전 binding 접근 주의 |
테스트 러너와 번들러가 자동 변환을 해준다면 개발 환경에서는 문제가 가려질 수 있다. 실제 Node.js로 진입점을 직접 실행하는 smoke test를 함께 두는 이유다.
{
"scripts": {
"test": "vitest run",
"smoke": "node dist/server.js --check-config"
}
}
마이그레이션할 때 내가 확인할 순서
- 실행 진입점을 웹 서버, 배치, CLI, 테스트로 나눈다.
- 각 진입점이 실제로 어떤 확장자와
package.json범위에서 실행되는지 적는다. - 의존성이 적은 유틸리티부터 바꾸고 경계 adapter를 둔다.
- default export와 named export 변환 결과를 작은 테스트로 고정한다.
- 순환 참조 경고를 목록으로 만들고 모듈 이동이 아니라 의존 방향을 정리한다.
- 마지막에 진입점과 배포 명령을 전환한다.
이 글의 모듈 이름과 함수는 실제 프로젝트 코드가 아니라 모듈 경계를 설명하기 위해 다시 만든 예시다.
결론
CommonJS와 ESM의 혼용 문제는 문법보다 로더의 규칙과 모듈 경계 문제다. 프로젝트 전체를 한 번에 바꾸기보다 경계를 명시하고 작은 단위로 이동하는 편이 안전하다.