Lab Log
[누가 시키지도 않았는데 번들러 만들기] 4. 소스맵: 번들된 코드에서 원본의 흔적 찾기
4. 소스맵: 번들된 코드에서 원본의 흔적 찾기
"에러 위치: bundle.js 2847번째 줄"
지난 편(Step 3. 번들링과 스코프)에서 우리는 드디어 파일을 하나로 합치는 데 성공했습니다. 수십 개의 파일이 IIFE 안에 질서 정연하게 들어가고, 브라우저에서도 정상적으로 실행됩니다.
그런데 잠깐. 실제 프로젝트에서 이 번들 파일을 배포했다고 상상해 봅시다. 운영 중에 에러가 납니다. 브라우저 콘솔이 이렇게 말합니다.
textUncaught TypeError: Cannot read properties of undefined at bundle.js:2847:23
bundle.js 2847번째 줄. 여러분이 작성한 수십 개의 원본 파일 중 어디에 해당하는 코드일까요? 그 파일의 몇 번째 줄일까요?번들러가 파일을 합치는 과정에서 원본의 행과 열 정보는 완전히 뒤섞입니다. 소스맵이 없다면, 이 에러를 디버깅하는 것은 사실상 불가능에 가깝습니다.
이번 편에서는 소스맵의 원리를 파헤치고, 우리 번들러에 직접 구현해보겠습니다.
1. 소스맵이란: "번들 좌표 → 원본 좌표" 변환표
소스맵을 가장 쉽게 설명하자면 좌표 변환표입니다.
.map 파일 형태로 번들 파일과 함께 배포됩니다.브라우저 DevTools는 이 파일을 읽어서, 에러 위치를 자동으로 원본 파일 기준으로 표시해 줍니다.
실제
.map 파일을 열어보면 이런 모양입니다.json{ "version": 3, "file": "index.js", "sources": ["src/index.js", "src/components/Button.js"], "sourcesContent": ["...", "..."], "mappings": "AAAA,SAAS;AACT,IAAIA..." }
sources는 원본 파일 목록입니다. sourcesContent에는 원본 파일의 코드가 통째로 들어갑니다.구현 코드의
includeContent: true 옵션이 켜지면 채워지는 필드입니다. 소스 파일이 없는 환경에서도 디버깅이 가능해지지만, .map 파일에 소스 코드 전체가 포함되어 파일 크기가 크게 늘고 소스가 그대로 노출된다는 트레이드오프가 있습니다.그리고
mappings는... 외계어처럼 보이는 암호문입니다. 이 mappings 필드가 소스맵의 핵심입니다. 여기에 모든 좌표 변환 정보가 압축되어 있습니다. 이걸 이해하려면 VLQ를 알아야 합니다.2. magic-string의 진짜 목적
우리는 1편부터
magic-string이라는 라이브러리를 써왔습니다. 단순히 "문자열을 덮어쓰는 도구" 로만 알고 있었죠. 그런데 왜 굳이 String.replace()나 정규식을 쓰지 않고 이 라이브러리를 선택했을까요?일반 문자열 치환은 "무엇이 바뀌었는지" 를 기억하지 못합니다. 하지만
magic-string은 모든 변환 작업의 이력을 내부에 쌓아둡니다.typescript// Module.ts (line 198) // import { Button } from './Button.js' → const { Button } = require(1) this.magicString.overwrite(node.start, node.end, replacement); // ^^^^^^^^^^ ^^^^^^^^ // 원본에서 어디부터 어디까지를 바꿨는지 기록!
overwrite(start, end, newText)를 호출할 때마다, magic-string은 내부적으로 이런 기록을 남깁니다."원본 코드의 0번 인덱스부터 32번 인덱스까지가 'const { Button } = require(1);'로 교체되었다"
magic-string은 나중에 "원본의 어느 위치가 결과물의 어느 위치에 해당하는지" 역산할 수 있게 됩니다.3. VLQ: mappings 가 외계어인 이유
이제 그 암호문으로 돌아올 차례입니다.
"mappings": "AAAA,SAAS"
이게 뭘까요? 이건 VLQ(Variable Length Quantity) 인코딩으로 압축된 좌표 데이터입니다.
왜 이렇게 압축하나요?
.map 파일이 번들 파일보다 훨씬 커질 수 있습니다.
VLQ는 두 가지 트릭으로 이를 해결합니다.-
트릭 1. 상대 좌표 절대 좌표(100번째 줄) 대신 이전 항목으로부터의 거리(+2줄)를 저장합니다. 대부분의 연속된 코드는 서로 가깝기 때문에, 숫자가 훨씬 작아집니다.
-
트릭 2. 가변 길이 인코딩 + Base64 작은 숫자(
0)는 1글자(A)로, 큰 숫자는 여러 글자로 표현합니다.Base64(A-Z, a-z, 0-9, +, /)를 사용해 바이너리 없이 텍스트 파일에 저장합니다.
그런데 왜 굳이 알파벳으로 바꾸나요?
A나 S 같은 글자로 바꾸는 걸까요?이유는 단 하나입니다. 쉼표나 마이너스 기호 없이 데이터를 다닥다닥 붙여 써서 용량을 극한으로 줄이기 위해서 입니다.
9,0,0,9 = 7글자가 필요합니다. 숫자가 커져서 15,0,-10,5가 되면 11글자입니다. 소스맵에는 이런 좌표가 수십만 개가 들어갑니다. 쉼표나 마이너스 기호조차도 용량 낭비인 셈입니다.9,0,0,9라는 7글자짜리 데이터가 SAAS라는 딱 4글자로 압축됩니다.A나 S 자체에는 아무런 특별한 의미가 없습니다. Base64라는 전 세계 공통 암호표에 적힌 기호일 뿐입니다.A = 0번, B = 1번, C = 2번 ... S = 18번 ... a = 26번 ...S를 발견하면 "Base64 18번 값이구나!" 하고 읽어들입니다.그 18이라는 숫자를 6비트 이진수로 펼치면 실제 좌표 값이 나오는 구조입니다.
VLQ 비트 구조
암호문을 해독하기 전에, 각 Base64 문자 안에 담긴 6비트의 역할부터 파악해야 합니다.
text[ bit5 | bit4 bit3 bit2 bit1 | bit0 ] ↑ ←—— 값 비트 (4비트) ——→ ↑ 연속 비트 부호 비트 (0=끝, 1=다음 문자도 같은 숫자) (첫 번째 그룹만) (0=양수, 1=음수)
- bit 5 (연속 비트):
1이면 다음 Base64 문자도 같은 숫자의 일부입니다.0이면 이 문자가 마지막입니다. 15보다 큰 값을 여러 글자에 걸쳐 표현할 수 있는 핵심 장치입니다. - bit 4~1 (값 비트): 4비트로 0~15를 담습니다.
- bit 0 (부호 비트): 오직 첫 번째 그룹에만 적용됩니다.
0= 양수,1= 음수. 상대 좌표는 음수가 될 수 있으므로 부호가 필요합니다.
이걸 기차 화물칸으로 비유하면 이렇습니다. 각 Base64 문자는 6칸짜리 기차 화물칸입니다.
- 맨 앞칸(연속 비트): "뒤에 기차칸이 더 연결되어 있나요?" (1이면 연결됨, 0이면 끝)
- 가운데 4칸(값 비트): 실제 짐(숫자)을 싣는 공간 (0~15)
- 맨 뒤칸(부호 비트): "양수인가요, 음수인가요?" (0=양수, 1=음수, 첫 번째 칸에만 적용)
AAAA 직접 해독해보기
mappings의 각 세그먼트는 쉼표(,)로 구분되고, 세미콜론(;)은 번들 파일의 줄 바꿈을 의미합니다.AAAA라는 가장 단순한 세그먼트를 직접 해독해봅시다.'A'는 Base64 값 0 = 000000(2진수)입니다. 비트 구조를 적용하면 bit 5 = 0(연속 없음), bit 4~1 = 0000(값 0), bit 0 = 0(양수)이므로 값은 0입니다.이 4개의 숫자는 각각 다음을 의미합니다.
| 순서 | 의미 | 값 |
|---|---|---|
| 1번째 | 번들 파일 열 | 0 (0번째 열) |
| 2번째 | sources 배열 인덱스 | 0 (첫 번째 파일) |
| 3번째 | 원본 파일 행 | 0 (0번째 줄) |
| 4번째 | 원본 파일 열 | 0 (0번째 열) |
AAAA는 "번들 파일의 0번째 열 → 소스 0번 파일(src/index.js)의 0행 0열" 을 의미합니다.S가 왜 9인지도 추적해봅시다. 'S'는 Base64 값 18 = 010010(2진수)입니다.- bit 5 =
0→ 연속 없음 - bit 4~1 =
1001→ 값 = 9 - bit 0 =
0→ 양수
S는 값 9입니다. SAAS는 [9, 0, 0, 9]이고, 상대 좌표이므로 앞 세그먼트 [0,0,0,0]에 더해 번들 9번째 열 → src/index.js 0행 9열을 가리킵니다.15를 초과하는 값 — 연속 비트 동작 예시
지금까지는 9 이하 값들만 등장했습니다. 첫 번째 그룹의 4비트 값(0~15)으로 충분했기 때문입니다. 값이 16 이상이 되면 처음으로 연속 비트(bit 5)가 실제로 켜집니다.
값 20을 인코딩해봅시다.
- 하위 4비트 =
0100(= 4), 남은 값 =20 >> 4= 1 → 연속 비트 =1 - 부호 =
0(양수) - 첫 번째 문자:
1|0100|0=101000₂= 40 → Base64[40] =o
남은 값 1을 두 번째 문자로:
- 두 번째 문자부터는 부호 비트 없이 5비트 전체가 값 →
0|00001=000001₂= 1 → Base64[1] =B
값 20 = oB (두 글자). 복원 시: 4 + 1 × 16 = 20.
두 번째 글자의 bit 0은 더 이상 부호 비트가 아닙니다. 두 번째 글자부터는 bit 5만 연속 비트로 쓰고, 나머지 5비트(bit 4~0) 전체가 값을 담습니다. 값이 커질수록 세 번째, 네 번째 글자로 이어지는 구조입니다.
상대 좌표의 기준점은 어떻게 정해지나요?
한 가지 궁금증이 남을 수 있습니다. 상대 좌표라면 기준점이 있어야 하는데, 그 기준점은 어떻게 정해지는 걸까요?
답은 간단합니다. 맨 처음 [0, 0, 0, 0]에서 출발해서, 방금 전까지 계산해 둔 최종 위치가 다음 이동의 기준점이 됩니다. 꼬리에 꼬리를 무는 릴레이 달리기입니다.
[+5, +0, +1, -3]이라면?- 직전 도착지점:
[9, 0, 0, 9] - 이동 거리:
[+5, +0, +1, -3] - 새 도착지점:
[14, 0, 1, 6]
이런 식으로 계속 누적됩니다. 외부에서 기준점을 정해주는 것이 아니라, 항상 직전 세그먼트에서 이어받는 구조입니다.
;)이 등장하면 번들 파일에서 다음 줄로 넘어갔다는 뜻입니다. 이때 번들 파일의 열(첫 번째 값)만 0으로 초기화되고, 나머지 원본 파일의 위치(파일 번호, 행, 열)는 그대로 유지됩니다. 번들 파일에서 줄이 바뀌어도 원본 코드 기준으로는 아까 읽던 위치 근처일 가능성이 높기 때문입니다.magic-string이 이걸 대신해 줍니다. 우리가 할 일은 단지 overwrite()로 이력을 남기고, generateMap()을 호출하는 것뿐입니다.4. 구현: Graph.ts와 Module.ts
Graph.ts의 generate() 메서드에서 이루어집니다.Module.ts: 변환 이력 기록
Module 클래스는 생성 시점에 MagicString을 초기화합니다.typescript// Module.ts (line 65) this.magicString = new MagicString(this.content); // 원본 코드 내용을 MagicString으로 감쌉니다. // 이후 모든 변환 작업은 이 객체를 통해 이루어집니다.
transform() 메서드에서 ESM → CJS 변환이 이루어질 때마다 이력이 쌓입니다.typescript// Module.ts (line 198): ImportDeclaration 변환 시 // "import { Button } from './Button.js'" 구간을 교체 this.magicString.overwrite(node.start, node.end, replacement); // node.start, node.end: AST가 알려준 원본 코드의 정확한 위치 // replacement: "const { Button } = require(1);"
magic-string이 "어디가 어떻게 바뀌었는지" 를 기억합니다. 이 두 도구의 역할 분리가 핵심입니다.Graph.ts: 소스맵 통합 생성
generate() 메서드에서 모든 모듈의 이력을 Bundle로 통합하고 소스맵을 생성합니다.typescript// Graph.ts (line 97~) const bundle = new Bundle({ separator: '\n', // 각 모듈 사이의 구분자 }); // 각 모듈을 Bundle에 추가 (line 155~158) bundle.addSource({ filename: path.relative(process.cwd(), module.filePath), // ⭐ 보안 핵심 (아래 설명) content: module.magicString, // 변환 이력이 담긴 객체를 통째로 전달 }); // VLQ 인코딩된 소스맵 생성 (line 178~182) const map = bundle.generateMap({ file: 'index.js.map', // 소스맵 파일명 includeContent: true, // 원본 코드도 .map에 포함 hires: true, // 고해상도 (열 단위 매핑) });
bundle.generateMap()이 호출되는 순간, magic-string은:- 모든
addSource()로 추가된 모듈들의 변환 이력을 수집합니다. - 각 변환 이력을 VLQ 인코딩으로 변환합니다.
mappings문자열을 생성해서.map파일로 저장합니다.
5. 보안: 절대 경로 노출 문제
filename 옵션에 주목하세요. 여기에 무심코 절대 경로를 넣으면 보안 문제가 생깁니다.typescript// ❌ 위험: 절대 경로 그대로 사용 bundle.addSource({ filename: module.filePath, // → "/Users/han/repository/my-company/secret-project/src/index.js" content: module.magicString, });
sources 배열에 이 경로가 그대로 들어갑니다.json{ "sources": ["/Users/han/repository/my-company/secret-project/src/index.js"] }
.map 파일이 프로덕션 서버에 배포된다면:- 개발자의 로컬 시스템 구조가 노출됩니다.
- 프로젝트 내부 경로가 외부에 공개됩니다.
- 보안 감사에서 지적 사항이 될 수 있습니다.
해결책: path.relative()
typescript// ✅ 안전: 프로젝트 루트 기준 상대 경로 사용 (Graph.ts line 156) bundle.addSource({ filename: path.relative(process.cwd(), module.filePath), // → "src/index.js" (프로젝트 루트 기준) content: module.magicString, });
path.relative(process.cwd(), module.filePath)는:process.cwd(): 번들러가 실행되는 디렉토리 (보통 프로젝트 루트)module.filePath: 모듈의 절대 경로- 결과: 프로젝트 루트 기준의 상대 경로
sources 배열에는 깔끔한 상대 경로만 남습니다.json{ "sources": ["src/index.js", "src/components/Button.js"] }
더 큰 위험: 소스 코드 자체의 유출
절대 경로 노출보다 더 치명적인 문제가 있습니다. 소스맵 파일 하나만 있어도 원본 코드를 통째로 복구할 수 있습니다.
includeContent: true로 생성된 소스맵에는 sourcesContent 배열에 원본 파일의 코드가 텍스트 그대로 들어갑니다. 누군가 .map 파일을 다운로드하기만 하면 주석 하나까지 완벽하게 원본을 살려낼 수 있습니다. (reverse-sourcemap 같은 도구가 이를 자동화해 줍니다.)sourcesContent가 없는 경우에도 마찬가지입니다. mappings가 "번들의 이 글자는 원본의 저 변수명이다"를 기억하고 있기 때문에, 번들된 코드를 역추적해서 원래 변수명, 함수명, 파일 구조를 그대로 조립해낼 수 있습니다.그래서 실무에서는 이렇게 대처합니다.
| 전략 | 방법 | 적합한 경우 |
|---|---|---|
| 소스맵 생성 안 함 | 빌드 옵션에서 소스맵 비활성화 | 에러 추적 도구를 쓰지 않는 경우 |
| 배포 서버에 올리지 않음 | .map 파일은 빌드하되 서버 업로드에서 제외 | Sentry 같은 에러 수집 서버에만 업로드 |
| Hidden 소스맵 | JS 파일 맨 아래 //# sourceMappingURL=... 주석만 제거 | 브라우저가 소스맵을 찾지 못하도록 숨김 |
6. 라이브러리 배포와 소스맵 전략
우리가 만들고 있는 것은 웹사이트가 아니라 라이브러리입니다. 라이브러리 배포 맥락에서 소스맵을 어떻게 다뤄야 할지 살펴봅니다.
sourcesContent의 용량 문제
.map 파일의 크기는 번들된 .js 파일보다 2~3배 더 큽니다. 원본 코드가 텍스트로 전부 들어가기 때문입니다.그런데도 이 거대한 파일을 만드는 이유는, 일반 사용자는 이 파일을 다운로드하지 않기 때문입니다.
- 일반 사용자: 웹사이트에 접속하면
bundle.js만 다운로드합니다..map파일은 전혀 신경 쓰지 않습니다. - 개발자(디버깅): F12를 눌러 DevTools를 여는 순간, 브라우저가
//# sourceMappingURL=...주석을 보고 그때서야.map파일을 백그라운드에서 다운로드하기 시작합니다.
사용자들의 로딩 속도에는 전혀 영향을 주지 않습니다.
라이브러리 사용자는 원본 코드가 없습니다
dist/ 폴더만 올립니다. 라이브러리 사용자의 node_modules/my-lib/ 안에는 dist/index.js와 dist/index.js.map만 존재합니다. 원본이 담긴 src/ 폴더는 없습니다.sourcesContent가 없다면, 라이브러리 사용자가 디버깅하려고 에러를 클릭해도 브라우저는 원본 코드 파일을 찾지 못합니다. sourcesContent에 원본 코드를 통째로 넣어줘야만 라이브러리 사용자의 DevTools에서 원본 코드를 볼 수 있습니다.소스맵 체이닝
node_modules/my-lib/dist/index.js를 가져와 자신의 앱에 합칠 때, 우리의 index.js.map을 발견하고 자신의 소스맵과 연결합니다.src/components/Button.js 15번째 줄에서 났네요!" 라고 정확히 짚어줍니다.어떤 전략을 선택해야 할까요?
| 상황 | 권장 전략 |
|---|---|
| 오픈소스 라이브러리 | includeContent: true로 소스맵 포함 배포. 어차피 코드가 공개되어 있고 DX가 압도적으로 좋아집니다. |
| 사내용/비공개 라이브러리 | 소스맵을 npm에 올리지 않거나, sourcesContent 없이 배포. 에러 수집 서버(Sentry 등)에만 소스맵을 별도 업로드합니다. |
소스맵을 아예 배포하지 않으면? 라이브러리를 설치한 개발자가 에러를 만났을 때, 글 맨 첫 줄의 그 상황이 그대로 재현됩니다.
textUncaught TypeError: Cannot read properties of undefined at node_modules/my-lib/dist/index.js:2847:23
디버깅을 포기하고 GitHub 이슈 탭에 불친절한 리포트를 남기게 됩니다.
7. 결과물 확인
이론은 충분히 다뤘습니다. 실제로 빌드해서 소스맵이 제대로 생성되는지 확인해봅시다.
bash$ pnpm --filter @package/sample-lib run build 📦 Minibundler started... 📂 Processing: /Users/han/.../src/index.js 📂 Processing: /Users/han/.../src/components/Button.js 🛠️ Generating bundle... 📦 Generated CJS Bundle: dist/index.js 🗺️ Generated SourceMap: dist/index.js.map ✨ Generated Standalone ESM: dist/index.mjs ✅ Bundle built successfully!
dist/ 디렉토리에 세 개의 파일이 생성됩니다.textdist/ ├── index.js ← CJS 번들 (맨 아래에 sourceMappingURL 주석 포함) ├── index.js.map ← 소스맵 └── index.mjs ← ESM 번들
index.js 맨 마지막 줄을 확인해보면 소스맵 참조 주석이 붙어 있습니다.javascript// dist/index.js 마지막 줄 //# sourceMappingURL=index.js.map
브라우저와 Node.js는 이 주석을 읽고 자동으로 소스맵을 불러옵니다.
왜 JS 파일명과 똑같이 만들까요? 다르면 안 되나요?
index.js → index.js.map. 이 패턴이 너무 당연하게 느껴지지 않나요?사실 이름은 달라도 됩니다. JS 파일과 소스맵 파일을 연결하는 건 파일명이 아니라, JS 파일 맨 아래의 주석 한 줄입니다.
javascript//# sourceMappingURL=index.js.map // ^^^^^^^^^^^^ // 여기에 적힌 경로로 소스맵을 찾아감
브라우저는 오직 이 주석만 봅니다. 따라서 이렇게 써도 동작합니다.
javascript//# sourceMappingURL=debug-info.map // 완전히 다른 이름 //# sourceMappingURL=sourcemaps/main.map // 하위 디렉토리 //# sourceMappingURL=data:application/json;base64,eyJ2Z... // 파일 없이 인라인으로 embed
index.js.map이 세 군데 등장합니다.typescript// Graph.ts (line 178~192): 세 곳이 모두 맞물려야 합니다 const map = bundle.generateMap({ file: 'index.js.map', // ① 소스맵 내부의 자기 파일명 ... }); fs.writeFileSync(path.join(distDir, 'index.js.map'), map.toString()); // ② 실제 저장 파일명 fs.appendFileSync( path.join(distDir, 'index.js'), '\n//# sourceMappingURL=index.js.map', // ③ JS 파일에 심는 참조 경로 );
index.js.map을 찾으려다 404를 냅니다.<파일명>.map 관행이 굳어진 이유는 실용적인 이유 때문입니다.| 이유 | 설명 |
|---|---|
| 가독성 | main.js.map, vendor.js.map처럼 번들이 여러 개일 때 어떤 맵인지 이름만 봐도 알 수 있다 |
| 일괄 처리 | 배포 스크립트에서 *.map 패턴으로 소스맵만 골라 서버에서 제외하거나 별도 보관하기 쉽다 |
| 도구 호환성 | 일부 도구가 <name>.map 패턴을 기반으로 소스맵을 자동 탐색한다 |
결론적으로, 파일명은 관행이지 규칙이 아닙니다. 세 곳을 일관되게 맞추기만 하면 어떤 이름이든 동작합니다.
sources 상대 경로 확인
소스맵에 절대 경로가 포함되지 않았는지 확인해봅시다.
bashnode -e " const fs = require('node:fs'); const map = JSON.parse(fs.readFileSync('packages/@package/sample-lib/dist/index.js.map', 'utf-8')); console.log('sources:', map.sources); "
정상이라면 아래처럼 상대 경로만 출력됩니다.
bashsources: [ 'src/index.js', 'src/components/Button.js' ]
/Users/han/...)가 보인다면 path.relative() 처리가 빠진 것입니다.8. 마무리
이번 편에서 우리가 만든 것을 정리해봅시다.
- Module 생성 시 —
new MagicString(content)으로 원본 코드를 변환 이력 저장소에 담습니다. - ESM → CJS 변환 시 —
magicString.overwrite(start, end, replacement)로 "원본 N번째 ~ M번째 자리가 새 코드로 바뀌었다"를 기록합니다. - 번들 조합 시 —
bundle.addSource({ filename: 상대경로, content: magicString })으로 각 모듈의 이력을 통합합니다. - 소스맵 생성 시 —
bundle.generateMap({ includeContent: true, hires: true })로 이력을 VLQ 인코딩 →mappings문자열 →.map파일로 저장합니다.
MagicString을 소개했을 때, 단순한 문자열 치환 도구처럼 보였습니다. 하지만 이제는 그 진짜 목적이 보입니다. 변환 이력을 기록하기 위한 장치였던 것입니다. 소스맵을 위해 처음부터 설계된 선택이었습니다.에필로그: 여기서 더 나아간다면
acorn으로 AST를 파싱하고, 모듈 그래프를 따라 의존성을 추적하고, MagicString으로 코드를 합치고, 소스맵으로 원본을 추적하는 것까지.실제 번들러들은 여기에 더 많은 것을 올려놓습니다.
- Externals —
react같은 peer dependency를 번들에서 제외하고 런타임에 외부에서 주입받는 전략. 라이브러리 배포에 필수적입니다. - Tree Shaking — ESM의 정적 구조를 분석해 실제로 사용되지 않는 export를 번들에서 제거합니다.
import { one } from './math'만 썼다면two,three는 번들에 들어가지 않습니다. - Code Splitting — 번들을 여러 청크로 나눠 초기 로딩에 필요한 코드만 먼저 내려받게 합니다. 동적
import()가 분기점이 됩니다. - Plugin System — webpack의 탭어블 훅, rollup의 플러그인 API처럼, 번들링 파이프라인의 각 단계에 외부 코드가 끼어들 수 있는 확장 지점을 만드는 구조입니다.
- Scope Hoisting — 모듈 경계를 제거해 함수 래퍼를 줄이고 런타임 오버헤드를 낮춥니다.
- HMR — 전체 페이지 리로드 없이 변경된 모듈만 런타임에 교체합니다.
이 중 어느 것을 열어봐도, 우리가 Step 1부터 다뤄온 개념들이 그 안에 있습니다.
acorn, AST, 모듈 그래프, 코드 생성. 실제 도구의 코드베이스가 낯설지 않을 겁니다. rollup이 비교적 읽기 쉽고, esbuild는 성능 관점에서 흥미롭습니다.
체크리스트 (Step 4)
- 소스맵이 "번들 좌표 → 원본 좌표" 변환표라는 개념을 이해했다.
MagicString.overwrite()가 단순 치환이 아니라 이력 기록임을 이해했다.Bundle.addSource()→Bundle.generateMap()흐름을 설명할 수 있다.AAAAVLQ 세그먼트가[0, 0, 0, 0]을 의미함을 이해했다.- VLQ가 숫자 대신 Base64 문자를 쓰는 이유(용량 절감)를 설명할 수 있다.
- 상대 좌표가
[0,0,0,0]에서 출발해 누적되는 릴레이 방식임을 이해했다. - 소스맵을 통한 원본 코드 복구 가능성과 보안 위험을 설명할 수 있다.
- 오픈소스/비공개 라이브러리의 소스맵 배포 전략 차이를 설명할 수 있다.
path.relative()를 써야 하는 보안 이유를 설명할 수 있다.dist/index.js.map의sources가 상대 경로인지 직접 확인했다.