모듈 시스템
전역 오염을 막기 위한 IIFE·CommonJS의 흐름을 거쳐 ES Modules의 export·import로 의존성을 분리합니다.
지금까지 기본 문법부터 비동기 처리, 객체 지향과 함수형 프로그래밍까지 살펴봤습니다. 그런데 실제 웹 애플리케이션의 코드는 수십에서 수백 개의 파일로 나뉘므로, 파일들이 서로 필요한 기능을 주고받고 의존 관계를 정리할 방법이 필요합니다. 이것이 모듈 시스템(module system)입니다.
모듈은 기능 단위로 나눈 코드 묶음으로, 안쪽은 감추고 필요한 부분만 바깥에 내보냅니다. 모듈을 쓰면 관련된 코드는 한곳에 모이고 파일 사이의 연결은 느슨해지며, 전역 스코프가 이름으로 어지럽혀지지 않습니다. 이 글에서는 모듈 시스템이 발전해 온 과정을 짧게 살펴본 뒤, 지금의 표준인 ES 모듈(ECMAScript modules, ESM)을 중심으로 사용법을 익힙니다.
모듈 시스템이 필요한 이유
모듈 시스템이 없던 초기의 자바스크립트에는 몇 가지 문제가 있었습니다. 모든 스크립트 파일이 하나의 전역 스코프를 함께 쓰므로, 다른 파일에서 같은 이름의 변수나 함수를 선언하면 서로 덮어쓰는 이름 충돌(name collision)이 생겼습니다. 어떤 스크립트가 다른 스크립트에 의존하는지는 <script> 태그의 순서로만 정할 수 있어서, 태그 하나가 빠지거나 순서가 바뀌면 오류가 났습니다.
또 코드를 기능 단위로 떼어 내기 어려워 다른 프로젝트에서 다시 쓰기 힘들었고, 쓰지 않는 코드까지 함께 불러오기 쉬웠습니다. 모든 것이 전역에 드러나 있으니 내부 구현을 감추는 캡슐화도 어려웠습니다. 이런 문제를 풀기 위해 여러 모듈 방식이 차례로 등장했습니다.
모듈 시스템의 역사
언어에 공식 모듈 시스템이 없던 시절에는 여러 패턴과 도구로 모듈과 비슷한 기능을 만들었습니다. 각 방식이 파일마다 스코프를 어떻게 나누는지, 무엇을 내보내는지, 다른 파일을 어떻게 가져오는지를 중심으로 살펴봅니다.
IIFE를 이용한 모듈 패턴 (과거)
가장 흔하던 방법은 즉시 실행 함수 표현식(immediately invoked function expression, IIFE)입니다. 함수를 정의하자마자 실행해서, 그 안의 변수는 함수 스코프에 감추고 바깥에 보여 줄 것만 전역에 하나 남깁니다. 6장에서 본 클로저를 이용하는 방식입니다.
// (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 (바깥에서 접근 불가)publicMethod()는 클로저로 privateVariable과 privateMethod()를 쓸 수 있지만, 바깥에서는 두 이름 모두 보이지 않습니다. 이 방식으로 전역에 남는 이름은 myModule 하나로 줄지만, 모듈 사이의 의존 관계는 여전히 <script>의 순서로 직접 맞춰야 했습니다.
CommonJS
CommonJS는 Node.js가 처음부터 지원해 온 모듈 시스템입니다. 지금의 Node.js는 CommonJS와 ES 모듈을 모두 지원하므로, Node.js가 CommonJS만 쓴다는 뜻은 아닙니다. 다른 모듈은 require()로 가져오고, 바깥에 내보낼 것은 module.exports나 exports에 담습니다. require()는 파일을 동기적으로 읽어 모듈을 가져옵니다.
직접 확인하려면 cjs-practice라는 새 폴더를 만들고, 다음 세 파일을 저장합니다. 첫 번째는 폴더의 형식을 정하는 package.json입니다. 설치할 패키지는 없으며, 바깥 폴더의 설정을 따라 다른 형식으로 해석되지 않도록 이 폴더가 CommonJS라고 밝혀 둡니다.
{ "type": "commonjs" }// 📁 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)); // 결과: 6그 폴더의 터미널에서 node app.js를 실행하면 8과 6이 출력됩니다. 이 math.js는 뒤에서 만들 ES 모듈용 math.js와 내용이 다르므로, 두 실습은 서로 다른 폴더에서 진행하세요.
CommonJS는 Node.js 생태계가 커지는 데 큰 역할을 했지만, 브라우저는 require()를 알지 못해 그대로 쓸 수 없었습니다. 브라우저에서 쓰려면 여러 파일을 하나로 묶어 주는 변환 도구가 필요했습니다.
AMD
브라우저는 파일을 네트워크로 받아 와야 하므로, 파일을 다 받을 때까지 멈춰 기다리는 동기 방식은 맞지 않았습니다. AMD(Asynchronous Module Definition)는 브라우저에서 모듈을 비동기로 불러오기 위해 나온 방식이며, require.js 라이브러리가 대표적입니다.
// 📁 myModule.js (AMD 형식)
define(['dependency1', 'dependency2'], function(dep1, dep2) {
// 모듈 코드
const myValue = dep1.someFunction() + dep2.anotherValue;
return {
myFunction: function() {
console.log(myValue);
}
};
});define()의 첫 인수는 의존하는 모듈의 목록이고, 그 모듈들을 모두 불러오면 두 번째 인수인 함수가 그 모듈들을 인수로 받아 실행됩니다. 비동기 로드는 해결했지만 모든 코드를 define() 함수로 감싸야 해서 문법이 번거로웠습니다. 이 코드는 require.js 같은 로더가 있어야 동작하는 형식 비교용 예시입니다.
ES 모듈
ES2015(ES6)부터 자바스크립트 언어 자체에 공식 모듈 시스템인 ES 모듈이 들어왔습니다. 지금은 대부분의 브라우저와 Node.js가 별도 도구 없이 ES 모듈을 직접 지원합니다.
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가 발생할 수 있다.
위 그림은 ES 모듈들이 서로를 가져오는 의존 그래프를 보여 줍니다. 화살표는 import하는 모듈에서 의존하는 대상으로 향합니다. 진입 모듈인 entry.js는 module-a.js, module-b.js, 외부 패키지를 가져오고, A와 B는 공통 모듈인 shared.js를 함께 가져옵니다. A와 B는 서로를 가져오는 순환 의존 관계이기도 합니다. 일곱 개의 화살표는 실행되는 순서가 아니라 무엇이 무엇에 의존하는지를 나타냅니다.
그림 아래쪽은 로더가 이 그래프를 실행하는 네 단계를 보여 줍니다. 먼저 './math.js' 같은 모듈 이름을 실제 위치로 해석하는데, 브라우저는 URL과 import map 규칙을, Node.js는 파일 경로와 패키지의 exports 설정 같은 규칙을 씁니다. 그다음 모듈 파일을 가져오거나 읽어 그래프를 만들고, import와 export의 이름을 서로 연결한 뒤, 마지막으로 의존 관계를 따라 각 모듈의 본문을 실행합니다.
이름을 연결하는 단계에서 가져온 이름은 값의 복사본이 아니라 내보낸 쪽의 변수를 계속 가리키는 라이브 바인딩(live binding)이 됩니다. 연결은 실행보다 먼저 일어나므로, 순환 의존이 있으면 아직 초기화되지 않은 바인딩을 읽게 될 수 있고 이때는 ReferenceError가 납니다. 순환 의존이 항상 실패하는 것은 아니지만 초기화 순서에 주의해야 합니다.
여기부터는 esm-practice라는 새 폴더에서 실습합니다. 아래 코드의 첫 줄 주석에 적힌 이름으로 각각 저장하세요.
export로 내보내기
export 키워드로 모듈에서 내보낼 변수, 함수, 클래스를 정합니다. 내보내는 방식은 두 가지입니다.
이름 있는 내보내기(named export)는 여러 항목을 각자의 이름으로 내보냅니다.
// 📁 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)는 모듈마다 하나만 둘 수 있으며, 모듈의 핵심 기능 하나나 클래스 하나를 내보낼 때 주로 씁니다. 가져오는 쪽은 기본 내보내기에 원하는 이름을 붙여 가져올 수 있습니다.
// 📁 greeter.js (ESM)
const defaultGreeting = "Hello, World!";
export default function greet(name = "Guest") {
return `${defaultGreeting} ${name}!`;
}
// 클래스도 기본 내보내기 가능
// export default class User { ... }import로 가져오기
import 키워드로 다른 모듈이 내보낸 항목을 가져옵니다. 이름 있는 내보내기는 { } 안에 내보낸 이름 그대로 적어 가져오고, 다른 이름으로 쓰고 싶으면 as로 별칭을 붙입니다. * as 이름으로 가져오면 모듈이 내보낸 모든 항목을 속성으로 가진 네임스페이스 객체(namespace object)를 받습니다. 기본 내보내기는 { } 없이 원하는 이름으로 가져옵니다.
// 📁 app.js
import { PI, add, subtract as minus } from "./math.js"; // 브라우저에서는 .js 확장자 필수
import * as MathUtils from "./math.js";
import myGreetFunction from "./greeter.js"; // greeter.js의 기본 내보내기를 myGreetFunction으로 가져옴
console.log(PI); // 3.14159
console.log(add(10, 5)); // 15
console.log(minus(20, 7)); // 13 (subtract를 minus라는 별칭으로 사용)
console.log(MathUtils.add(1, 2)); // 3 (네임스페이스 객체의 속성으로 사용)
console.log(myGreetFunction("Alice")); // Hello, World! Alice!
// 기본 내보내기와 이름 있는 내보내기를 함께 가져올 수도 있습니다.
// import myGreetFunction, { something } from "./greeter.js"; // greeter.js가 something도 내보낼 때import 선언은 모듈의 맨 위에 모아 두는 것이 보통입니다. 같은 math.js를 여러 번 가져와도 모듈은 한 번만 실행되고, 두 import는 같은 모듈의 이름들을 가리킵니다.
브라우저에서 ES 모듈 사용하기
브라우저에서 ES 모듈을 쓰려면 <script> 태그에 type="module" 속성을 붙입니다. 같은 폴더에 다음 파일을 index.html로 저장합니다.
<!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"을 붙이면 import 문법을 쓸 수 있게 되는 것 이상으로 실행 방식이 달라집니다. 모듈 스크립트는 defer를 붙인 것처럼 HTML 해석을 막지 않고 문서를 다 읽은 뒤 실행됩니다. 모듈 안의 코드는 항상 엄격 모드이고, 최상위에 선언한 변수도 전역이 아닌 그 모듈의 스코프에 속하며, 최상위의 this는 undefined입니다. 또 모듈은 CORS(Cross-Origin Resource Sharing) 규칙에 따라 가져오므로, 다른 출처의 모듈은 그 서버가 허락해야 불러올 수 있습니다.
파일을 더블 클릭해 file: 주소로 열면 이 보안 규칙 때문에 모듈을 불러오지 못할 수 있습니다. 편집기에 있는 로컬 HTTP 서버 기능으로 esm-practice 폴더를 열고, http://localhost:.../index.html 주소에서 개발자 도구의 콘솔을 확인하세요. 새 패키지를 설치할 필요는 없습니다. 콘솔에 3.14159, 15, 13, 3, Hello, World! Alice!가 출력되면 성공이며, 화면에 제목이 보인다고 모듈이 실행된 것은 아닙니다.
오류가 나면 개발자 도구의 Network 탭에서 app.js, math.js, greeter.js가 404를 받거나 HTML 문서로 응답하지 않았는지 확인합니다. ./math.js 같은 경로는 그 import를 적은 모듈 파일의 URL을 기준으로 해석되며, 서버가 자바스크립트 형식으로 응답해야 합니다. 브라우저는 확장자를 대신 붙여 주지 않으므로 경로에 .js까지 적어야 합니다.
Node.js에서 ES 모듈 사용하기
Node.js에서 ES 모듈을 쓰는 방법은 두 가지입니다. 하나는 package.json에 "type": "module"을 넣는 것으로, 그러면 그 폴더의 .js 파일이 ES 모듈로 처리됩니다. 다른 하나는 파일 확장자를 .mjs로 쓰는 것으로, .mjs 파일은 package.json과 관계없이 ES 모듈로 처리되고 .cjs 파일은 항상 CommonJS로 처리됩니다.
{
"name": "my-node-app",
"version": "1.0.0",
"type": "module",
"main": "app.js",
"scripts": {
"start": "node app.js"
}
}기존 package.json을 고친다면 다른 키는 그대로 두고 "type": "module"만 추가합니다. esm-practice 폴더에 이 설정을 저장하고 node app.js를 실행하면 브라우저와 같은 다섯 줄이 출력됩니다. Node.js에서도 import와 export 문법은 같지만, window나 document 같은 브라우저 객체는 없습니다.
최근 Node.js는 type이 없을 때 파일 안의 문법을 보고 ES 모듈인지 추측하기도 합니다. 하지만 추측에 맡기면 같은 파일이 환경에 따라 다르게 해석될 수 있으므로, package.json의 type이나 .mjs, .cjs 확장자로 형식을 분명히 밝혀 두는 편이 좋습니다.
가져온 값은 언제 바뀔까
그림에서 본 라이브 바인딩을 직접 확인해 봅니다. 같은 esm-practice 폴더에 다음 두 파일을 새로 만듭니다. 브라우저에서는 index.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입니다. 가져온 countForModule은 counter.js의 변수를 계속 가리키므로, incrementForModule()이 바꾼 최신 값 1을 읽습니다. 반면 snapshotForModule은 가져온 시점의 숫자 0을 별도의 상수에 복사해 둔 것이라 그대로입니다.
라이브 바인딩은 읽기 전용입니다. 값을 바꿀 수 있는 것은 내보낸 counter.js 쪽뿐이며, 가져온 쪽에서 countForModule에 다른 값을 대입할 수는 없습니다. 다만 객체를 가져왔다면 그 객체의 속성을 바꾸는 것은 별개의 문제라서, 모듈에서 가져온 객체라고 저절로 바뀌지 않게 되지는 않습니다.
더 깊이: 두 모듈이 서로를 가져오면
a.js가 b.js를 가져오고 b.js도 a.js를 가져오는 것을 순환 의존(cyclic import)이라고 하며, ES 모듈에서 이것이 곧바로 오류가 되지는 않습니다. 앞에서 본 것처럼 가져온 이름은 값이 아니라 내보낸 변수와의 연결이고, 그 값은 실제로 읽는 순간에 가져오기 때문입니다. 문제는 읽는 시점입니다. a.js를 진입 모듈로 실행하면 의존 모듈인 b.js가 먼저 실행되는데, 이때 b.js의 맨 위 코드에서 a를 읽으면 a.js는 아직 실행 전이라 ReferenceError: Cannot access 'a' before initialization이 납니다. 같은 읽기를 함수 안이나 타이머 콜백에 두어 두 모듈이 모두 실행된 뒤에 하면 오류가 나지 않습니다.
CommonJS는 다르게 동작합니다. Node.js 문서에 따르면 require()가 서로를 부르면 무한 반복을 막으려고, 아직 실행이 끝나지 않은 모듈의 exports 객체를 그 시점의 모습 그대로 돌려줍니다. 그래서 오류 대신 아직 채워지지 않은 속성이 undefined로 보이며, 문제가 어디서 시작되었는지 알아채기가 더 어렵습니다.
MDN의 JavaScript 모듈 안내는 순환 의존을 대체로 피하라고 권하며, 두 모듈을 하나로 합치거나, 함께 쓰는 코드를 세 번째 모듈로 옮기거나, 한쪽의 코드를 다른 쪽으로 옮기는 방법을 듭니다. 오류 메시지에 초기화 전에 접근했다는 내용이 보이면, 모듈 사이의 가져오기가 고리를 이루지 않았는지 먼저 확인합니다.
정리
- 모듈은 기능 단위로 나눈 코드 묶음으로, 안쪽을 감추고 필요한 것만 내보내 전역 이름 충돌과 의존 순서 문제를 줄입니다.
- IIFE는 함수 스코프로 이름을 감췄고, CommonJS는
require()와module.exports로, AMD는define()으로 브라우저 비동기 로드를 지원했습니다. - ES 모듈은 언어에 내장된 표준으로,
export로 내보내고import로 가져오며 기본 내보내기는 모듈마다 하나입니다. - 브라우저는
type="module"과 확장자를 포함한 경로가 필요하고, HTTP 서버로 열어야 하며, 모듈은 항상 엄격 모드로 실행됩니다. - Node.js는
package.json의"type"이나.mjs,.cjs확장자로 모듈 형식을 정합니다. - 가져온 이름은 내보낸 변수를 가리키는 읽기 전용 라이브 바인딩이고, 같은 모듈은 여러 번 가져와도 한 번만 실행됩니다.
연습 문제
1. 두 번 갱신하기
live.js의 incrementForModule(); 다음 줄에 같은 호출을 한 번 더 넣었습니다. 출력은 무엇이고, snapshotForModule에는 왜 이 갱신이 반영되지 않을까요?
해설 보기
출력은 0 2입니다. counter.js의 countForModule이 두 번 늘어나고, 가져온 이름은 그 변수의 최신 값을 읽습니다.
snapshotForModule은 처음 읽은 원시값 0을 복사해 둔 상수라 바뀌지 않습니다. 그림의 의존 화살표도 이 연결을 뜻할 뿐, 값이 바뀔 때마다 파일 전체를 다시 실행한다는 뜻은 아닙니다.
2. 가져온 이름에 대입하기
live.js의 incrementForModule();을 countForModule = 5;로 바꿔 실행하면 어떻게 될까요? 값을 5로 바꾸고 싶다면 어떻게 해야 할까요?
해설 보기
대입하는 줄에서 TypeError가 납니다(“Assignment to constant variable.”과 비슷한 메시지). 가져온 이름은 읽기 전용 바인딩이라, counter.js에서 let으로 선언했더라도 가져온 쪽에서는 다른 값을 넣을 수 없습니다.
값을 바꾸려면 counter.js가 export function setCountForModule(value) { countForModule = value; }처럼 값을 바꾸는 함수를 내보내고, 가져온 쪽은 그 함수를 호출해야 합니다. 변경은 변수를 선언한 모듈 안에서만 일어납니다.
3. 모듈은 몇 번 실행될까
counter.js의 맨 위에 console.log("counter 평가");를 넣었습니다. 그리고 live.js의 첫 import 아래에 import * as CounterAgain from "./counter.js";를 추가했습니다. “counter 평가”는 몇 번 출력될까요?
해설 보기
한 번만 출력됩니다. 같은 모듈을 여러 번 가져와도 로더는 그 모듈을 한 번만 실행하고, 두 import는 같은 모듈의 바인딩에 연결됩니다.
그래서 CounterAgain.countForModule도 countForModule과 같은 변수를 가리키며, incrementForModule()을 호출한 뒤에는 둘 다 같은 최신 값을 읽습니다. 그림의 로더 단계에서 평가가 모듈마다 한 번 이루어지는 것과 같은 원리입니다.
모듈의 경로 해석과 HTTP 실행 조건, 바인딩 규칙은 MDN의 JavaScript 모듈 문서에서, Node.js가 두 모듈 형식을 구분하는 규칙은 Node.js의 모듈 문서에서 확인할 수 있습니다.
다음 글 메모리 관리와 가비지 컬렉션에서는 자바스크립트가 더 쓰지 않는 메모리를 어떻게 회수하는지와, 메모리가 계속 늘어나는 누수가 왜 생기는지를 살펴봅니다.