Ray Book
모듈 시스템

순환 참조

모듈 간 순환 참조가 발생했을 때 CommonJS와 ESM 각각의 동작 방식, 문제점, 그리고 해결 전략을 살펴봅니다

javascriptmodulecircular-dependencycommonjsesm

순환 참조란

순환 참조 (circular dependency) 는 모듈 A가 모듈 B를 import하고, 모듈 B가 다시 모듈 A를 import하는 상황입니다.

moduleA.js --import--> moduleB.js
     ^                      |
     +------import----------+

의도치 않게 자주 발생합니다. 프로젝트가 커지면서 모듈 간 의존성이 복잡해지면, 간접적인 순환 (A → B → C → A) 도 생깁니다.

CommonJS의 순환 참조

CJS에서 순환 참조가 발생하면, 불완전한 export 객체를 받게 됩니다.

// a.js
console.log('a.js 시작');
const b = require('./b');
console.log('a.js에서 b.value:', b.value);

module.exports = { value: 'A' };
console.log('a.js 끝');

// b.js
console.log('b.js 시작');
const a = require('./a');
console.log('b.js에서 a.value:', a.value);

module.exports = { value: 'B' };
console.log('b.js 끝');

node a.js를 실행하면:

a.js 시작
b.js 시작
b.js에서 a.value: undefined    ← a.js가 아직 export를 완료하지 않음!
b.js 끝
a.js에서 b.value: B
a.js 끝

왜 이렇게 되는가

  1. a.js 실행 시작 → require('./b') 호출
  2. b.js 실행 시작 → require('./a') 호출
  3. Node.js가 순환을 감지 → a.js의 현재까지의 export (아직 빈 객체) 를 반환
  4. b.js가 받은 a에는 value가 없음 → undefined
  5. b.js 실행 완료 → module.exports = { value: 'B' } 설정
  6. a.js로 돌아와 b.value를 읽음 → 'B' (정상)

핵심: CJS는 순환을 만나면 실행이 완료되지 않은 모듈의 불완전한 export를 반환합니다.

ESM의 순환 참조

ESM은 라이브 바인딩 덕분에 다르게 동작합니다.

// a.mjs
console.log('a.mjs 시작');
import { valueB } from './b.mjs';

export const valueA = 'A';
console.log('a.mjs에서 valueB:', valueB);

// b.mjs
console.log('b.mjs 시작');
import { valueA } from './a.mjs';

export const valueB = 'B';
console.log('b.mjs에서 valueA:', valueA);

node a.mjs를 실행하면:

b.mjs 시작
ReferenceError: Cannot access 'valueA' before initialization

ESM의 동작 과정

  1. 구문 분석, a.mjs와 b.mjs의 import/export를 모두 파악
  2. 인스턴스화, export 바인딩을 메모리에 생성 (아직 값 없음)
  3. 평가, 의존성 그래프의 가장 깊은 곳 (b.mjs) 부터 실행

b.mjs가 먼저 실행될 때, valueA의 바인딩은 존재하지만 아직 초기화되지 않았습니다. const/let으로 선언된 변수를 초기화 전에 접근하면 TDZ (Temporal Dead Zone) 에 의해 ReferenceError가 발생합니다. CJS의 조용한 undefined와 달리, ESM은 에러를 명시적으로 던져서 문제를 빨리 발견할 수 있습니다.

함수 선언은 호이스팅된다

// a.mjs
import { greetB } from './b.mjs';
export function greetA() { return 'Hello from A'; }
console.log(greetB()); // "Hello from B", 정상!

// b.mjs
import { greetA } from './a.mjs';
export function greetB() { return 'Hello from B'; }
console.log(greetA()); // "Hello from A", 정상!

function 선언은 호이스팅되므로, 평가 순서와 관계없이 사용할 수 있습니다. 반면 const, let, 화살표 함수는 호이스팅되지만 TDZ(Temporal Dead Zone)에 의해 초기화 전에는 접근할 수 없습니다.

CJS vs ESM 순환 참조 비교

항목CommonJSESM
감지 방법캐시에서 불완전한 export 반환초기화되지 않은 바인딩 참조
결과undefined (조용한 실패)ReferenceError (const/let의 TDZ)
함수 선언순서에 따라 undefined 가능호이스팅으로 정상 동작
값 변경 반영불가 (값 복사)가능 (라이브 바인딩)

순환 참조 감지하기

도구 활용

# madge, 의존성 그래프를 시각화하고 순환 참조를 감지
npx madge --circular src/

# 결과 예시
# Circular dependencies found!
# src/a.js → src/b.js → src/a.js
# eslint-plugin-import, lint 규칙으로 순환 참조 방지
# .eslintrc
# { "rules": { "import/no-cycle": "error" } }

수동 확인

모듈에서 import한 값이 undefined인데 분명 export하고 있다면, 순환 참조를 의심해보세요.

순환 참조 해결 전략

1. 공통 모듈 추출

순환의 원인이 되는 공유 코드를 별도 모듈로 분리합니다.

// Before: A ↔ B (순환)

// After: A → shared, B → shared (순환 해소)
// shared.js
export const config = { /* ... */ };

// a.js
import { config } from './shared';

// b.js
import { config } from './shared';

2. 의존성 역전

콜백이나 인터페이스를 사용해 직접 참조를 제거합니다.

// Before
// auth.js
import { log } from './logger';  // logger가 auth를 import하면 순환

// After
// auth.js, 의존성 주입
export function createAuth(logger) {
  return {
    login(user) {
      logger.log(`${user} 로그인`);
    }
  };
}

3. 지연 로딩

import를 함수 안으로 이동하여 실행 시점을 늦춥니다.

// a.mjs
export function getB() {
  // 필요한 시점에 동적 import
  return import('./b.mjs').then(m => m.valueB);
}

4. 모듈 구조 재설계

순환 참조는 종종 모듈 구조 설계의 문제를 알려주는 신호입니다. 모듈의 책임이 명확하지 않거나, 너무 많은 일을 하고 있을 수 있습니다.

// 좋지 않은 구조, 서로 참조
userService ↔ orderService

// 개선된 구조, 단방향 의존
userService → shared/types
orderService → shared/types
orderService → userService (단방향)

시리즈 정리

모듈 시스템 시리즈를 통해 다음을 살펴봤습니다.

  1. 모듈이 필요한 이유, 전역 스코프 오염과 IIFE의 한계
  2. CommonJS와 ESM, 두 모듈 시스템의 동작 방식과 핵심 차이
  3. 번들링과 Tree-shaking, ESM의 정적 구조가 가능하게 하는 최적화
  4. 순환 참조, 순환 의존성의 동작과 해결 전략

JavaScript 모듈 시스템의 진화는 "전역 변수의 혼란을 어떻게 정리할 것인가"라는 질문에서 시작해, 스코프 격리, 명시적 의존성, 정적 분석이라는 답에 도달했습니다. ESM이 표준으로 자리잡으면서 tree-shaking 같은 최적화가 가능해졌고, 이는 현대 프론트엔드 빌드 시스템의 핵심이 되었습니다.