Devin.KR
로그인

자바스크립트 모듈 - ESM 과 CommonJS 차이, 순환 참조 (JS 중급 15단원)

개발자 조회 1

이 단원에서 배우는 것

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 와 접속은 함수 안에 둔다.

스스로 확인하기

  1. 다음 파일 구성에서 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);
  2. 아래 두 파일은 순환 참조로 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; }
  3. CommonJS 프로젝트에서 ESM 전용 패키지 nanoid 를 써야 한다. const { nanoid } = require('nanoid') 가 실패할 때 코드를 어떻게 고치는가. 그리고 그 방법이 require 와 결정적으로 다른 점 하나는 무엇인가.

정답

  1. counter 로드
    1
    counter.js 를 두 번 import 했지만 모듈은 한 번만 평가되고 캐시되므로 로그도 한 번만 찍힌다. n 은 라이브 바인딩이라 bump() 이후의 값 1 이 보인다. 이것이 CommonJS 라면 const { n } = require(...) 가 값을 복사하므로 0 이 나온다.
  2. 양쪽이 서로에게서 상수 하나씩만 가져오고 있다. 그 상수들을 별도 파일로 뺀다.
    // 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'; 로 다시 내보내면 된다. 다만 이런 재수출이 쌓이면 의존 관계가 다시 흐려지므로 최소한으로 쓴다.
  3. 동적 import() 로 바꾼다.
    async function createLoanId() {
      const { nanoid } = await import('nanoid');
      return nanoid(10);
    }
    결정적 차이는 비동기라는 점이다. require 는 동기적으로 값을 돌려주므로 모듈 최상위에서 const { nanoid } = require('nanoid') 처럼 바로 쓸 수 있었지만, import() 는 Promise 를 반환하므로 이 함수를 쓰는 모든 자리가 async 가 되어야 한다. 호출 체인 전체가 영향을 받는다. 그래서 실무에서는 대개 패키지 하나 때문에 프로젝트 전체를 "type": "module" 로 옮기는 쪽을 택한다.