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 모듈 장과 맞물린다. 이 노트는 그래프·사이클만 깊게.