자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이.
ESM live binding, default/named export, circular dependency, dynamic import, CJS와 ESM 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.
오늘의 질문: “import는 다른 파일의 값을 복사해서 가져오는 걸까, 아니면 계속 연결된 이름을 읽는 걸까?”이번 글은 JavaScript Quizbook의 Module 장을 닫는 글입니다. 범위를 줄여서 “import/export 문법 정리”로 끝내면 아깝습니다. 모듈은 파일을 나누는 문법이 아니라, 런타임 초기화 순서와 번들링 경계를 만드는 의존성 그래프입니다.
이번 글에서 잃으면 안 되는 포인트는 이겁니다.
ESM import는 단순 값 복사가 아니라 live binding을 읽는다. named export와 default export는 소비자가 읽는 모양과 리팩터링 비용이 다르다. circular dependency는 “서로 import했다”가 아니라 초기화 전에 읽는 값이 생기는가를 봐야 한다. dynamic import는 lazy loading 도구이지만, chunk/error/loading 경계까지 같이 만든다. CommonJS와 ESM 차이는 역사 지식이 아니라, 도구/테스트/번들러 디버깅에서 드러난다. tree-shaking은 “모듈을 썼다”만으로 자동 보장되지 않고, export shape와 side effect에 영향을 받는다.
// 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); // 1view.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하면 여러 소비자가 같은 객체를 공유합니다. 코드 리뷰에서는 질문이 바뀝니다.
이 모듈 상태는 정말 공유되어야 하는가? 테스트 사이에 상태가 남지 않는가? React state나 query cache처럼 수명과 invalidation이 관리되는가? 단순 helper인 척하지만 내부 mutable singleton을 숨기고 있지 않은가?
문법만 보면 둘 다 “밖으로 공개한다”입니다.
// 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가 지저분해질 수 있습니다.
리뷰에서는 이렇게 봅니다.
내부 파일끼리 굳이 barrel을 통해 import하고 있지 않은가? 타입만 필요한데 값 import를 하고 있지 않은가? 순환이 있어도 초기화 전에 읽는 값은 없는가? 모듈 상단에서 즉시 실행되는 코드가 import 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를 “성능 좋아지는 마법”으로만 보면 안 됩니다. 이 코드는 새로운 상태를 만듭니다.
아직 로딩 중 로딩 성공 로딩 실패 네트워크/배포 chunk mismatch 사용자가 취소하거나 route를 떠남
React라면 lazy와 Suspense로 감쌀 수 있습니다.
const MarkdownEditor = lazy(() => import("./MarkdownEditor"));
function EditorPanel() {
return (
<Suspense fallback={<EditorSkeleton />}>
<MarkdownEditor />
</Suspense>
);
}여기서도 리뷰 질문은 단순합니다.
fallback이 실제 사용자 흐름에 맞는가? import 실패가 error boundary로 잡히는가? chunk 이름/경로가 배포 후에도 안정적인가? route를 떠난 뒤 로딩 결과가 UI를 갱신하지 않는가? 자주 쓰는 기능을 과하게 쪼개서 오히려 UX를 나쁘게 만들지 않았는가?
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 };둘의 차이를 깊게 외울 필요는 없지만, 프론트엔드 작업에서 다음 순간은 자주 만납니다.
Vite/Next/Node 설정 파일에서 require가 안 된다.
Jest/Vitest mock이 ESM 모듈에서 다르게 동작한다.
default interop 때문에 import foo from "pkg"가 환경마다 어긋난다.
package의 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를 봅니다.
import가 값 복사처럼 쓰이고 있지 않은가? 모듈 스코프 mutable state가 전역 상태처럼 퍼지지 않는가? named/default export 모양이 공개 API와 리팩터링에 맞는가? 내부 파일이 barrel file을 역으로 import해 순환을 만들지 않는가? 타입만 필요한 곳에서 값 import를 하고 있지 않은가? dynamic import에 loading/error/fallback 경계가 있는가? side effect import가 이름과 위치로 의도를 드러내는가? tree-shaking을 기대하는 코드가 export 모양과 side effect 측면에서 그 기대를 깨지 않는가? CJS/ESM interop 때문에 테스트, 빌드, 배포 환경이 달라지는 지점은 없는가?
모듈은 프로젝트가 커질수록 “폴더 정리”가 아니라 설계 표면이 됩니다. 작은 helper를 어디에 두느냐보다, 어떤 방향으로 의존성이 흐르고 어떤 경계에서 비동기 로딩과 실패를 받아들이는지가 더 중요합니다.
Day 5의 참조/Map 이야기는 모듈에서 객체를 export할 때 다시 등장합니다. const export가 내부 mutation까지 막지는 않습니다.
Day 8의 Promise timing은 dynamic import의 loading/error 상태와 이어집니다.
Effective TypeScript Day 12의 public API 타입, module augmentation, source map은 이번 글의 모듈 경계와 직접 연결됩니다.
Effective TypeScript Day 14의 JS→TS migration은 CJS/ESM, allowJs, 모듈별 전환 전략을 다시 다룹니다.
Post Q&A
자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이 아니라 의존성 그래프다 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
DOM 이벤트의 capture/bubble, delegation, default action, React synthetic event 경계를 프론트엔드 코드 리뷰 관점에서 정리합니다.