자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이.
ESM live binding, default/named export, circular dependency, dynamic import, CJS와 ESM 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.
오늘의 질문: “
import는 다른 파일의 값을 복사해서 가져오는 걸까, 아니면 계속 연결된 이름을 읽는 걸까?”
이번 글은 JavaScript Quizbook의 Module 장을 닫는 글입니다. 범위를 줄여서 “import/export 문법 정리”로 끝내면 아깝습니다. 모듈은 파일을 나누는 문법이 아니라, 런타임 초기화 순서와 번들링 경계를 만드는 의존성 그래프입니다.
이번 글에서 잃으면 안 되는 포인트는 이겁니다.
// counter.js
export let count = 0;
export function increment() {
count += 1;
}
// view.js
import { count, increment } from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1
view.js가 count의 처음 값 0을 복사해 온다면 마지막 로그도 0이어야 합니다. 하지만 ESM의 named import는 export된 binding을 바라봅니다. 그래서 counter.js 안에서 count가 바뀌면 import 쪽에서 읽는 값도 바뀐 값입니다.
이걸 “live binding”이라고 부릅니다. 프론트엔드 코드 리뷰에서는 이 차이가 꽤 중요합니다. 모듈 밖에서 값을 다시 할당할 수는 없지만, export한 모듈이 그 binding을 바꾸면 import한 쪽은 최신 값을 읽을 수 있습니다.
import { count } from "./counter.js";
count = 10; // TypeError: import binding은 import한 쪽에서 재할당할 수 없다
import는 연결되어 있지만, 소유권은 export한 모듈에 있습니다. 이 한 문장으로 module state, circular dependency, 테스트 mock 문제를 꽤 많이 설명할 수 있습니다.
파일을 나누는 이유는 읽기 쉽게 만들기 위해서만이 아닙니다. 모듈은 다음 질문을 코드에 남깁니다.
| 질문 | 코드 리뷰에서 보는 지점 |
|---|---|
| 누가 값을 소유하는가? | export한 모듈이 상태를 바꾸는가, 소비자가 읽기만 하는가 |
| 언제 초기화되는가? | import graph를 따라 평가 순서가 꼬이지 않는가 |
| 어디까지 묶어서 배포되는가? | static import와 dynamic import 경계가 의도와 맞는가 |
| 어떤 API 모양으로 공개하는가? | named/default export가 리팩터링과 tree-shaking에 유리한가 |
| 실행만 하는 import가 있는가? | side effect import가 번들/테스트/초기화 비용을 숨기지 않는가 |
그래서 “파일이 너무 길어서 나눴다”는 설명만으로는 부족합니다. 프론트엔드 프로젝트에서는 모듈 경계가 라우팅, 번들 크기, 테스트 격리, circular dependency, 디자인 시스템 API까지 건드립니다.
다시 작은 예제로 보겠습니다.
// settings.js
export let locale = "ko";
export function setLocale(nextLocale) {
locale = nextLocale;
}
// header.js
import { locale } from "./settings.js";
export function renderHeader() {
return locale === "ko" ? "홈" : "Home";
}
header.js는 locale을 가져올 때 문자열 값을 복사해 두는 게 아닙니다. settings.js의 export binding을 읽습니다. 그래서 다른 곳에서 setLocale("en")을 호출한 뒤 renderHeader()가 다시 실행되면 영어 문구를 볼 수 있습니다.
이 모델은 편하지만 위험한 방향도 있습니다. 모듈 스코프에 mutable state를 두면 전역 상태처럼 행동합니다.
// cache.js
export const cache = new Map();
// pageA.js
import { cache } from "./cache.js";
cache.set("user", user);
// pageB.js
import { cache } from "./cache.js";
console.log(cache.get("user"));
const cache라고 해서 Map 내부가 불변이 되는 건 아닙니다. Day 4의 freeze, Day 5의 참조/Map 이야기와 이어집니다. 모듈이 객체 참조를 export하면 여러 소비자가 같은 객체를 공유합니다. 코드 리뷰에서는 질문이 바뀝니다.
문법만 보면 둘 다 “밖으로 공개한다”입니다.
// named export
export function formatPrice(value) {
return new Intl.NumberFormat("ko-KR").format(value);
}
// default export
export default function formatPrice(value) {
return new Intl.NumberFormat("ko-KR").format(value);
}
하지만 소비하는 쪽의 모양은 다릅니다.
import { formatPrice } from "./format.js";
import moneyFormatter from "./format.js";
named export는 import 이름이 공개 API와 맞물립니다. 리팩터링할 때 추적하기 쉽고, 여러 export를 묶어도 무엇을 쓰는지 비교적 선명합니다. default export는 소비자가 이름을 마음대로 붙일 수 있습니다. 작은 컴포넌트 하나를 default로 내보내는 식은 흔하지만, 유틸 모듈 전체가 default object가 되면 사용 지점이 흐려집니다.
// utils.js
export default {
formatPrice,
parseDate,
clamp,
};
// checkout.js
import utils from "./utils.js";
utils.formatPrice(total);
이런 모양은 tree-shaking에도 불리할 수 있고, “이 파일이 실제로 무엇을 쓰는가”를 읽기 어렵게 만듭니다. 물론 default export가 항상 나쁘다는 뜻은 아닙니다. 라우트 컴포넌트, 페이지 컴포넌트, 하나의 주 역할을 가진 모듈에서는 자연스럽습니다. 다만 shared util, design-system primitive, domain model처럼 공개 API가 중요한 곳에서는 named export가 리뷰와 검색에 더 유리한 경우가 많습니다.
순환 의존성은 이렇게 생깁니다.
// a.js
import { bValue } from "./b.js";
export const aValue = "A";
export const fromB = bValue;
// b.js
import { aValue } from "./a.js";
export const bValue = `B uses ${aValue}`;
겉으로 보면 a.js와 b.js가 서로를 import합니다. 문제는 “서로 연결됐다” 자체가 아니라, 각 모듈이 평가되는 중에 아직 초기화되지 않은 binding을 읽는 순간입니다. ESM은 import graph를 먼저 만들고, 모듈을 연결한 뒤, 순서에 따라 평가합니다. 이때 어떤 값은 아직 초기화 전일 수 있습니다.
실제 프로젝트에서는 더 교묘하게 보입니다.
// index.ts
export { Button } from "./Button";
export { buttonVariants } from "./buttonVariants";
// Button.tsx
import { buttonVariants } from "./index";
// buttonVariants.ts
import type { ButtonSize } from "./Button";
barrel file인 index.ts가 편해서 여기저기 import하다 보면, 컴포넌트와 유틸이 다시 barrel을 타고 들어오는 순환이 생깁니다. TypeScript에서는 import type으로 런타임 의존성을 줄일 수 있지만, 값 import와 타입 import가 섞이면 여전히 module graph가 지저분해질 수 있습니다.
리뷰에서는 이렇게 봅니다.
순환 의존성을 무조건 금지하면 현실성이 떨어집니다. 하지만 design-system, route registry, plugin registry처럼 경계가 커지는 곳에서는 순환이 생기기 쉽고, 디버깅 비용이 큽니다. 이때는 “어느 방향이 더 아래 계층인가”를 정해야 합니다. 컴포넌트가 registry를 import하는지, registry가 컴포넌트를 import하는지, 타입만 필요한지, lazy boundary로 끊을 수 있는지를 봐야 합니다.
static import는 모듈 그래프에 처음부터 들어갑니다.
import { MarkdownEditor } from "./MarkdownEditor.js";
dynamic import는 실행 중에 Promise로 모듈을 가져옵니다.
async function openEditor() {
const { MarkdownEditor } = await import("./MarkdownEditor.js");
return new MarkdownEditor();
}
프론트엔드에서는 route-level code splitting, admin-only tool, 무거운 chart/editor, feature flag 뒤의 기능에 자주 씁니다. 하지만 dynamic import를 “성능 좋아지는 마법”으로만 보면 안 됩니다. 이 코드는 새로운 상태를 만듭니다.
React라면 lazy와 Suspense로 감쌀 수 있습니다.
const MarkdownEditor = lazy(() => import("./MarkdownEditor"));
function EditorPanel() {
return (
<Suspense fallback={<EditorSkeleton />}>
<MarkdownEditor />
</Suspense>
);
}
여기서도 리뷰 질문은 단순합니다.
Day 8에서 봤던 async timing과도 이어집니다. dynamic import는 Promise입니다. 즉 실패, 지연, 순서 역전, 취소를 생각해야 합니다.
CommonJS는 보통 이렇게 씁니다.
// CommonJS
const fs = require("node:fs");
module.exports = { readConfig };
ESM은 이렇게 씁니다.
// ESM
import fs from "node:fs";
export { readConfig };
둘의 차이를 깊게 외울 필요는 없지만, 프론트엔드 작업에서 다음 순간은 자주 만납니다.
require가 안 된다.default interop 때문에 import foo from "pkg"가 환경마다 어긋난다.type: "module", exports, module, main 필드 때문에 번들러 해석이 달라진다.__dirname, __filename 같은 CJS 습관이 ESM에서 바로 안 먹힌다.이건 “옛날 문법 vs 새 문법” 문제가 아닙니다. 모듈 시스템이 다르면 로더, mock, bundler, tree-shaking, package boundary가 달라집니다. 그래서 라이브러리 도입 PR에서 가끔은 API보다 package shape를 먼저 봐야 합니다.
Tree-shaking은 사용하지 않는 export를 번들에서 제거하려는 최적화입니다. 그런데 모든 코드가 쉽게 제거되는 건 아닙니다.
// math.js
export function add(a, b) {
return a + b;
}
export function heavyDebugTool() {
console.log("debug only");
}
소비자가 add만 import하면 heavyDebugTool은 제거될 수 있습니다. 하지만 다음처럼 모듈이 import되는 순간 일을 해버리면 판단이 어려워집니다.
// analytics.js
console.log("install analytics listener");
window.addEventListener("click", trackClick);
export function trackClick(event) {
// ...
}
이건 side effect import와 연결됩니다.
import "./analytics.js";
이 import는 값을 가져오려는 코드가 아니라 실행을 의도한 코드입니다. 필요한 경우도 있습니다. CSS import, polyfill, 전역 등록, analytics bootstrapping이 그 예입니다. 하지만 side effect import가 많아지면 “이 기능은 어디서 켜지는가?”가 흐려지고 테스트도 어려워집니다.
리뷰에서는 side effect를 숨기지 않는 모듈 이름과 위치가 중요합니다.
import "./bootstrap/installAnalyticsListener.js";
이 정도만 되어도 “실행을 위한 import”라는 의도가 더 잘 보입니다.
모듈 PR을 볼 때는 파일 수보다 graph를 봅니다.
모듈은 프로젝트가 커질수록 “폴더 정리”가 아니라 설계 표면이 됩니다. 작은 helper를 어디에 두느냐보다, 어떤 방향으로 의존성이 흐르고 어떤 경계에서 비동기 로딩과 실패를 받아들이는지가 더 중요합니다.
const export가 내부 mutation까지 막지는 않습니다.allowJs, 모듈별 전환 전략을 다시 다룹니다.Post Q&A
자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이 아니라 의존성 그래프다 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
ESM live binding, default/named export, circular dependency, dynamic import, CJS와 ESM 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.
DOM 이벤트의 capture/bubble, delegation, default action, React synthetic event 경계를 프론트엔드 코드 리뷰 관점에서 정리합니다.
프로토타입 조회, class 문법, 인스턴스 필드와 prototype 메서드 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.
고차 함수, 순수 함수, 부수 효과, async 함수의 Promise 반환을 코드 리뷰에서 어떻게 읽을지 정리합니다.
this 바인딩을 정의 위치가 아니라 호출 형태, 메서드 분리, arrow function, bind 기준으로 읽는 방법을 코드 리뷰 관점에서 정리합니다.
콜 스택, 태스크 큐, 마이크로태스크 큐를 기준으로 Promise와 setTimeout 실행 순서를 코드 리뷰 관점에서 정리합니다.
클로저가 값 복사가 아니라 렉시컬 환경 참조라는 점을 stale closure, 루프 콜백, React 이벤트 리뷰 관점에서 정리합니다.
스코프 체인, 렉시컬 환경, var/let/const 호이스팅 차이를 콜백·상태 버그 리뷰 관점에서 다시 정리합니다.
객체 참조와 얕은 복사, Object와 Map/Set의 차이, WeakMap/WeakSet이 필요한 메모리 상황을 프론트엔드 코드 리뷰 관점에서 정리합니다.
객체 프로퍼티를 key/value가 아니라 descriptor로 읽는 법, getter/setter와 defineProperty, preventExtensions/seal/freeze의 경계를 코드 리뷰 관점에서 정리합니다.
객체가 원시값으로 바뀌는 ToPrimitive 흐름, + 연산자의 문자열 연결과 숫자 덧셈 분기, Symbol.toPrimitive가 코드 리뷰에서 왜 중요한지 정리합니다.
==, ===, Object.is, SameValueZero가 각각 어떤 비교 알고리즘을 쓰는지 정리하고, includes와 indexOf, Map/Set 키 비교에서 생기는 프론트엔드 리뷰 포인트를 잡습니다.
자바스크립트의 Number가 왜 정수처럼 보여도 부동소수점 모델 위에서 움직이는지, NaN과 -0, safe integer, BigInt를 프론트엔드 코드 리뷰 관점에서 다시 정리합니다.