0. 들어가며…
지난 시간에는 웹팩이 여러 파일을 어떻게 청크(Chunk) 단위로 분리하는지 살펴봤다.
청크 분리는 빌드 타임(Build Time) 에 일어나며, 크게 세 단계로 요약할 수 있다.
| 단계 | 설명 |
|---|---|
| 1. ModuleGraph 생성 | import / require 관계를 분석하여 의존성 그래프를 만든다. |
| 2. ChunkGraph 생성 | 어떤 모듈을 어떤 청크로 묶을지 결정한다. |
| 3. 최종 산출물 생성 | 설정(splitChunks, runtimeChunk)에 따라 여러 JS 파일이 생성된다. |
결과는 이런 형태다.
dist/
├─ main.js
├─ runtime.js (설정에 따라 분리)
└─ dynamic.[hash].js
여기까지는 빌드 타임 이야기였다.
🤔 그런데 여기서 의문 하나!
이렇게 나눠진 청크는 브라우저에서 어떻게 실행할까?
만약, 단일 파일이라면 실행은 단순하다.
<script src="main.js"></script>
브라우저가 파일을 다운로드하고 실행하면 끝이다.
하지만 실제 서비스는 그렇지 않다.
- 여러 청크가 존재하고
import()가 있다.- CommonJS와 ESM이 섞여 있고
- 모듈 간 의존 관계가 복잡하게 얽혀 있다.
그리고 더 근본적인 문제가 하나 있다.
브라우저는 기본적으로 CommonJS 문법을 이해하지 못한다!
require("./math")
module.exports= {}
브라우저가 이해하는 것은 ECMAScript 표준 문법(import/export)과 Web API뿐이다.
require는 Node.js 런타임이 제공하는 함수이기에, 브라우저는 CommonJS 모듈 시스템을 알지 못한다.
✏️ 그런데 왜 번들은 실행될까?
아니 브라우저가 CommonJS 문법을 모르는데, 왜 에러 없이 실행되는 걸까?
이유는 단순하다. 웹팩이 모듈 시스템 자체를 번들 안에 구현했기 때문이다.
번들은 단순히 파일을 합친 결과물이 아니다.
내 코드 + 모듈 실행기(Runtime Engine)이 결합된 하나의 실행 프로그램이다!
이번 시간에는 웹팩이 삽입한 런타임 모듈 시스템이
- 어떻게 모듈을 등록하고
- 어떻게 실행을 통제하며
- 어떻게 동적 import까지 확장하는지
단계적으로 알아보겠다.
1. 웹팩 런타임은 무엇인가?
앞에서 언급했듯이, 번들은 단순한 파일 묶음이 아니다.
모듈 실행기를 포함한 하나의 실행 프로그램이다.
그렇다면 이 ‘실행기’는 어떤 구조로 이루어져 있을까?
1) 웹팩 런타임의 핵심 구조
(1) 구조부터 살펴보자
웹팩이 번들 안에 삽입하는 런타임의 핵심은 세 가지다.
var __webpack_modules__= { ... };
var __webpack_module_cache__= {};
function __webpack_require__(moduleId) { ... }
이 세 요소가 합쳐져 하나의 모듈 시스템을 구성한다.
| 요소 | 역할 |
|---|---|
__webpack_modules__ |
아직 실행되지 않은 모듈 팩토리 저장소 |
__webpack_module_cache__ |
실행 완료된 모듈 보관소 |
__webpack_require__ |
실행 여부를 판단하고 흐름을 제어하는 엔진 |
그렇다면, 번들에 포함된 각 소스 파일 모듈은 런타임에서 어떻게 저장될까?
웹팩은 모듈을 ‘파일’ 그대로 보관하지 않는다.
대신, 각 파일을 실행 가능한 팩토리 함수로 변환해 __webpack_modules__ 테이블에 등록한다.
이 변환은 빌드 타임에 이루어지며, 모듈은 다음과 같은 함수 형태로 감싸진다.
function(module,exports,__webpack_require__) {
// 원래 파일 코드
}
런타임에서는 모듈이 필요해질 때마다 __webpack_require__(moduleId)가 호출되고,
이 함수가 해당 모듈의 팩토리 함수를 실행한다.
즉, 번들 내부에는 브라우저에서 동작하는 CommonJS 실행 환경이 구현되어 있는 것이다.
(2) 왜 이런 구조가 필요한가?
만약 이 구조가 없으면 브라우저는 다음을 보장할 수 없다.
- 모듈은 최초 한 번만 실행된다.
- 실행 결과(
exports)는 공유된다. - 모듈 간 의존성은 런타임에서 동적으로 해석된다.
특히 첫 번째, 두 번째 규칙은 CommonJS 모듈 시스템의 중요한 특징이다.
- 한 번 실행된 모듈은 다시 실행되지 않고,
- 그 결과는 동일한 인스턴스로 공유된다.
웹팩 런타임은 이 동작을 브라우저 환경에서도 유지하기 위해 이 구조를 만들었다.
그럼 이 중에서
- 캐시를 확인하고
- 팩토리를 호출하고
- 실행 흐름을 통제하는 역할은 누가 할까?
바로 __webpack_require__다. 이제 이 함수가 내부에서 어떤 과정을 거쳐 모듈을 실행하는지 살펴보자.
2) webpack_require의 내부 동작
(1) 먼저 코드부터 살펴보자
웹팩 번들 내부의 대략적인 구조는 다음과 같다.
function __webpack_require__(moduleId) {
// 1. 캐시 확인
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// 2. 새 module 객체 생성
var module = { exports: {} };
// 3. 캐시에 먼저 등록 (중요)
__webpack_module_cache__[moduleId] = module;
// 4. 모듈 팩토리 실행
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
// 5. exports 반환
return module.exports;
}
(2) 실행 흐름을 단계적으로 살펴보자
예를 들어 entry 모듈(index.js)이 math.js를 불러온다고 가정해보자.
빌드 타임에 math.js가 moduleId = 1로 매핑되었다면,
런타임에서는 파일 경로 대신 이 moduleId(1)를 기준으로 모듈을 식별한다.
즉, 브라우저에서 실행되는 코드는 다음과 같다.
const add = __webpack_require__(1); // math.js의 moduleId가 1이라고 가정
webpack_require_(1)이 호출되면, 이 함수 내부에서 여러 단계를 진행하게 된다.
1️⃣ 캐시 확인 단계 — 이미 실행된 적이 있는가?
var cachedModule = __webpack_module_cache__[moduleId];
__webpack_require__이 호출되면 가장 먼저 이 모듈이 이미 실행된 적이 있는지 확인한다.
왜 가장 먼저 캐시를 확인할까?
그 이유는 CommonJS에서는 모듈이 한 번만 실행되고,
이후에는 그 결과가 재사용되어야 하기 때문이다.
따라서 두 번째 require 호출시 팩토리를 재실행하면 안 된다.
이미 캐시에 존재한다면, 저장된 exports를 그대로 반환하고 팩토리는 호출하지 않는다.
2️⃣ 새 module 객체 생성 단계 — 실행 컨테이너 준비
var module = { exports: {} };
해당 모듈의 캐시가 없다면, 웹팩은 빈 module 객체를 만든다.
이 객체는 실행 결과를 담는 역할을 한다.
3️⃣ 캐시에 먼저 등록하는 단계 — 순환 참조를 막는 장치
__webpack_module_cache__[moduleId] = module;
모듈을 실행하기 전에, 생성한 module 객체를 먼저 캐시에 등록한다.
이는 순환 참조(Circular Dependency) 처리를 위해 필요하다.
왜 실행 전에 모듈 객체를 캐시에 등록해야 할까?
이유는 모듈은 실행 중에도 다시require될 수 있기 때문이다.
만약 캐시에 등록되어 있지 않으면 동일 모듈이 새로 생성되어 순환 참조 문제가 발생한다.
예를 들어, A가 B를 호출하고, B가 A를 호출하는 구조가 있다고 가정하자.
A → B → A
만약 A 실행이 완료된 후에 캐시에 등록된다면,
- 두 번째 A
require는 “아직 캐시에 없다”고 판단하고 새 모듈 객체를 다시 생성한다. - 그 결과, 무한 재귀(A → B → A → B → A …)가 발생한다.
하지만 실행 전에 캐시에 등록해두면,
- 두 번째
require는 이미 존재하는module객체를 반환하고 - 아직 완성되지 않은
exports라도 동일 인스턴스를 공유한다.
이것이 CommonJS의 표준 동작 방식이며, 웹팩 런타임은 이를 그대로 구현했다.
4️⃣ 모듈 팩토리가 실행되는 단계
캐시에 해당 모듈이 없다면, __webpack_modules__ 테이블에 등록된 팩토리 함수를 호출한다.
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
팩토리 함수가 호출되면,
module,exports,__webpack_require__가 인자로 전달되고- 함수 내부의 원본 코드가 실행되며
- 실행 결과가
module.exports에 기록된다.
5️⃣ exports를 반환하는 단계
팩토리 함수 실행이 끝나면,
return module.exports;
를 통해 module.exports가 그대로 반환된다.
__webpack_require__의 반환값은 곧 해당 모듈의 module.exports다.
팩토리 함수 호출
→ module.exports에 값 기록
→ module.exports 반환
(3) 한눈에 보는 실행 흐름
__webpack_require__(1)
↓
[1] 캐시 확인
↓
[2] module 객체 생성
↓
[3] 캐시 선등록
↓
[4] 팩토리 실행
↓
[5] exports 반환
이 흐름은 CommonJS 모듈 시스템의 실행 규칙을 그대로 따른다.
- 캐시 확인 → 모듈의 단일 실행 보장
- 객체 생성 → 실행 결과를 저장할 컨테이너 준비
- 캐시 선등록 → 순환 참조 처리
- 팩토리 실행 → 실제 코드 평가
- exports 반환 → 실행 결과 전달
3) 번들은 실제로 어떻게 실행되는가?
지금까지 우리는 __webpack_require__의 내부 동작을 살펴봤다.
그렇다면 이 알고리즘은 언제부터 동작하기 시작할까?
브라우저가 번들을 로드하는 시점부터 ~ 모듈 실행이 시작되기까지의 흐름을 따라가보자.
그 출발점은 브라우저가 번들을 실행하는 순간이다.
(1) 브라우저는 ‘웹팩’을 모른다
예를 들어 main.js라는 번들 파일이 있다고 가정해보자.
<script src="main.js"></script>
브라우저는 웹팩이라는 개념을 알지 못한다.
단지 하나의 자바스크립트 파일을 실행할 뿐이다.
이 파일이 로드되면 자바스크립트 엔진은 코드 상단부터 순차적으로 실행한다.
그렇다면 번들 파일의 가장 위에는 무엇이 있을까?
(2) 런타임 구조가 먼저 정의된다
번들 상단에는 웹팩이 삽입한 런타임 코드가 위치한다.
var __webpack_modules__ = { ... };
var __webpack_module_cache__ = {};
function __webpack_require__(moduleId) { ... }
브라우저는 이 코드를 일반 자바스크립트처럼 실행한다.
하지만 이 시점에서 아직 어떤 모듈도 실행되지 않았다.
- 모듈은 함수 형태로
__webpack_modules__에 등록되어 있고 - 캐시는 비어 있으며
__webpack_require__는 단지 정의만 되어 있다.
즉, 실제 애플리케이션 로직이 시작되기 전에 모듈 실행 환경이 초기화된다.
(3) 번들 마지막에서 entry 모듈이 호출된다
번들 하단에는 entry 모듈을 실행하는 코드가 위치한다.
// Webpack 4
__webpack_require__(entryModuleId);
// Webpack 5
// __webpack_exec__를 쓰기도 함.
// 단, __webpack_exec__은 내부적으로 __webpack_require__를 사용하므로 동작은 유사함
__webpack_exec__("./src/index.js");
실제 애플리케이션 로직은 entry 모듈이 호출되는 순간부터 시작된다.
이 호출이 이루어지면 다음과 같은 흐름이 전개된다.
entry 모듈 실행
→ 내부에서 __webpack_require__ 호출
→ 의존 모듈 require
→ 의존성 트리 재귀 실행
이 시점부터 __webpack_require__의 5단계 알고리즘이
entry 모듈에서 시작해, 의존성 그래프를 따라 재귀적으로 실행된다.
(4) 정리
번들의 실행 흐름은 다음과 같다.
브라우저가 main.js 실행
↓
런타임 구조 정의
↓
entry 모듈 호출
↓
__webpack_require__ 알고리즘 시작
↓
의존성 트리 재귀 실행
브라우저는 번들 파일을 로드하는 즉시 상단 코드부터 실행한다.
그러나 실제 애플리케이션 로직은 entry 모듈이 호출되는 시점부터 시작된다.
즉, 실행의 출발점은 파일 로드가 아니라 entry 호출이다.
4) 동적 import는 어디에 연결되는가?
앞에서 살펴봤듯이,
- 모든 모듈 실행은
__webpack_require__를 통해 이루어지고 - entry 호출이 실행의 출발점이 된다.
그렇다면 import()는 이 구조에서 어떤 위치를 차지할까?
다음과 같은 코드가 있다고 가정해보자.
import("./dynamic");
겉보기에는 비동기 require처럼 보이지만, 빌드 결과는 다음과 같이 변환된다.
__webpack_require__.e("dynamic")
.then(__webpack_require__.bind(__webpack_require__,"./src/dynamic.js"));
동적 import의 실행은 두 단계로 이루어진다.
- 1단계: 청크 로딩 단계
__webpack_require__.e("dynamic")가 호출되어- 해당 청크 파일을 네트워크로 로드한다.
- 2단계: 모듈 실행 단계
- 청크 로딩이 완료되면
__webpack_require__가 호출되어 해당 모듈을 실행한다. - 즉, 동적 import 역시
__webpack_require__로 연결된다.
- 청크 로딩이 완료되면
(1) 첫 번째 단계 — 청크 로딩
__webpack_require__.e("dynamic")
.e는 지정된 청크를 로드하기 위한 비동기 요청을 시작한다.
즉, 아직 로드되지 않은 청크 파일에 대해 <script> 태그를 생성하고, 브라우저가 해당 파일을 다운로드하도록 한다.
<script src="dynamic.bundle.js"></script>
이 시점에서는 모듈 코드가 실행되지 않는다. 단지 청크 파일을 로드하는 단계다.
(2) 두 번째 단계 — 모듈 실행
청크 로딩이 완료되면 Promise가 resolve되고, 다음 코드가 실행된다.
.then(__webpack_require__.bind(__webpack_require__, "./src/dynamic.js"));
여기서 실제로 호출되는 것은 다음과 같다.
__webpack_require__("./src/dynamic.js");
즉, 동적 import도 최종적으로는 __webpack_require__으로 모듈을 실행한다.
그렇다면 로드된 청크는 어디에 등록될까?
청크 파일 내부에는 다음과 같은 코드가 포함되어 있다.
(self["webpackChunkapp"] = self["webpackChunkapp"] || []).push([
["dynamic"],
{
"./src/dynamic.js": function(module, exports, __webpack_require__) {
...
}
}
]);
겉보기에는 단순한 배열 push처럼 보인다.
하지만 실제로는 다음과 같은 일이 일어난다.
1. push 호출
2. 청크 안에 들어 있던 모듈 정보 전달
3. 해당 모듈이 __webpack_modules__ 객체에 추가됨
즉, 새로 로드된 모듈의 팩토리 함수가 __webpack_modules__ 객체에 그대로 추가된다.
그 이후의 과정은 정적 import와 동일하다.
(3) 전체 실행 흐름
동적 import의 실행 흐름은 다음과 같다.
import()
↓
__webpack_require__.e() // 청크 로딩
↓
청크 파일 실행 (webpackChunk.push)
↓
__webpack_modules__에 모듈 추가
↓
__webpack_require__(moduleId)
↓
기존 require 알고리즘 수행
여기서 주목할 점은 실행 구조 자체는 변하지 않는다는 것이다.
동적 import도 최종적으로는 __webpack_require__를 통해 모듈을 실행한다.
차이가 있다면, 모듈이 등록되는 시점이다.
- 정적 import → 빌드 시점에 모듈이
__webpack_modules__에 포함됨 - 동적 import → 런타임에 청크가 로드되면서 모듈이 추가됨
즉, 동적 import는 모듈을 나중에 등록할 뿐, 실행 방식은 기존 require 알고리즘과 동일하다.
지금까지의 내용을 한 장으로 정리하면 다음과 같다.
2. 지금까지의 내용 정리
(1) 빌드 타임 — 모듈을 함수로 변환하는 단계
웹팩은 빌드 과정에서 다음 작업을 수행한다.
소스 코드 분석
↓
ModuleGraph 생성 (의존성 분석)
↓
ChunkGraph 생성 (출력 전략 결정)
↓
모듈을 팩토리 함수로 변환
↓
__webpack_modules__ 객체 구성
이 시점에서 각 파일은 더 이상 ‘파일’이 아니다.
function(module,exports,__webpack_require__) {
// 원래 코드
}
형태의 팩토리 함수로 변환되어 __webpack_modules__에 등록된다.
(2) 런타임 초기화 — 실행 환경 준비
브라우저가 main.js를 실행하면 먼저 런타임 구조가 정의된다.
__webpack_modules__ 생성
__webpack_module_cache__ 생성
__webpack_require__ 정의
↓
entry 모듈 호출
이때부터 모듈 실행이 시작된다.
(3) require 알고리즘 — 모듈 실행 규칙
__webpack_require__(moduleId)가 호출되면 다음 순서가 적용된다.
[1] 캐시 확인
↓
[2] module 객체 생성
↓
[3] 캐시 선등록
↓
[4] 팩토리 함수 실행
↓
[5] module.exports 반환
이 구조를 통해:
- 모듈은 한 번만 실행되고
- 실행 결과는 공유되며
- 순환 참조가 안전하게 처리된다.
(4) 동적 import — 모듈을 나중에 등록하는 방식
동적 import는 실행 구조를 바꾸지 않는다.
import()
↓
__webpack_require__.e() // 청크 로딩
↓
webpackChunk.push() // 모듈 추가
↓
__webpack_require__() // 기존 알고리즘 실행
차이는 단 하나다.
정적 import는 빌드 시점에 모듈이 등록되고,
동적 import는 런타임에 청크가 로드되면서 모듈이 추가된다.
(5) 전체 구조 한눈에 보기
[빌드 타임]
파일 → 팩토리 함수 → __webpack_modules__ 구성
[런타임]
entry 호출 → __webpack_require__ → 의존성 트리 재귀 실행
[동적 import]
청크 로딩 → 모듈 추가 → 동일 require 알고리즘 적용
3. 마치며…
이번 시간에는 웹팩 번들이 런타임에서 어떻게 실행되는지 알아보았다.
웹팩은 모듈을 파일 그대로 두지 않는다.
빌드 타임에 각 모듈을 팩토리 함수로 변환하고, 런타임에서는 이를 __webpack_require__를 통해 실행한다.
__webpack_require__(moduleId)
이 함수는
- 모듈을 한 번만 실행하고
- 실행 결과를 캐시하며
- 의존성을 재귀적으로 해결한다
동적 import도 예외는 아니다.
청크를 나중에 로드할 뿐, 실행 구조는 동일하다.
결국 번들의 실행 흐름은 하나로 정리된다.
entry 호출
→ require 실행
→ 팩토리 함수 실행
→ module.exports 반환
웹팩 런타임은 이 단순한 구조 위에서 동작하는 걸 알 수 있다.
다음 시간에는 Webpack의 또 다른 핵심 기능인
HMR(Hot Module Replacement)이 어떻게 런타임과 모듈 캐시 위에서 동작하는지 살펴보겠다.
'라이브러리 파헤치기' 카테고리의 다른 글
| [Webpack] 번들링의 원리: 수천 개의 모듈은 어떻게 묶을까? (2) | 2026.02.01 |
|---|---|
| 아코디언(Accordion)에 대해 알아보자 (with. 라이브러리 구조 맛보기) (2) | 2024.02.04 |
댓글