← 글 목록

notes

Module graph · cycles — import 방향과 평가 순서

ESM 모듈 그래프·live binding·순환 참조. 끊는 법과 지연 읽기.

참고: MDN JavaScript modules · ECMA-262 Module Semantics

import가 ent히 실패한 줄 알았는데, 순환 참조였다.

모듈은 파일이 칸인 동시에 그래프다. 평가 순서·live binding·사이클을 모르면 “왜 undefined지?”가 반복된다.

flowchart LR
  A[a.mjs] -->|import| B[b.mjs]
  B -->|import| C[c.mjs]
  C -.->|cycle| A
  EVAL[평가 순서] --> LINK[연결 live binding]
  LINK --> EXEC[실행]

모듈이 그래프인 이유

각 파일이 노드, import/export가 방향 간선.

진입점에서 의존성을 만나며 모듈 레코드를 만들고, 연결(link)한 뒤 실행(evaluate)한다.

  • 한 모듈은 한 번만 평가
  • export는 live binding — 내보내는 쪽 값이 바뀌면 수입 쪽도 같은 바인딩을 본다
  • default·named export 규칙이 다름
// counter.mjs
export let count = 0;
export function inc() { count += 1; }

// main.mjs
import { count, inc } from './counter.mjs';
inc();
console.log(count); // 1 — live

평가 순서 감각

  • 파싱 → 모듈 맵
  • 의존성 재귀 로드
  • 연결: import ↔ export 슬롯
  • 실행: 본문 평가

실행 중 아직 초기화 전 바인딩을 읽으면 TDZ/ReferenceError 또는 일시적 undefined — 사이클에서 흔하다.

순환 참조 (cycles)

A→B→A. 문법은 허용될 수 있지만 설계 냄새.

// a.mjs
import { b } from './b.mjs';
export const a = 'a';
console.log('a sees b =', b);

// b.mjs
import { a } from './a.mjs';
export const b = 'b';
console.log('b sees a =', a);

진입·순서에 따라 한쪽이 상대 export를 덜 초기화된 상태로 본다.

증상

  • Cannot access before initialization
  • 함수는 되는데 필드가 undefined
  • 테스트 진입점만 바꾸면 실패

완화

  • 사이클 끊기 — 공유를 shared.mjs로
  • 함수·게터로 늦추기 — 실행 시점에 읽기
  • 동적 import() — 느슨한 결합
  • 계층 — domain ← app ← ui, 역방향 금지
// shared.mjs
export const APP = 'demo';

// late read
import * as b from './b.mjs';
export function use() {
  return b.value; // 호출 시점
}

CommonJS와의 차이 감각

require는 실행 중 동기 로드·exports 객체에 가깝다.

ESM은 정적 구조·live binding·호이스팅된 import.

번들러가 섞으면 default export 감싸임 혼란이 추가된다.

트리 셰이킹·정적 import

정적 import는 미사용 export 제거에 유리.

동적 import()는 코드 분할·조건부 로드·사이클 완화(복잡도↑).

async function loadEditor() {
  const mod = await import('./editor.mjs');
  return mod.create();
}

번들러 그래프 vs 런타임

소스 사이클은 HMR·초기화 버그의 원인. 번들 후엔 안 보여도 소스는 고쳐야 한다.

도구: madge, dependency-cruiser, 번들 analyze.

re-export / barrel

export * from './a.mjs';
export * from './b.mjs';

큰 barrel이 하위를 서로 끌어오면 그래프가 엉킨다. public API만 얇게.

사이드이펙트 모듈

import './init-polyfill.mjs';

바인딩 없이 실행만. 순서가 중요하면 진입점에서 먼저 import.

일기 — 사이클을 처음 밟았을 때

설정이 로거를 import하고, 로거가 설정의 logLevel을 import했다. 앱 시작만 터지고 테스트 진입점이 다르면 통과했다. madge로 원을 본 뒤에야 간선 방향이 설계라는 걸 받아들였다. 공유 env.mjs를 빼니 조용해졌다.

추가 — 게터로 지연

import { getB } from './b.mjs';
export function useB() { return getB(); }

헷갈리던 장면 / 함정

  • import는 항상 안전하다는 착각
  • live binding을 복사값으로 오해
  • 거대 상호 import
  • CJS named import interop
  • 사이드이펙트 모듈 순서 의존

표

질문 → 자답

Q. 사이클 = 무조건 버그?

A. 문법 오류는 아닐 수 있다. 초기화 순서 버그의 온상. 끊는 편이 이득.

Q. 함수로 감싸면?

A. 읽기 시점을 본문 실행 후로 미룬다.

웹 보강 메모

MDN circular dependencies와 Node ESM 가이드를 읽었다. CI에 madge --circular를 선택 단계로 넣을 만하다.

실무로 바로

  • 계층: domain ← application ← ui
  • 공유 상수/타입 모듈
  • 사이클 탐지 CI
  • barrel 최소화

실전 리팩터 순서

  • madge로 사이클 목록
  • 공유 노드 추출
  • dependency-cruiser 계층 린트
  • 동적 import는 최후

체크리스트

  • 진입점 기준 그래프
  • live binding 테스트
  • 사이클 CI
  • barrel 남용 여부
  • CJS interop 경계 문서

여기서 잠깐 💁‍♂️

Deep Dive 모듈 장과 맞물린다. 이 노트는 그래프·사이클만 깊게.