개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← 글 목록

모듈 — 코드를 나누고 연결하는 방법

2025년 8월 30일
JavaScript모듈ESMCommonJSimportexport

스크립트 파일 두 개를 순서대로 불러왔을 뿐인데, 나중에 실행된 파일이 앞서 선언한 값을 조용히 덮어씁니다. 두 파일이 서로 다른 곳에서 작성됐어도, 실행되는 순간에는 하나의 전역 공간을 함께 쓰고 있었기 때문입니다.

모듈이 없던 시절의 문제

초기 JavaScript는 모듈 시스템이 없었습니다. 모든 스크립트가 전역 스코프를 공유했기 때문에 전역 오염과 의존성 순서 문제가 반복해서 발생했습니다.

<!-- index.html -->
<script src="utils.js"></script>   <!-- var user = "Alice" -->
<script src="auth.js"></script>    <!-- var user = "Bob" → utils.js의 user 덮어씀 -->

전역 오염: 서로 다른 파일에서 같은 이름의 변수를 선언하면 나중에 로드된 파일이 앞의 값을 덮어쓰는 현상.

의존성 순서 문제: <script> 태그 순서가 곧 의존성 순서였던 문제. 순서가 틀리면 런타임 오류가 났고, 파일이 늘어날수록 순서를 맞추는 일 자체가 부담이 됐습니다.

모듈의 역할

모듈은 세 가지 문제를 동시에 해결합니다.

  • 캡슐화: 파일 내부의 변수와 함수는 명시적으로 공개하지 않는 한 외부에서 접근할 수 없습니다.
  • 의존성 명확화: 어떤 파일이 무엇에 의존하는지 코드 상단에 선언합니다.
  • 재사용: 한 번 작성한 모듈을 여러 곳에서 import해 사용합니다.

CommonJS (CJS)

Node.js가 도입한 전통적인 모듈 방식입니다. require로 불러오고 module.exports로 내보냅니다.

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

function multiply(a, b) {
  return a * b;
}

module.exports = { add, multiply };
// app.js
const { add, multiply } = require('./math');

console.log(add(2, 3));       // 5
console.log(multiply(4, 5));  // 20

require는 동기(synchronous)로 실행됩니다. 파일을 불러올 때까지 다음 줄로 넘어가지 않습니다. 브라우저 환경에서는 이 동기 방식이 성능 문제를 일으키기 때문에 CJS는 주로 Node.js 서버 환경에서 사용됩니다.

ES Modules (ESM)

ES2015(ES6)에서 표준으로 도입된 모듈 시스템입니다. 브라우저와 Node.js(v12+) 모두 지원하며, import와 export 키워드를 사용합니다.

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

export function multiply(a, b) {
  return a * b;
}
// app.js
import { add, multiply } from './math.js';

console.log(add(2, 3));       // 5
console.log(multiply(4, 5));  // 20

ESM은 비동기(asynchronous)로 파싱됩니다. 브라우저는 import 구문을 만나면 파일을 병렬로 내려받을 수 있어 성능에 유리합니다. Node.js에서 ESM을 사용하려면 파일 확장자를 .mjs로 쓰거나 package.json에 "type": "module"을 추가합니다.

모듈 스코프

CJS와 ESM 모두 파일마다 독립된 스코프를 갖습니다. 다만 방식이 다릅니다. CJS는 Node.js가 파일 전체를 함수로 감싸 실행하는 방식으로 스코프를 흉내 내고, ESM은 언어 차원에서 파일마다 스코프를 갖도록 정의합니다. 두 방식 모두 export(또는 module.exports)하지 않은 변수는 파일 외부에서 접근할 수 없습니다.

┌─────────────────────┐   ┌─────────────────────┐
│      math.js        │   │       app.js         │
│                     │   │                      │
│  const PI = 3.14    │   │  import { add }      │
│  export function    │◄──│  from './math.js'    │
│    add(a, b) { }    │   │                      │
│  // PI: not exported│   │  console.log(PI)     │
│                     │   │  // ReferenceError   │
└─────────────────────┘   └─────────────────────┘

ESM의 추가 기능

정적 구조(코드를 실행하지 않고도 import/export 관계를 미리 분석할 수 있는 성질) 덕분에, ESM에는 CJS에 없는 기능이 몇 가지 더 있습니다.

Named Export vs Default Export

// named.js — 여러 개를 내보낼 때
export const PI = 3.14159;
export function square(x) { return x * x; }

// default.js — 하나를 대표로 내보낼 때
export default function greet(name) {
  return `Hello, ${name}!`;
}
// 가져올 때
import { PI, square } from './named.js';       // Named: 중괄호 필수, 이름 일치 필요
import greet from './default.js';              // Default: 중괄호 없음, 이름 자유
import myGreet from './default.js';            // Default는 이름을 바꿔도 됩니다

하나의 파일에 default export는 최대 한 개, named export는 여러 개 사용할 수 있습니다.

Dynamic Import

import 함수를 사용하면 런타임에 조건에 따라 모듈을 로드할 수 있습니다. 정적 import와 달리 조건문 안에서도 사용할 수 있으며 Promise를 반환합니다.

// 버튼을 클릭했을 때만 차트 라이브러리를 로드합니다
button.addEventListener('click', async () => {
  const { renderChart } = await import('./chart.js');
  renderChart(data);
});

초기 로딩 시 불필요한 코드를 내려받지 않아도 되므로, Webpack이나 Vite 같은 번들러와 함께 사용하면 청크 분리(Code Splitting)로 초기 로딩 속도를 줄일 수 있습니다.

청크 분리(Code Splitting): 하나의 큰 번들 파일을 여러 개의 작은 파일(청크)로 나눠, 필요한 시점에만 내려받도록 하는 최적화 기법.

Tree Shaking

번들러는 ESM의 정적 구조(import/export가 런타임이 아닌 파싱 시점에 결정됨)를 분석해 실제로 사용되지 않는 export를 최종 번들에서 제거합니다.

번들러(Bundler): 여러 모듈 파일을 하나(또는 소수)의 파일로 묶어주는 도구. Webpack, Vite, Rollup 등이 있습니다.

// utils.js
export function used() { return 'used'; }
export function unused() { return 'unused'; }  // 아무도 import 안 함

// app.js
import { used } from './utils.js';
// unused()는 번들 결과물에 포함되지 않습니다

순환 의존성

A가 B를 import하고 B가 A를 import하는 상황을 순환 의존성이라고 합니다. CJS와 ESM은 이 상황을 다르게 처리합니다.

CJS의 순환 의존성

// main.js
require('./a.js');

// a.js
console.log('a 시작');
const b = require('./b.js');
console.log('a에서 본 b:', b);   // { value: 'b의 값' } — 정상
module.exports = { value: 'a의 값' };

// b.js
console.log('b 시작');
const a = require('./a.js');
console.log('b에서 본 a:', a);   // {} — 비어있음
module.exports = { value: 'b의 값' };

Node는 모듈을 require로 불러올 때 module.exports를 빈 객체로 초기화해 캐시에 먼저 등록하고, 그 다음 파일을 실행하며 이 객체를 채웁니다. a.js가 실행되던 중 b.js를 require로 불러오면, a.js의 module.exports는 아직 빈 객체인 상태로 실행이 중단됩니다. 이때 b.js가 다시 a.js를 require로 불러오면 Node는 캐시에 등록된 모듈을 재실행하지 않고, 현재 상태의 module.exports(아직 빈 객체)를 그대로 반환합니다. 그 결과 b.js는 빈 객체인 a를 받습니다. 반면 a.js는 b.js가 실행을 완전히 마친 뒤 반환된 module.exports를 받기 때문에 b는 정상적인 값으로 보입니다.

ESM의 순환 의존성

// a.js
import { b } from './b.js';
export const a = `a uses ${b}`;

// b.js
import { a } from './a.js';
export const b = `b uses ${a}`;  // a가 아직 초기화되지 않아 undefined

ESM은 값이 아니라 참조(live binding)를 연결합니다. import하는 시점에는 아직 초기화되지 않은 변수를 참조하고 있을 뿐이라, 접근하는 순간 undefined가 나옵니다.

해결책

두 경우 모두 원인은 같습니다. a와 b가 서로를 필요로 하기 전에, 공통으로 필요한 값을 별도 파일로 분리하면 순환 자체가 사라집니다.

// shared.js
export const shared = 'base value';

// a.js
import { shared } from './shared.js';
export const a = `a uses ${shared}`;

// b.js
import { shared } from './shared.js';
export const b = `b uses ${shared}`;

순환 의존성은 대부분 설계 문제의 신호입니다.

요약

개념핵심
모듈코드를 파일 단위로 분리해 캡슐화와 재사용을 가능하게 하는 단위
CommonJSrequire/module.exports, 동기 실행, Node.js 서버 환경 중심
ES Modulesimport/export, 비동기 파싱, 브라우저·Node.js 모두 지원
Named / Default Export여러 개를 내보내는 방식 / 하나를 대표로 내보내는 방식
Dynamic Importimport로 런타임에 조건부 로드, 코드 스플리팅에 활용
순환 의존성A와 B가 서로 import하는 상황. CJS는 나중에 시작된 쪽이 빈 객체를 받고, ESM은 undefined가 발생
Tree Shaking번들러가 사용되지 않는 export를 최종 번들에서 제거하는 최적화
항목CommonJSESM
문법require / module.exportsimport / export
실행 방식동기비동기
주요 환경Node.js브라우저, Node.js(v12+)
Tree Shaking어려움가능
Dynamic Import기본 require로 대체import 함수 지원

모듈은 코드를 파일 단위로 캡슐화해 전역 오염을 막고, 의존성을 선언적으로 표현해 유지보수를 쉽게 만듭니다. 오늘날 프론트엔드 생태계는 ESM을 표준으로 수렴하고 있으며, Tree Shaking과 Dynamic Import를 활용하면 성능까지 함께 챙길 수 있습니다.

← 이전 글프로토타입 — JavaScript 상속의 실제 구조
다음 글 →이벤트 루프 — 싱글 스레드가 동시성을 흉내내는 방법