6편. 모듈 시스템 이해하기: ESM, import/export
📚 목차
1. ESM 기본 개념과 import/export 문법
2. 모듈 로딩과 해석(Resolution), dynamic import
3. Top-Level await (ES2022)
✔ 마무리
JavaScript는 오랫동안 모듈 시스템이 부족한 언어였지만, 2015년(ES6)부터 ECMAScript에서 공식 모듈 표준인 ESM(ECMAScript Module)이 도입되었습니다.
2025년 현재, ESM은 JavaScript의 유일한 공식 모듈 표준으로 자리 잡았으며, 브라우저와 Node.js 환경 모두에서 기본으로 채택되고 있습니다.

📂 [GitHub 실습 코드 보러가기] (https://github.com/cericube/nodejs-tutorials) /JavaScript
1. ESM 기본 개념과 import/export 문법
✔️ ESM이란?
ES Modules(ESM)은 JavaScript의 공식 표준 모듈 시스템으로, ES6(ES2015)에서 도입되었습니다.
이전의 CommonJS(Node.js)나 AMD와 달리, 언어 자체에 내장된 모듈 시스템입니다.
ESM의 주요 특징은 다음과 같습니다
▸ 정적 구조: import/export 문은 파일의 최상위 레벨에서만 사용 가능하며, 컴파일 타임에 모듈 구조를 파악할 수 있습니다
▸ 비동기 로딩: 모듈은 비동기적으로 로드되어 성능이 향상됩니다
▸ 싱글톤 패턴: 모듈은 한 번만 실행되고, 여러 곳에서 import해도 같은 인스턴스를 공유합니다
▸ 자동 strict mode: 모듈 내부 코드는 자동으로 strict mode로 실행됩니다
ESM은 모듈에서 값을 내보내는 방식에 따라 크게 Named Export와 Default Export 두 가지 문법을 제공합니다.
🔷 기본 문법: export / import
export와 import는 모듈 간의 공용 인터페이스를 정의하는 핵심 문법입니다.
export는 모듈의 내부 자원(변수, 함수, 클래스 등)을 외부로 공개하고, import는 공개된 자원을 가져와 현재 모듈에서 사용할 수 있게 합니다.
| 문법 | 역할 |
| export (내보내기) | 현재 파일(모듈)에 정의된 특정 값을 다른 모듈에서 접근할 수 있도록 공개합니다. |
| import (가져오기) | 다른 모듈에서 export된 값을 현재 파일로 가져와 사용할 수 있게 합니다. |
✔️ Named Export / Import 사용하기
Named export는 모듈에서 여러 값을 내보낼 때 사용합니다. 각 값은 고유한 이름을 가지며, import할 때 정확한 이름을 지정해야 합니다.
// math.js - Named exports
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
// 또는 한 번에 export
const E = 2.71828;
function subtract(a, b) {
return a - b;
}
export { E, subtract };
// main.js - Named imports
import { PI, add, multiply } from './math.js';
console.log(PI); // 3.14159
console.log(add(5, 3)); // 8
console.log(multiply(4, 2)); // 8
// 별칭(alias) 사용
import { subtract as minus } from './math.js';
console.log(minus(10, 3)); // 7
// 모든 export를 객체로 가져오기
import * as MathUtils from './math.js';
console.log(MathUtils.PI); // 3.14159
console.log(MathUtils.add(1, 2)); // 3
✔️ Default Export / Import 사용하기
모듈당 단 1개만 지정할 수 있으며, 이 값은 그 모듈의 대표값으로 간주됩니다. 가져올 때 이름을 자유롭게 지정할 수 있습니다
// calculator.js - Default export
export default class Calculator {
constructor() {
this.result = 0;
}
add(num) {
this.result += num;
return this;
}
multiply(num) {
this.result *= num;
return this;
}
getResult() {
return this.result;
}
}
// 함수를 default export
// export default function calculate(x, y) {
// return x + y;
// }
// app.js - Default import
import Calculator from './calculator.js';
const calc = new Calculator();
const result = calc.add(10).multiply(2).getResult();
console.log(result); // 20
// 원하는 이름으로 import 가능
import MyCalc from './calculator.js';
const calc2 = new MyCalc();
✔️ Named와 Default Export 혼합 사용
하나의 모듈에서 대표값은 Default Export로, 부가적인 값들은 Named Export로 제공할 수 있습니다.
// user.js - 혼합 사용
export default class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
}
export const USER_ROLES = {
ADMIN: 'admin',
USER: 'user',
GUEST: 'guest'
};
export function validateEmail(email) {
return email.includes('@');
}
// main.js - 혼합 import
import User, { USER_ROLES, validateEmail } from './user.js';
const admin = new User('Alice', 'alice@example.com');
console.log(admin.name); // Alice
console.log(USER_ROLES.ADMIN); // 'admin'
console.log(validateEmail('test@email.com')); // true
✔️ Re-export
다른 모듈에서 가져온 값을 현재 모듈의 인터페이스를 통해 다시 외부에 공개하는 기능입니다. 모듈을 구조화하거나, 여러 모듈을 하나의 패키지로 묶어 제공할 때 유용합니다.
// shapes/circle.js
export class Circle {
constructor(radius) {
this.radius = radius;
}
area() {
return Math.PI * this.radius ** 2;
}
}
// shapes/rectangle.js
export class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
}
// shapes/index.js - 메인 진입점
export { Circle } from './circle.js';
export { Rectangle } from './rectangle.js';
// 또는 모든 것을 re-export
// export * from './circle.js';
// export * from './rectangle.js';
// app.js - 단일 진입점에서 import
import { Circle, Rectangle } from './shapes/index.js';
const circle = new Circle(5);
const rect = new Rectangle(4, 6);
console.log(circle.area()); // 78.53981633974483
console.log(rect.area()); // 24
2. 모듈 로딩과 해석(Resolution), dynamic import
모듈을 가져오는 과정은 단순히 파일을 읽는 것이 아니라, 경로 해석(Resolution) → 모듈 로딩(Loading) → 평가(Evaluation)의 단계를 거칩니다.
🔷 경로 기반 해석(Path-based Resolution)
ECMAScript 표준은 모듈 경로를 해석할 때 명확한 경로 기반을 원칙으로 합니다.
🔸상대 경로
현재 파일 위치를 기준으로 다른 모듈을 참조합니다.
./ (현재 디렉토리) 또는 ../ (상위 디렉토리)를 사용하며, 확장자 포함이 강력히 권장됩니다.
import { sum } from './utils/math.js';
🔸절대 URL
브라우저 환경에서는 완전한 URL을 사용하여 다른 출처(Origin)의 모듈도 가져올 수 있습니다.
// 브라우저: 외부 URL 모듈 로딩
import { test } from "https://example.com/module.js";
Node.js에서는 file:// 스킴을 사용하여 로컬 파일 시스템의 절대 경로를 참조합니다.
🔸ECMAScript 명세는 확장자 생략을 표준으로 허용하지 않습니다.
// 권장
import util from './util.js';
▸ 브라우저는 “파일명 그대로 요청”하기 때문에 확장자가 필수입니다.
▸ Node.js 역시 ESM 모드에서는 확장자 생략을 기본적으로 허용하지 않습니다.
🔷 Node.js 환경의 모듈 해석: package.json의 "exports"
package.json의 "exports" 필드는 Node.js에서 패키지의 진입점과 내부 구조를 외부 사용자에게 노출하는 현대적인 방법입니다.
// mypkg/package.json
{
"name": "mypkg",
"exports": {
".": "./src/index.js", // 'mypkg'의 기본 진입점
"./feature": "./src/feature.js" // 'mypkg/feature'로 접근
}
}
import main from "mypkg"; // './src/index.js' 로 해석됨
import feature from "mypkg/feature"; // './src/feature.js' 로 해석됨
이 기능은 Node.js 환경에서 서드파티 라이브러리의 모듈 구조를 깔끔하게 관리하는 데 매우 유용합니다.
🔷 static import vs dynamic import
import는 두 가지 형태로 사용됩니다. 정적인 선언문과 비동기 함수 형태입니다.
| 구분 | static import | dynamic import |
| 문법 | import { name } from './module.js'; | const mod = await import('./module.js'); |
| 특징 | 모듈 로딩이 정적이며, 컴파일 시점에 의존성 분석이 완료됨 |
모듈 로딩이 런타임 조건에 따라 발생하며, 비동기적으로 로딩됨 |
| 반환값 | 없음 (선언문, 실행 시 반환값 없음) | Promise 객체 |
| 용도 | 핵심 의존성 로딩, 트리 쉐이킹(tree-shaking)에 최적화 | 코드 스플리팅, 조건부 로딩, 필요할 때만 가져오는 플러그인 구조 등 |
✔️ dynamic import() 예시
import()는 함수처럼 호출되며 Promise를 반환합니다. 이를 통해 런타임 조건에 따라 필요한 모듈만 비동기적으로 로드할 수 있습니다.
// 조건부 로딩 예제
let parser;
if (mode === 'json') {
// Promise를 await으로 기다려 모듈 객체를 얻음
parser = await import('./jsonParser.js');
} else {
parser = await import('./xmlParser.js');
}
// 모듈 객체를 통해 export된 함수 사용
parser.parse(data);
// 모듈 경로를 동적으로 구성하여 로드
const locale = navigator.language;
const messages = await import(`./i18n/${locale}.js`);
📌 모듈 캐싱 (Memoization)
모듈은 최초 로딩 후 모듈 인스턴스가 캐싱됩니다. 두 번째 import는 파일을 재평가하지 않고 캐시된 동일한 인스턴스를 반환합니다.
// a.js 파일을 첫 번째로 로딩 및 평가
const a1 = await import('./a.js');
// a.js 파일을 두 번째로 로딩 (캐시된 인스턴스를 반환)
const a2 = await import('./a.js');
console.log(a1 === a2); // 출력: true (동일 객체)
이러한 동작은 ECMAScript 표준에 명시되어 있으며, 모듈의 일관된 싱글톤(Singleton) 동작을 보장합니다.
3. Top-Level await (ES2022)
✔️ Top-Level await란?
ES2022에서 도입된 Top-Level await는 모듈의 최상위 레벨에서 async 함수 없이 await를 직접 사용할 수 있게 해줍니다.
이는 모듈 초기화 과정을 크게 단순화합니다.
TLA는 애플리케이션의 시작 단계에서 필수적인 초기화 작업의 순서와 완료를 보장하는 데 주로 사용됩니다.
✔️ 동적 설정 및 환경 로딩
애플리케이션 시작 시점에 환경(운영, 개발 등)을 감지하고, 해당 환경에 맞는 설정 모듈을 동적으로 로드하여 내보내는 패턴입니다.
// config-loader.js
// 1. 환경 변수를 비동기로 로드
const envResponse = await fetch('/api/environment');
const environment = await envResponse.json();
// 2. 환경에 따라 다른 설정 파일을 동적 import 및 await
let config;
if (environment.mode === 'production') {
config = await import('./config.prod.js');
} else {
config = await import('./config.dev.js');
}
// 3. 데이터베이스 연결 등 후속 초기화 작업 수행
const db = await initializeDatabase(config.dbUrl);
// 모든 것이 준비된 후 export
export const appConfig = config.default;
export const database = db;
// main.js: config-loader.js의 모든 비동기 작업이 끝난 후 실행 시작
import { appConfig, database } from './config-loader.js';
console.log('App started with config:', appConfig);
✔️ 의존성이 있는 모듈의 순차적 초기화
모듈 A가 모듈 B에 의존하고, 모듈 B가 초기화에 비동기 작업을 필요로 할 때, TLA는 실행 순서를 자동으로 보장합니다.
실행 순서: database.js 완료 → cache.js 완료 → api.js 실행.
// database.js
console.log('1. Connecting to database...');
const connection = await connectToDatabase();
export const db = connection;
// cache.js (database.js에 의존)
import { db } from './database.js'; // 📌 database.js의 await이 끝날 때까지 대기
console.log('2. Initializing cache with DB connection...');
const cache = await initializeCache(db);
export const appCache = cache;
// api.js (cache.js에 의존)
import { appCache } from './cache.js'; // 📌 cache.js의 await이 끝날 때까지 대기
console.log('3. Starting API server...');
const server = await startServer(appCache);
export const api = server;
✔️ 조건부 기능 로딩 및 코드 스플리팅
기능 플래그나 사용자 권한에 따라 필요한 모듈만 로드하여 초기 로딩 시간을 단축할 수 있습니다.
// feature-flags.js
const flagsResponse = await fetch('/api/feature-flags');
const flags = await flagsResponse.json();
// 기능 플래그가 true일 때만 해당 모듈을 로드
export const analytics = flags.analyticsEnabled
? await import('./analytics.js') // 로드 후 await
: null;
export const experimentalFeatures = flags.experimental
? await import('./experimental.js')
: null;
// app.js
import { analytics, experimentalFeatures } from './feature-flags.js';
if (analytics) {
analytics.trackPageView(); // 로드되었을 때만 실행
}
✔️ 병렬 로딩을 통한 성능 최적화
TLA를 사용할 때, 여러 개의 비동기 작업을 순차적으로 await하면 총 초기화 시간이 길어집니다.
Promise.all()을 사용하여 병렬로 실행함으로써 성능을 최적화할 수 있습니다.
// parallel-loader.js
// ❌ 순차적 로딩 (느림)
// const users = await fetchUsers();
// const products = await fetchProducts();
// ✅ 병렬 로딩 (빠름)
const [users, products, categories] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/products').then(r => r.json()),
fetch('/api/categories').then(r => r.json())
]);
console.log('All data loaded:', {
userCount: users.length,
productCount: products.length
});
export { users, products, categories };
✔️ 주의사항과 모범 사례
TLA는 강력하지만, 모듈 로딩을 지연시키므로 몇 가지 주의 사항이 있습니다.
1. 순환 의존성 (Circular Dependency) 문제
▸ TLA를 사용하는 모듈 A와 B가 서로를 import할 때, await 때문에 데드락(Deadlock)이 발생하여 모듈 로딩이 무한히 멈출 수 있습니다.
▸ 상호 의존적인 초기화가 필요한 경우, 초기화 로직을 별도의 공통 모듈로 분리하고 해당 모듈에서 Promise.all()을 사용하여 한 번에 처리합니다.
2. 에러 핸들링과 Fallback
▸ TLA가 포함된 모듈에서 예외가 발생하면, 해당 모듈을 import하는 모든 상위 모듈의 로딩도 실패합니다.
▸ try...catch 블록으로 외부 통신을 감싸고, 실패 시 기본값(Fallback)을 제공하여 애플리케이션의 치명적인 오류를 방지해야 합니다.
// robust-loader.js
let userData;
try {
const response = await fetch('/api/user/profile');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
userData = await response.json();
} catch (error) {
console.error('Failed to load user data, using guest fallback:', error);
// 📌 에러 발생 시 Fallback 데이터 제공
userData = { id: 'guest', name: 'Guest User' };
}
export const user = userData;
✔ 마무리
현대 JavaScript 개발의 핵심 개념인 ESM 모듈 시스템을 중심으로, import/export 문법, 모듈 해석 규칙, dynamic import(), 그리고 Top-Level await까지 모듈과 관련된 최신 ECMAScript 기능들을 폭넓고 깊이 있게 살펴보았습니다.
모듈 시스템은 단순히 코드를 여러 파일로 나누는 기술을 넘어, 애플리케이션 구조를 정의하고, 실행 흐름을 결정하며, 전체 프로젝트의 유지보수성과 확장성을 좌우하는 기본 설계 요소입니다.
특히 2025년 기준으로 ESM은 JavaScript의 사실상 유일한 표준 모듈 시스템으로 자리 잡았으며, 브라우저와 Node.js 모두에서 동일한 문법으로 사용할 수 있는 범용성을 갖추게 되었습니다.
정적 구조를 기반으로 한 import/export는 트리 쉐이킹과 같은 최적화 기술을 가능하게 만들고, dynamic import()는 런타임 상황에 따라 필요한 모듈만 로딩할 수 있는 유연성을 제공합니다.
또한 Top-Level await의 도입으로 모듈 레벨에서 자연스럽게 비동기 초기화를 표현할 수 있게 되어, 실무 코드의 가독성과 구조적 일관성 역시 큰 폭으로 개선되었습니다.
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.
'3.SW개발 > Node.js' 카테고리의 다른 글
| [JavaScript] 8편. 반복문 이해하기: for, while, forEach, Iterable (0) | 2025.12.08 |
|---|---|
| [JavaScript] 7편. 컬렉션 이해하기: 배열,Map,Set,groupBy,WeakMap (0) | 2025.12.07 |
| [JavaScript] 5편. 비동기 처리와 이벤트 루프 이해하기 : Promise, async/await, Event Loop (0) | 2025.12.06 |
| [JavaScript] 4편. 클래스(Class) & 프로토타입(Prototype) 이해하기 (1) | 2025.12.06 |
| [JavaScript] 3편. 함수 다루기: 함수 선언, this, 화살표 함수, 클로저(Closure), 콜백(Callback) (0) | 2025.12.05 |