자바스크립트 모듈 - ESM 과 CommonJS 차이, 순환 참조 (JS 중급 15단원)
이 단원에서 배우는 것
9단원부터 14단원까지 만든 것들 — createLoanIdGenerator, LoanPolicy 클래스, borrow, buildReport — 을 지금까지 한 파일에 두고 설명했다. 실제 프로젝트에서는 파일을 나눈다. 문제는 자바스크립트에 모듈 시스템이 두 개 있고, 둘이 섞이는 지점에서 사고가 난다는 점이다. 13단원에서 "최상위 await 는 ES 모듈에서만 된다"고 미뤄 둔 이야기를 여기서 마무리한다. Node.js 20 이상 기준으로 쓴다.
- CommonJS 와 ESM 이 각각 어떻게 로드되는지 구분하고, Node 가 어떤 기준으로 둘을 판별하는지 안다.
- named export 와 default export 를 상황에 맞게 고르고, 동적
import()를 쓴다. - 순환 참조가 두 시스템에서 각각 어떻게 깨지는지 재현하고, 설계로 피한다.
왜 필요한가
파일을 나누는 이유는 "정리"가 아니다. 무엇이 밖으로 나가는지 통제하기 위해서다. 9단원의 클로저나 11단원의 #private 가 객체 안에서 한 일을, 모듈은 파일 단위로 한다. 모듈 안에서 선언한 것은 export 한 것만 밖에서 보인다. 나머지는 이름 충돌도 없고, 밖에서 수정할 수도 없다.
모듈이 없던 시절에는 이 통제가 불가능했다. 스크립트 태그를 여러 개 넣으면 모든 변수가 하나의 전역에 쌓였고, 로드 순서가 틀리면 undefined is not a function 이 났다. 9단원에서 본 즉시 실행 함수 관용구가 이 문제의 임시 처방이었다.
// 모듈 이전: 즉시 실행 함수로 전역 오염을 막던 방식
var LoanLib = (function () {
var seq = 0; // 밖에서 안 보인다
function nextId() { return ++seq; }
return { nextId: nextId }; // 이것만 내보낸다
})();
이 패턴이 언어 문법으로 들어온 것이 ES 모듈이다. 다만 그전에 Node.js 가 자체 방식(CommonJS)을 이미 널리 퍼뜨렸고, 그래서 지금도 둘이 공존한다.
문법과 예제
두 시스템 나란히 보기
| CommonJS (CJS) | ES Modules (ESM) | |
|---|---|---|
| 내보내기 | module.exports = ... / exports.x = ... | export / export default |
| 가져오기 | const x = require('./a.js') | import x from './a.js' |
| 로드 시점 | 실행 중 (동적) | 실행 전 (정적 분석) |
| 조건부 로드 | if 안에서 require 가능 | 불가. import() 함수를 쓴다 |
| 확장자 | 생략 가능 | 필수 (./books.js) |
최상위 await | 불가 | 가능 |
| 내보낸 값 | 복사본 (스냅샷) | 라이브 바인딩 |
__dirname | 있음 | 없음 (import.meta.dirname) |
Node 가 어느 쪽으로 해석할지는 다음 순서로 정한다. 확장자 .mjs → ESM, .cjs → CJS, .js 라면 가장 가까운 package.json 의 "type" 필드를 본다. "type": "module" 이면 ESM, 없거나 "commonjs" 면 CJS 다. 새 프로젝트는 "type": "module" 로 시작한다. 브라우저 쪽은 선택지가 없고 ESM 뿐이다.
ESM 내보내기와 가져오기
// books.js
export const CATEGORIES = ['JS', '설계', '네트워크'];
export function label(book) {
return `${book.id} ${book.title}`;
}
export class BookStore {
#items = new Map();
add(book) { this.#items.set(book.id, book); return this; }
get(id) { return this.#items.get(id); }
get size() { return this.#items.size; }
}
const INTERNAL_VERSION = 3; // export 하지 않으면 밖에서 보이지 않는다
// main.js
import { label, CATEGORIES, BookStore } from './books.js'; // 확장자 필수
import * as books from './books.js'; // 통째로 받기
import { label as bookLabel } from './books.js'; // 이름 바꾸기
const store = new BookStore();
store.add({ id: 'B001', title: '자바스크립트 완벽 가이드' });
console.log(store.size, label(store.get('B001'))); // 1 B001 자바스크립트 완벽 가이드
console.log(books.CATEGORIES); // [ 'JS', '설계', '네트워크' ]
export default 도 있다. 파일이 하나만 내보낼 때 쓴다.
// loanPolicy.js
export default class LoanPolicy { /* 11단원의 그것 */ }
export const DEFAULT_DAYS = 14; // default 와 named 를 섞어도 된다
// main.js — default 는 이름을 마음대로 붙일 수 있다
import Policy, { DEFAULT_DAYS } from './loanPolicy.js';
어느 쪽을 쓸지는 팀 규칙 문제지만, 근거는 있다. named export 는 이름이 강제되므로 검색과 자동 완성이 정확하다. default 는 가져오는 쪽이 이름을 마음대로 붙이므로 같은 클래스가 파일마다 다른 이름으로 불린다. 리팩터링 도구도 추적하지 못한다. 특별한 이유가 없으면 named 를 기본으로 한다.
import 는 정적이다 — 실행 전에 결정된다
if (isAdmin) {
import { deleteBook } from './admin.js'; // SyntaxError
}
console.log(label({ id: 'B001', title: '가' })); // 정상 동작한다
import { label } from './books.js'; // ← 아래에 적혀 있는데도
import 선언은 코드가 실행되기 전에 전부 처리된다. 그래서 조건문 안에 넣을 수 없고, 반대로 파일 맨 아래에 적어도 위에서 쓸 수 있다. 이 성질 덕분에 번들러가 "실제로 안 쓰는 export 는 빼는" 최적화(tree shaking)를 할 수 있다. CommonJS 의 require 는 실행 중에 호출되는 그냥 함수라 이게 불가능하다.
조건부로 불러와야 한다면 함수 형태의 import() 를 쓴다. Promise 를 반환하므로 12·13단원 내용이 그대로 적용된다.
async function loadReporter(format) {
if (format === 'excel') {
const { toExcel } = await import('./reporters/excel.js'); // 필요할 때만 로드
return toExcel;
}
const { toCsv } = await import('./reporters/csv.js');
return toCsv;
}
무겁고 가끔 쓰는 기능(엑셀 생성, 차트 라이브러리)을 초기 로딩에서 빼는 데 쓴다. 프런트엔드에서 "코드 분할"이라 부르는 것이 이것이다.
라이브 바인딩
ESM 이 내보내는 것은 값이 아니라 변수에 대한 참조다. 모듈 안에서 값이 바뀌면 가져간 쪽에서도 바뀐 값이 보인다.
// counter.js
export let loanCount = 0;
export function borrow() { loanCount += 1; }
// main.js
import { loanCount, borrow } from './counter.js';
console.log(loanCount); // 0
borrow();
borrow();
console.log(loanCount); // 2 ← 다시 import 하지 않았는데 바뀌었다
// loanCount = 10; // TypeError: Assignment to constant variable.
// 가져온 쪽에서는 읽기 전용이다
CommonJS 는 반대다. const { loanCount } = require('./counter.js') 로 꺼낸 값은 그 시점의 복사본이라 영원히 0 이다. 두 시스템을 오가는 코드에서 "값이 갱신되지 않는다"는 버그가 여기서 나온다.
다만 라이브 바인딩에 기대는 설계는 권하지 않는다. 어디서 값이 바뀌는지 추적하기 어렵다. 변하는 상태는 함수로 읽는다.
// counter.js — 이쪽이 낫다
let loanCount = 0;
export function borrow() { loanCount += 1; }
export function getLoanCount() { return loanCount; }
실무형 예제: 대출 서비스를 파일로 나눈다
// domain/loanPolicy.js — 순수 규칙. 아무것도 import 하지 않는다
export const DEFAULT_DAYS = 14;
export function dueDate(from, days = DEFAULT_DAYS) {
const d = new Date(from);
d.setDate(d.getDate() + days);
return d;
}
// infra/bookRepository.js — 데이터 접근만
export async function findBook(id) { /* 13단원의 그것 */ }
export async function saveLoan(book, memberId) { /* ... */ }
// service/loanService.js — 규칙과 데이터 접근을 조립
import { dueDate } from '../domain/loanPolicy.js';
import { findBook, saveLoan } from '../infra/bookRepository.js';
export async function borrow(memberId, bookId, now = new Date()) {
const book = await findBook(bookId);
if (book.stock <= 0) throw new Error(`${book.title}: 재고 없음`);
const loan = await saveLoan(book, memberId);
return { ...loan, dueAt: dueDate(now) };
}
의존이 service → domain, service → infra 한 방향으로만 흐른다. domain 은 아무것도 가져오지 않으므로 테스트할 때 아무것도 준비할 필요가 없다. 이 방향성이 다음에 볼 순환 참조를 원천적으로 막는다.
실무에서 자주 틀리는 것
1. 순환 참조 — CJS 는 조용히 undefined, ESM 은 요란하게 실패
두 파일이 서로를 가져오면 어느 한쪽은 상대가 아직 다 실행되기 전의 모습을 본다. 결과가 시스템마다 다르다.
// CommonJS — a.js
const b = require('./b.js');
console.log('a.js 에서 본 b.loanLimit =', b.loanLimit);
exports.name = 'a';
// CommonJS — b.js
const a = require('./a.js');
console.log('b.js 에서 본 a.name =', a.name);
exports.loanLimit = 5;
$ node a.js
b.js 에서 본 a.name = undefined ← a.js 는 아직 exports.name 을 실행하지 않았다
a.js 에서 본 b.loanLimit = 5
(node:1234) Warning: Accessing non-existent property 'name' of module exports
inside circular dependency
CJS 는 미완성 exports 객체를 그대로 넘긴다. 에러 없이 undefined 가 흘러가므로, 문제가 한참 뒤 엉뚱한 곳에서 드러난다. 경고 문구를 놓치면 원인을 찾는 데 반나절이 간다.
// ESM — ca.js
import { loanLimit } from './cb.js';
export const name = 'a';
console.log('ca.js 에서 본 loanLimit =', loanLimit);
// ESM — cb.js
import { name } from './ca.js';
export const loanLimit = 5;
console.log('cb.js 에서 본 name =', name);
$ node ca.js
ReferenceError: Cannot access 'name' before initialization
ESM 은 링크만 미리 걸어 두고 값은 실행 순서대로 채운다. 아직 안 채워진 const 를 읽으면 9단원의 TDZ 규칙이 그대로 적용되어 즉시 터진다. 조용히 틀린 값이 흐르는 것보다 낫다.
주의할 점: 함수 선언은 호이스팅되므로 순환 참조에서도 동작하는 것처럼 보인다. 그래서 "우리 코드는 순환 참조가 있어도 괜찮다"고 착각하기 쉽다. 나중에 상수 하나를 추가하는 순간 깨진다.
고치는 방법은 import 를 옮기는 것이 아니라 구조를 바꾸는 것이다.
- 양쪽이 공유하는 것을 제3의 파일(
constants.js,types.js)로 빼낸다. 대부분 이걸로 해결된다. - 한쪽이 다른 쪽을 부르는 것이 아니라, 필요한 함수를 인자로 받게 바꾼다(의존성 주입).
- 레이어를 정하고 의존 방향을 한쪽으로 고정한다. 위 예제의
service → domain,service → infra구조가 그것이다.domain은 아무것도 가져오지 않으므로 순환이 생길 수 없다.
madge --circular src 같은 도구로 CI 에서 순환을 검사하면 생기기 전에 막을 수 있다.
2. ESM 에서 __dirname 과 require 를 찾는다
// ESM 파일에서
console.log(__dirname); // ReferenceError: __dirname is not defined in ES module scope
const fs = require('fs'); // ReferenceError: require is not defined
CJS 가 모듈마다 넣어 주던 __dirname·__filename·require·module·exports 다섯 개가 ESM 에는 없다. 대신 import.meta 를 쓴다.
// Node 20.11 / 21.2 이상
console.log(import.meta.dirname); // 디렉터리 경로 문자열
console.log(import.meta.filename);
// 그 이전 버전 호환
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
import.meta.url 은 파일 경로가 아니라 file:///... 형태의 URL 이다. 그대로 fs.readFile 에 넘기면 안 된다. 반드시 fileURLToPath 로 변환한다.
3. CJS 에서 ESM 패키지를 require 한다
const chalk = require('chalk');
// Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported
유명 패키지들이 ESM 전용으로 넘어가면서 대량으로 겪은 문제다. Node 22.12 부터는 require() 로 ESM 을 불러오는 것이 기본 허용되어 상당 부분 완화됐다. 다만 대상 모듈(또는 그 의존 그래프)에 최상위 await 가 있으면 여전히 실패한다.
// 최상위 await 이 있는 ESM 을 require 하면
// Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph
// with top-level await. Use import() instead.
확실한 우회는 동적 import() 다. CJS 안에서도 쓸 수 있고 Promise 를 반환한다.
// CJS 파일 안에서
async function main() {
const { default: chalk } = await import('chalk');
console.log(chalk.green('OK'));
}
main();
반대 방향, 즉 ESM 에서 CJS 를 가져오는 것은 예전부터 잘 된다. 다만 module.exports 전체가 default 로 들어오므로 named import 가 안 되는 경우가 있다.
import pkg from 'some-cjs-package'; // 이건 된다
const { helper } = pkg;
import { helper } from 'some-cjs-package'; // 안 될 수 있다
4. ESM 에서 확장자를 생략한다
import { label } from './books';
// Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../books' imported from ...
CJS 의 require('./books') 는 books.js, books/index.js 를 차례로 시도했다. ESM 은 그런 추측을 하지 않는다. 파일 이름을 그대로 적어야 한다. 폴더의 index.js 도 마찬가지로 './books/index.js' 라고 다 적는다.
TypeScript 를 쓰면 여기서 한 번 더 헷갈린다. .ts 파일을 ESM 으로 컴파일할 때 import 경로는 .js 로 적어야 한다. 컴파일 결과 기준이기 때문이다. 직관에 어긋나지만 규칙이 그렇다.
5. 모듈 최상위에 부작용을 둔다
// config.js
export const config = JSON.parse(readFileSync('./config.json', 'utf8')); // ← import 만 해도 파일을 읽는다
export const db = await connectDatabase(); // ← import 만 해도 접속한다
모듈 본문은 처음 import 될 때 딱 한 번 실행된다. 위 파일을 가져오는 순간 파일 읽기와 DB 접속이 일어난다. 이 함수를 쓰지 않는 테스트에서도 마찬가지다. 테스트가 느려지고, 설정 파일이 없는 환경에서는 import 하는 것만으로 죽는다.
// 이렇게 바꾼다 — 필요할 때 호출한다
let cached = null;
export function getConfig() {
cached ??= JSON.parse(readFileSync('./config.json', 'utf8'));
return cached;
}
9단원의 캐시 클로저와 같은 형태다. 한 번만 실행된다는 성질 자체는 유용하므로(싱글턴을 만들기 쉽다) 무조건 나쁜 건 아니다. 기준은 이렇다. 순수 계산과 상수는 최상위에 둬도 되고, I/O 와 접속은 함수 안에 둔다.
스스로 확인하기
- 다음 파일 구성에서
node main.js의 출력은 무엇인가.package.json에"type": "module"이 있다.// counter.js export let n = 0; export const bump = () => { n += 1; }; console.log('counter 로드'); // main.js import { n, bump } from './counter.js'; import './counter.js'; bump(); console.log(n); - 아래 두 파일은 순환 참조로
ReferenceError가 난다. 제3의 파일을 만들어 해결하라.// member.js import { MAX_LOANS } from './loan.js'; export const GRADE = { STAFF: 'staff', STUDENT: 'student' }; export function canBorrow(count) { return count < MAX_LOANS; } // loan.js import { GRADE } from './member.js'; export const MAX_LOANS = 5; export function isStaffLoan(loan) { return loan.grade === GRADE.STAFF; } - CommonJS 프로젝트에서 ESM 전용 패키지
nanoid를 써야 한다.const { nanoid } = require('nanoid')가 실패할 때 코드를 어떻게 고치는가. 그리고 그 방법이require와 결정적으로 다른 점 하나는 무엇인가.
정답
counter 로드 1counter.js를 두 번import했지만 모듈은 한 번만 평가되고 캐시되므로 로그도 한 번만 찍힌다.n은 라이브 바인딩이라bump()이후의 값 1 이 보인다. 이것이 CommonJS 라면const { n } = require(...)가 값을 복사하므로0이 나온다.- 양쪽이 서로에게서 상수 하나씩만 가져오고 있다. 그 상수들을 별도 파일로 뺀다.
기존 코드와의 호환이 필요하면// constants.js — 아무것도 import 하지 않는다 export const GRADE = { STAFF: 'staff', STUDENT: 'student' }; export const MAX_LOANS = 5; // member.js import { MAX_LOANS } from './constants.js'; export function canBorrow(count) { return count < MAX_LOANS; } // loan.js import { GRADE } from './constants.js'; export function isStaffLoan(loan) { return loan.grade === GRADE.STAFF; }member.js에서export { GRADE } from './constants.js';로 다시 내보내면 된다. 다만 이런 재수출이 쌓이면 의존 관계가 다시 흐려지므로 최소한으로 쓴다. - 동적
import()로 바꾼다.
결정적 차이는 비동기라는 점이다.async function createLoanId() { const { nanoid } = await import('nanoid'); return nanoid(10); }require는 동기적으로 값을 돌려주므로 모듈 최상위에서const { nanoid } = require('nanoid')처럼 바로 쓸 수 있었지만,import()는 Promise 를 반환하므로 이 함수를 쓰는 모든 자리가async가 되어야 한다. 호출 체인 전체가 영향을 받는다. 그래서 실무에서는 대개 패키지 하나 때문에 프로젝트 전체를"type": "module"로 옮기는 쪽을 택한다.