모듈 시스템
전역 오염을 막기 위한 IIFE·CommonJS의 흐름을 거쳐 ES Modules의 export·import로 의존성을 분리합니다.
우리는 지금까지 기본 문법부터 비동기 처리, 객체 지향, 함수형 프로그래밍까지 다양한 심화 개념을 학습했습니다.
하지만 실제 대규모 웹 애플리케이션에서는 코드가 수십~수백 파일로 나뉘므로, 이를 효율적으로 관리/재사용하고 의존성을 정리하는 구조가 필요합니다.
이 질문에 대한 답이 바로 모듈 시스템(Module System)입니다.
모듈은 특정 기능 단위로 코드를 분리하고 캡슐화하여, 필요한 부분만 가져다 쓸 수 있도록 하는 재사용 가능한 코드 블록입니다.
모듈 시스템을 사용하면 코드의 응집도를 높이고, 결합도를 낮추며, 전역 스코프 오염을 방지하여 유지보수성과 확장성이 뛰어난 애플리케이션을 구축할 수 있습니다.
이번 장에서는 모듈 시스템의 역사와 현재 표준인 ES 모듈(ES Modules)을 중심으로 핵심 흐름을 정리합니다.
DEPENDENCY
ESM 의존 그래프와 실행 경계
화살표는 import하는 모듈에서 의존 대상으로 향한다. A와 B는 서로 의존할 수 있으며, 로더는 이름 해석·읽기·바인딩 연결·평가를 거쳐 코드를 실행한다.
- 진입과 공유 의존
entry는 A·B·외부 package에 의존하고, A와 B는 shared를 함께 import한다.
- A ↔ B 순환
A가 B를 import하고 B도 A를 import한다. 서로의 초기화 시점에 주의한다.
1 · 이름 해석
브라우저는 URL/import map, Node는 파일·package exports 등의 규칙으로 specifier를 해석한다.
2 · 모듈 읽기
브라우저는 모듈을 가져오고 Node는 파일을 읽는다. 의존 그래프의 모듈 기록을 준비한다.
3 · 바인딩 연결
import/export를 live binding으로 연결한다. 아직 값이 초기화되지 않은 바인딩이 있을 수 있다.
4 · 평가·실행
의존 관계와 순환을 고려해 모듈 본문을 평가한다. 진입 모듈 실행도 이 평가 과정에 포함된다.
- 의존 대상으로 향하는 import
- 순환을 만드는 B→A import
Live binding은 값의 복사본이 아니다
import는 내보낸 바인딩에 연결된다. 순환 의존성이 있어도 항상 실패하지는 않지만, 초기화 전 바인딩을 읽으면 ReferenceError가 발생할 수 있다.
모듈 시스템의 필요성
모듈 시스템이 없던 초기의 자바스크립트는 다음과 같은 문제점들을 안고 있었습니다.
전역 스코프 오염: 모든 스크립트 파일이 전역 스코프를 공유하기 때문에, 변수나 함수 이름이 충돌(name collision)할 위험이 높았습니다.
이는 코드의 안정성을 해치고 디버깅을 어렵게 만들었습니다.
의존성 관리의 어려움: 스크립트 간의 실행 순서나 의존 관계를 수동으로 <script> 태그의 순서로 관리해야 했고, 누락되거나 순서가 바뀌면 오류가 발생했습니다.
코드 재사용의 어려움: 코드를 특정 기능 단위로 분리하기 어려워 재사용이 힘들었고, 불필요한 코드가 포함될 가능성이 높았습니다.
정보 은닉의 부족: 모든 것이 전역 스코프에 노출되어 있어, 캡슐화나 정보 은닉이 어려웠습니다.
이러한 문제들을 해결하기 위해 다양한 모듈 시스템이 등장하게 됩니다.
자바스크립트의 모듈 시스템 역사
이 절에서는 파일별 스코프, 내보내는 이름, 가져오는 경로를 나누어 확인합니다. 앞 장의 함수·객체와 클로저를 알고 있으면 IIFE 예시부터 따라갈 수 있습니다.
공식적인 모듈 시스템이 없던 시절, 개발자들은 여러 편법과 패턴을 사용하여 모듈과 유사한 기능을 구현했습니다.
IIFE를 이용한 모듈 패턴 (과거)
가장 흔하게 사용되던 패턴 중 하나로, 함수 스코프를 이용하여 전역 스코프 오염을 방지하고 변수를 은닉했습니다.
// (function() { ... })();
// 또는 (function() { ... }());
// (즉시 실행 함수 표현: Immediately Invoked Function Expression)
(function() {
let privateVariable = "나는 비밀!"; // 함수 스코프 안에 캡슐화
function privateMethod() {
console.log(privateVariable);
}
// 전역 스코프에 노출할 유일한 객체
window.myModule = {
publicMethod: function(message) {
console.log("공개 메서드: " + message);
privateMethod(); // privateMethod는 내부에서만 접근 가능
}
};
})();
myModule.publicMethod("안녕하세요"); // 결과: 공개 메서드: 안녕하세요, 나는 비밀!
// console.log(myModule.privateVariable); // undefined (접근 불가)이 방식은 전역 오염을 줄여주지만, 여전히 의존성 관리가 수동적이고 코드 복잡성이 증가하는 단점이 있었습니다.
CommonJS (Node.js의 표준 모듈 시스템)
CommonJS는 Node.js가 지원해 온 모듈 시스템입니다. 현재 Node.js는 CommonJS와 ES Modules를 모두 지원하므로, 아래 제목을 “Node.js는 CommonJS만 사용한다”는 뜻으로 읽지 않습니다.
아래 require() 예시는 동기적으로 모듈을 가져옵니다. cjs-practice라는 별도 폴더에 다음 두 원래 코드 블록을 각각 math.js·app.js로 저장하고, 같은 폴더에 아래 설정을 저장합니다. 이어서 그 폴더의 터미널에서 node app.js를 실행하면 8, 6이 출력됩니다. 뒤의 ESM용 math.js로 덮어쓰지 마세요.
{ "type": "commonjs" }위 파일 이름은 package.json입니다. 설치할 패키지는 없습니다. 부모 프로젝트의 설정을 우연히 따라 다른 형식으로 해석하지 않도록 이 실습 폴더의 형식을 명시합니다.
require(): 다른 모듈을 가져올 때 사용합니다.module.exports또는exports: 모듈 외부로 내보낼 대상을 정의합니다.
// 📁 math.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
module.exports = { // 외부로 내보낼 객체 정의
add: add,
subtract: subtract
};
// 또는 간단하게
// exports.add = add;
// exports.subtract = subtract;// 📁 app.js
const math = require('./math'); // math.js 모듈을 가져옴
console.log(math.add(5, 3)); // 결과: 8
console.log(math.subtract(10, 4)); // 결과: 6CommonJS는 Node.js 생태계를 성장시키는 데 큰 기여를 했지만, 브라우저 환경에서 바로 사용하기는 어렵다는 단점이 있었습니다 (변환 도구 필요).
AMD
CommonJS가 동기적인 로드를 지원하는 반면, 브라우저 환경에서는 네트워크를 통해 파일을 가져와야 하므로 비동기 로딩이 필요했습니다.
AMD는 이러한 요구사항을 충족하기 위해 등장한 비동기 모듈 시스템입니다.
require.js 라이브러리가 대표적입니다.
// 📁 myModule.js (AMD 형식)
define(['dependency1', 'dependency2'], function(dep1, dep2) {
// 모듈 코드
const myValue = dep1.someFunction() + dep2.anotherValue;
return {
myFunction: function() {
console.log(myValue);
}
};
});AMD는 비동기 로드를 지원했지만, 문법이 다소 복잡하다는 단점이 있었습니다.
ES Modules (ESM)
ES2015(ES6)부터 자바스크립트 언어 자체에 내장된 공식적인 모듈 시스템인 ES 모듈(ECMAScript Modules, ESM)이 등장했습니다.
이제 대부분의 모던 브라우저와 Node.js(최신 버전)에서 별도의 도구 없이 ES 모듈을 직접 지원합니다.
여기부터는 esm-practice라는 새 폴더를 사용합니다. CommonJS 예시의 파일과 섞지 않고, 내보내기 코드의 주석에 적힌 math.js·greeter.js로 각각 저장하세요. AMD 블록은 별도 로더가 필요한 역사적 형식 비교이며 이 실습 폴더에 함께 붙이지 않습니다.
export: 모듈 내보내기
export 키워드를 사용하여 모듈에서 내보낼 변수, 함수, 클래스 등을 지정합니다.
이름 기반 내보내기 (Named Exports): 여러 항목을 이름으로 내보낼 수 있습니다.
// 📁 math.js (ESM)
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
// 클래스도 내보낼 수 있습니다.
export class Calculator {
multiply(a, b) {
return a * b;
}
}기본 내보내기 (Default Export): 모듈당 오직 하나의 기본 내보내기만 가능합니다.
주로 모듈의 핵심 기능이나 단일 클래스를 내보낼 때 사용합니다.
export default는 이름 없이 내보내지므로 가져올 때 원하는 이름으로 지정할 수 있습니다.
// 📁 greeter.js (ESM)
const defaultGreeting = "Hello, World!";
export default function greet(name = "Guest") {
return `${defaultGreeting} ${name}!`;
}
// 클래스도 기본 내보내기 가능
// export default class User { ... }import: 모듈 가져오기
import 키워드를 사용하여 다른 모듈에서 내보낸 항목들을 가져옵니다.
이름 기반 가져오기 (Named Imports): {} 안에 내보낸 이름과 동일하게 지정하여 가져옵니다.
여러 항목을 가져올 수 있습니다.
// 📁 app.js
import { PI, add, subtract } from './math.js'; // .js 확장자 필수!
console.log(PI); // 결과: 3.14159
console.log(add(10, 5)); // 결과: 15
// 내보낸 이름과 다른 이름으로 사용하고 싶을 때 (별칭)
import { subtract as minus } from './math.js';
console.log(minus(20, 7)); // 결과: 13
// 모든 내보낸 항목을 하나의 객체로 가져오기
import * as MathUtils from './math.js';
console.log(MathUtils.add(1, 2)); // 결과: 3기본 가져오기 (Default Import): 기본 내보내기는 {} 없이 원하는 이름으로 가져올 수 있습니다.
// 📁 app.js
import myGreetFunction from './greeter.js'; // greeter.js의 기본 내보내기를 myGreetFunction으로 가져옴
console.log(myGreetFunction("Alice")); // 결과: Hello, World! Alice!
// 이름 기반 내보내기와 기본 내보내기를 함께 가져올 수도 있습니다.
// import myGreetFunction, { PI, add } from './greeter.js'; // (단, greeter.js에 PI와 add가 export 되어 있어야 함)브라우저에서 ES 모듈 사용하기
브라우저에서 ES 모듈을 사용하려면 <script> 태그에 type="module" 속성을 추가해야 합니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>ES Modules 예제</title>
</head>
<body>
<h1>ES Modules 테스트</h1>
<script type="module" src="app.js"></script>
</body>
</html>type="module"스크립트는defer속성을 가진 것처럼 동작하여 HTML 파싱을 블로킹하지 않습니다.- 모듈 내부는 기본적으로 엄격 모드(
'use strict')로 동작합니다. - 모듈 스크립트는 CORS(Cross-Origin Resource Sharing) 정책의 영향을 받습니다.
파일을 모아 실행하기
위 ESM용 math.js, greeter.js와 HTML을 각각 esm-practice 폴더에 저장하고 HTML 파일 이름을 index.html로 정합니다. 두 가져오기 예시를 함께 확인하려면 app.js에는 아래처럼 한 번에 저장합니다.
import { PI, add, subtract as minus } from "./math.js";
import * as MathUtils from "./math.js";
import myGreetFunction from "./greeter.js";
console.log(PI);
console.log(add(10, 5));
console.log(minus(20, 7));
console.log(MathUtils.add(1, 2));
console.log(myGreetFunction("Alice"));이미 사용하는 편집기의 로컬 HTTP 서버 기능으로 이 폴더를 열고, 제공한 http://localhost:.../index.html 주소에서 콘솔을 봅니다. 새로운 패키지를 설치할 필요는 없습니다. 파일을 더블 클릭한 file: 주소로는 모듈의 보안 조건 때문에 실패할 수 있습니다. 출력은 3.14159, 15, 13, 3, Hello, World! Alice!입니다. HTML 화면의 제목만으로 모듈 실행 성공을 판단하지 마세요.
오류가 나면 Network에서 app.js·math.js·greeter.js가 404나 HTML 문서로 응답하지 않는지 확인합니다. ./math.js는 가져오는 모듈 파일의 URL을 기준으로 해석됩니다. 서버가 JavaScript로 응답해야 하며, 브라우저의 상대 경로에는 여기처럼 파일 확장자를 포함합니다.
브라우저에서 type="module"을 붙이면 단순히 import 문법이 허용되는 것 이상으로 실행 조건이 달라집니다.
전역 스코프와 분리되고, HTML 파싱을 막지 않으며, 모듈 의존성 그래프를 따라 필요한 파일을 로드합니다.
Node.js에서 ES 모듈 사용하기
Node.js에서 ES 모듈을 사용하려면 몇 가지 방법이 있습니다.
package.json에 "type": "module" 추가: 프로젝트의 package.json 파일에 "type": "module"을 추가하면, .js 확장자를 가진 파일들이 기본적으로 ES 모듈로 처리됩니다.
{
"name": "my-node-app",
"version": "1.0.0",
"type": "module",
"main": "app.js",
"scripts": {
"start": "node app.js"
}
}기존 package.json을 수정한다면 다른 키는 유지하고 "type": "module"만 병합합니다.
.mjs 확장자 사용: 파일 확장자를 .mjs로 사용하면 Node.js는 해당 파일을 ES 모듈로 인식합니다.
(CommonJS는 .cjs 확장자 사용)
Node.js에서도 브라우저와 동일하게 import/export 문법을 사용하여 모듈을 가져오고 내보낼 수 있습니다.
위 esm-practice에 "type": "module" 설정을 적용하고 node app.js로 실행해도 같은 계산 출력을 확인할 수 있습니다. Node.js에서는 window·document 같은 브라우저 객체가 제공되지 않는다는 차이가 있습니다. .js의 해석을 추측하지 말고 폴더의 package.json이나 .mjs·.cjs 확장자로 형식을 명시하세요.
앞의 ESM 의존 그래프와 실행 경계 다이어그램은 모듈을 나누고 가져올 때 의존 관계와 런타임 경계를 함께 점검하는 기준입니다.
가져온 값은 언제 갱신될까요?
같은 ESM 폴더에서 아래 두 파일을 새로 만듭니다. 첫 파일은 counter.js, 둘째는 live.js입니다. 브라우저 HTML의 모듈 src를 live.js로 바꾸거나, 위 Node.js 설정에서 node live.js로 실행하세요.
// counter.js
export let countForModule = 0;
export function incrementForModule() {
countForModule += 1;
}// live.js
import { countForModule, incrementForModule } from "./counter.js";
const snapshotForModule = countForModule;
incrementForModule();
console.log(snapshotForModule, countForModule);출력은 0 1입니다. import는 내보낸 바인딩을 계속 참조하므로 함수가 바꾼 값을 읽습니다. snapshotForModule은 처음 읽은 숫자 0을 별도 상수에 저장했습니다. 가져온 쪽에서 countForModule = 5로 재대입할 수는 없습니다. 객체를 가져왔다면 그 객체의 속성 변경 가능 여부는 별개이며, 모듈이라는 이유로 객체가 자동 불변이 되지 않습니다.
독립 연습
live.js의 incrementForModule(); 다음에 같은 호출을 한 줄 더 넣으세요. 예상 출력과, 이 파일에서 snapshotForModule에 같은 갱신이 적용되지 않는 이유를 적어 보세요.
두 번 갱신한 결과
출력은 0 2입니다. counter.js의 바인딩만 두 번 갱신됩니다. 가져온 이름은 그 바인딩의 최신 값을 읽지만, 이미 복사한 원시 값 0은 바뀌지 않습니다. 이 의존성 화살표는 라이브 바인딩의 연결을 뜻하며 매번 파일 전체를 다시 평가한다는 뜻은 아닙니다.
근거: MDN 모듈의 경로·HTTP 실행·바인딩, Node.js의 두 모듈 형식.
모듈 시스템 요약
이번 장에서는 자바스크립트의 모듈 시스템이 왜 필요하며, 어떻게 발전해 왔는지, 그리고 현대 자바스크립트의 표준인 ES 모듈을 어떻게 사용하는지 상세히 학습했습니다.
이번 절에서는 모듈 시스템의 목적, IIFE/CommonJS/AMD의 흐름, ES 모듈 문법을 정리했습니다.
ES 모듈은 브라우저와 Node.js 양쪽에서 사용할 수 있는 표준 모듈 시스템입니다.
파일을 어떻게 나누고 어떤 이름으로 내보낼지 정하면, 코드 의존 관계를 더 쉽게 추적할 수 있습니다.
모듈을 나눌 때는 내보낼 API의 수, 기본값의 존재, 실행 환경, 로딩 조건을 함께 봐야 합니다.
named export, default export, namespace import, 브라우저 모듈은 공개 API 수와 실행 환경을 기준으로 구분합니다.
모듈 시스템은 의존성 그래프, live binding, 실행 환경의 로더 경계를 함께 확인해야 합니다.