LogoSEO Jing
  • All Posts
  • SEO Jing
  • okayJing
  • KD Team
  • CLAB Coreteam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이 아니라 의존성 그래프다

2026년 7월 8일·14분 읽기
오늘의 질문: “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에 영향을 받는다.

먼저 보는 코드

js
// 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한 쪽은 최신 값을 읽을 수 있습니다.

js
import { count } from "./counter.js";

count = 10; // TypeError: import binding은 import한 쪽에서 재할당할 수 없다

import는 연결되어 있지만, 소유권은 export한 모듈에 있습니다. 이 한 문장으로 module state, circular dependency, 테스트 mock 문제를 꽤 많이 설명할 수 있습니다.

ESM live binding과 module graph

틀리기 쉬운 직감: 모듈은 그냥 파일을 잘게 나눈 것이다

파일을 나누는 이유는 읽기 쉽게 만들기 위해서만이 아닙니다. 모듈은 다음 질문을 코드에 남깁니다.

질문코드 리뷰에서 보는 지점
누가 값을 소유하는가?export한 모듈이 상태를 바꾸는가, 소비자가 읽기만 하는가
언제 초기화되는가?import graph를 따라 평가 순서가 꼬이지 않는가
어디까지 묶어서 배포되는가?static import와 dynamic import 경계가 의도와 맞는가
어떤 API 모양으로 공개하는가?named/default export가 리팩터링과 tree-shaking에 유리한가
실행만 하는 import가 있는가?side effect import가 번들/테스트/초기화 비용을 숨기지 않는가

그래서 “파일이 너무 길어서 나눴다”는 설명만으로는 부족합니다. 프론트엔드 프로젝트에서는 모듈 경계가 라우팅, 번들 크기, 테스트 격리, circular dependency, 디자인 시스템 API까지 건드립니다.

live binding은 복사본이 아니다

다시 작은 예제로 보겠습니다.

js
// 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를 두면 전역 상태처럼 행동합니다.

js
// 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와 default export는 API 모양을 바꾼다

문법만 보면 둘 다 “밖으로 공개한다”입니다.

js
// 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);
}

하지만 소비하는 쪽의 모양은 다릅니다.

js
import { formatPrice } from "./format.js";
import moneyFormatter from "./format.js";

named export는 import 이름이 공개 API와 맞물립니다. 리팩터링할 때 추적하기 쉽고, 여러 export를 묶어도 무엇을 쓰는지 비교적 선명합니다. default export는 소비자가 이름을 마음대로 붙일 수 있습니다. 작은 컴포넌트 하나를 default로 내보내는 식은 흔하지만, 유틸 모듈 전체가 default object가 되면 사용 지점이 흐려집니다.

js
// 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가 리뷰와 검색에 더 유리한 경우가 많습니다.

circular dependency는 import 자체보다 초기화 순서가 문제다

순환 의존성은 이렇게 생깁니다.

js
// 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를 먼저 만들고, 모듈을 연결한 뒤, 순서에 따라 평가합니다. 이때 어떤 값은 아직 초기화 전일 수 있습니다.

실제 프로젝트에서는 더 교묘하게 보입니다.

ts
// 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로 끊을 수 있는지를 봐야 합니다.

dynamic import는 성능 최적화이면서 실패 경계다

static import는 모듈 그래프에 처음부터 들어갑니다.

js
import { MarkdownEditor } from "./MarkdownEditor.js";

dynamic import는 실행 중에 Promise로 모듈을 가져옵니다.

js
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로 감쌀 수 있습니다.

tsx
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입니다. 즉 실패, 지연, 순서 역전, 취소를 생각해야 합니다.

CJS와 ESM 차이는 도구 문제로 드러난다

CommonJS는 보통 이렇게 씁니다.

js
// CommonJS
const fs = require("node:fs");
module.exports = { readConfig };

ESM은 이렇게 씁니다.

js
// 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 모양과 side effect에 민감하다

Tree-shaking은 사용하지 않는 export를 번들에서 제거하려는 최적화입니다. 그런데 모든 코드가 쉽게 제거되는 건 아닙니다.

js
// math.js
export function add(a, b) {
  return a + b;
}

export function heavyDebugTool() {
  console.log("debug only");
}

소비자가 add만 import하면 heavyDebugTool은 제거될 수 있습니다. 하지만 다음처럼 모듈이 import되는 순간 일을 해버리면 판단이 어려워집니다.

js
// analytics.js
console.log("install analytics listener");
window.addEventListener("click", trackClick);

export function trackClick(event) {
  // ...
}

이건 side effect import와 연결됩니다.

js
import "./analytics.js";

이 import는 값을 가져오려는 코드가 아니라 실행을 의도한 코드입니다. 필요한 경우도 있습니다. CSS import, polyfill, 전역 등록, analytics bootstrapping이 그 예입니다. 하지만 side effect import가 많아지면 “이 기능은 어디서 켜지는가?”가 흐려지고 테스트도 어려워집니다.

리뷰에서는 side effect를 숨기지 않는 모듈 이름과 위치가 중요합니다.

js
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, 모듈별 전환 전략을 다시 다룹니다.

Day 13 복습 퀴즈

Quiz1 / 4
Q.ESM named import의 live binding에 대한 설명으로 가장 알맞은 것은 무엇일까요?

Post Q&A

오케이징에게 물어보기

자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이 아니라 의존성 그래프다 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!

0/500

포스트 목록

/study/javascript-quizbook
파일 13개, 폴더 0개
자바스크립트 퀴즈북 리마인드 Day 1: 숫자는 왜 가끔 믿을 수 없을까자바스크립트 퀴즈북 리마인드 Day 2: 같은 값인지 묻는 네 가지 방법자바스크립트 퀴즈북 리마인드 Day 3: + 연산자와 ToPrimitive 흐름자바스크립트 퀴즈북 리마인드 Day 4: 프로퍼티 descriptor와 freeze의 경계자바스크립트 퀴즈북 리마인드 Day 5: 참조, 복사, Map/Set의 메모리 감각자바스크립트 퀴즈북 리마인드 Day 6: 스코프 체인과 호이스팅자바스크립트 퀴즈북 리마인드 Day 7: 클로저 버그와 오래된 값자바스크립트 퀴즈북 리마인드 Day 8: 이벤트 루프와 Promise 타이밍자바스크립트 퀴즈북 리마인드 Day 9: this는 호출한 쪽이 정한다자바스크립트 퀴즈북 리마인드 Day 10: 함수는 값이고 경계다자바스크립트 퀴즈북 리마인드 Day 11: class를 봐도 프로토타입을 읽는다자바스크립트 퀴즈북 리마인드 Day 12: 이벤트는 target에서 끝나지 않는다자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이 아니라 의존성 그래프다

같은 섹션의 대표 이미지

13 posts · latest first
ESM live binding과 dynamic import, circular dependency 리뷰 포인트를 보여주는 module graph 다이어그램
Study26. 07. 09.

자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이.

ESM live binding, default/named export, circular dependency, dynamic import, CJS와 ESM 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 09.SEOJing
DOM 이벤트가 capture 단계로 내려가고 bubble 단계로 올라가는 경로 다이어그램
Study26. 07. 08.

자바스크립트 퀴즈북 리마인드 Day 12: 이벤트는.

DOM 이벤트의 capture/bubble, delegation, default action, React synthetic event 경계를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 08.SEOJing
인스턴스에서 프로토타입과 Object.prototype으로 이어지는 프로퍼티 조회 경로 다이어그램
Study26. 07. 07.

자바스크립트 퀴즈북 리마인드 Day 11: class를 봐도.

프로토타입 조회, class 문법, 인스턴스 필드와 prototype 메서드 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 07.SEOJing
입력, 순수 계산, 출력, 부수 효과 경계를 나누어 함수 설계를 보여주는 다이어그램
Study26. 07. 06.

자바스크립트 퀴즈북 리마인드 Day 10: 함수는 값이고 경계다.

고차 함수, 순수 함수, 부수 효과, async 함수의 Promise 반환을 코드 리뷰에서 어떻게 읽을지 정리합니다.

26. 07. 06.SEOJing
메서드 호출, 분리된 함수, 이벤트 콜백, bind와 arrow function의 this 차이를 보여주는 다이어그램
Study26. 07. 05.

자바스크립트 퀴즈북 리마인드 Day 9: this는 호출한 쪽이.

this 바인딩을 정의 위치가 아니라 호출 형태, 메서드 분리, arrow function, bind 기준으로 읽는 방법을 코드 리뷰 관점에서 정리합니다.

26. 07. 05.SEOJing
콜 스택이 비워진 뒤 마이크로태스크 큐가 먼저 실행되고 다음 태스크로 넘어가는 이벤트 루프 다이어그램
Study26. 07. 04.

자바스크립트 퀴즈북 리마인드 Day 8: 이벤트 루프와.

콜 스택, 태스크 큐, 마이크로태스크 큐를 기준으로 Promise와 setTimeout 실행 순서를 코드 리뷰 관점에서 정리합니다.

26. 07. 04.SEOJing
클로저가 생성 위치의 바인딩을 기억해 나중 실행에서 오래된 값을 읽을 수 있음을 보여주는 다이어그램
Study26. 06. 29.

자바스크립트 퀴즈북 리마인드 Day 7: 클로저 버그와 오래된.

클로저가 값 복사가 아니라 렉시컬 환경 참조라는 점을 stale closure, 루프 콜백, React 이벤트 리뷰 관점에서 정리합니다.

26. 06. 29.SEOJing
스코프 체인과 호이스팅 관계를 단순화한 다이어그램
Study26. 06. 28.

자바스크립트 퀴즈북 리마인드 Day 6: 스코프 체인과 호이스팅.

스코프 체인, 클로저, var/let/const 호이스팅 차이를 콜백·상태 버그 리뷰 관점에서 짧게 정리합니다.

26. 06. 28.SEOJing
JavaScript 객체 참조와 Map, Set, WeakMap의 참조 구조를 비교한 다이어그램
Study26. 06. 27.

자바스크립트 퀴즈북 리마인드 Day 5: 참조, 복사,.

객체 참조와 얕은 복사, Object와 Map/Set의 차이, WeakMap/WeakSet이 필요한 메모리 상황을 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 06. 27.SEOJing
JavaScript 프로퍼티 descriptor가 data descriptor와 accessor descriptor로 갈라지는 구조 다이어그램
Study26. 06. 26.

자바스크립트 퀴즈북 리마인드 Day 4: 프로퍼티.

객체 프로퍼티를 key/value가 아니라 descriptor로 읽는 법, getter/setter와 defineProperty, preventExtensions/seal/freeze의 경계를 코드 리뷰 관점에서 정리합니다.

26. 06. 26.SEOJing
객체가 ToPrimitive를 거쳐 + 연산자의 문자열 연결 또는 숫자 덧셈으로 갈라지는 흐름 다이어그램
Study26. 06. 25.

자바스크립트 퀴즈북 리마인드 Day 3: + 연산자와.

객체가 원시값으로 바뀌는 ToPrimitive 흐름, + 연산자의 문자열 연결과 숫자 덧셈 분기, Symbol.toPrimitive가 코드 리뷰에서 왜 중요한지 정리합니다.

26. 06. 25.SEOJing
==, ===, Object.is, SameValueZero의 차이를 정리한 JavaScript equality 다이어그램
Study26. 06. 24.

자바스크립트 퀴즈북 리마인드 Day 2: 같은 값인지 묻는 네.

==, ===, Object.is, SameValueZero가 각각 어떤 비교 알고리즘을 쓰는지 정리하고, includes와 indexOf, Map/Set 키 비교에서 생기는 프론트엔드 리뷰 포인트를 잡습니다.

26. 06. 24.SEOJing
JavaScript Number, safe integer, NaN, -0, BigInt를 한 장으로 정리한 다이어그램
Study26. 06. 23.

자바스크립트 퀴즈북 리마인드 Day 1: 숫자는 왜 가끔 믿을.

자바스크립트의 Number가 왜 정수처럼 보여도 부동소수점 모델 위에서 움직이는지, NaN과 -0, safe integer, BigInt를 프론트엔드 코드 리뷰 관점에서 다시 정리합니다.

26. 06. 23.SEOJing