[누가 시키지도 않았는데 번들러 만들기] 2. 코드를 데이터로 보는 법 (AST Graph)
2. 코드를 데이터로 보는 법 (AST Graph)
"코드는 텍스트가 아니다. 트리다."
magic-string이라는 도구를 맛봤습니다. 이제 본격적으로 코드를 분석하고, 여러 파일을 하나로 묶기 위한 준비를 시작해 봅시다.이번 편의 목표는 "흩어진 파일들을 찾아내어 하나의 지도로 그리는 것" 입니다.
1. 어디서부터 묶어야 할까?
번들러가 가장 먼저 해야 할 일은 무엇일까요? 바로 "진입점" 을 찾는 것입니다.
index.js나 main.js) 을 읽고, 그 파일이 어떤 다른 파일들을 필요로 하는지(import) 추적해 나가야 합니다. 이것이 바로 의존성 그래프를 그리기 위한 첫 단추입니다.2. 코드는 단순한 텍스트가 아니다
import ... from ... 구문을 찾아내야 합니다.왜 정규표현식을 썼을까?
문자열에서 특정 패턴을 찾는 것. 개발자라면 누구나 가장 먼저 떠올리는 도구가 있죠. 바로 정규표현식 입니다.
import 문은 꽤 단순한 패턴을 가지고 있어 보입니다.javascriptimport { a } from './a.js'; import b from './b.js';
"어? 이거 그냥 문자열 검색으로 찾으면 되는 거 아냐?"
String.match() 한 방이면 끝날 것 같았죠.javascriptconst regex = /import\s+.*\s+from\s+['"](.*)['"];?/g; const code = "import { a } from './a.js';"; const result = code.match(regex); // 오! 찾아졌다! ./a.js
3. 정규표현식은 파서가 아니다
예외 케이스
하지만 코드가 조금만 복잡해져도 정규표현식은 무력해집니다.
javascript// 1. 주석 처리된 코드 // import { a } from './a.js'; // 2. 문자열 내부의 텍스트 const message = "Please import './config.js' first."; // 3. 줄바꿈이 포함된 import import { somethingLong } from './utils.js';
import 하려고 시도하다가 에러를 뿜어내는 번들러를 마주하게 됩니다.결론: 코드를 단순한 텍스트로 처리하려다간 큰코다칩니다. 구조로 봐야 합니다.
4. 도구 1: AST - 코드를 해부하다
그래서 등장하는 도구가 바로 AST(Abstract Syntax Tree, 추상 구문 트리) 입니다.
이름을 하나씩 뜯어보면 이렇습니다.
- Abstract: 세미콜론, 괄호, 공백 같은 표면적인 문법 요소는 버리고, 의미만 남긴다는 뜻입니다.
- Syntax: 코드의 문법적 구조 — 변수 선언인지, 함수 호출인지, import 문인지를 구분합니다.
- Tree: 결과물이 부모-자식 관계를 가진 트리 구조라는 뜻입니다.
결국 AST란, 우리가 작성한 코드를 컴퓨터가 이해하기 쉬운 트리 형태의 데이터 구조로 바꿔놓은 것입니다.
사실 AST는 번들러만의 도구가 아닙니다. ESLint가 코드 규칙을 검사할 때, Prettier가 코드를 포맷팅할 때, Babel이 최신 문법을 변환할 때 — 모두 AST를 기반으로 동작합니다. 이번 편에서 AST를 다루는 관점을 익히면, 이 도구들이 내부에서 뭘 하는지도 보이기 시작합니다.
acorn을 사용하면 코드를 다음과 같이 분석해줍니다.코드가 데이터가 되는 과정
javascript// 입력 코드 import { name } from './name.js';
이 한 줄의 코드는 파서를 거치면 거대한 JSON 객체가 됩니다. 핵심만 요약했습니다.
json{ "type": "Program", "body": [ { "type": "ImportDeclaration", // "이건 import 선언문이야!" "source": { "type": "Literal", "value": "./name.js" // "경로는 이거야!" }, "specifiers": [ { "type": "ImportSpecifier", "imported": { "name": "name" } // "name을 가져왔어!" } ] } ] }
type === 'ImportDeclaration'인 노드만 골라내면 됩니다. 주석이나 문자열은 ImportDeclaration이 아니므로 자연스럽게 무시됩니다. 정확도 100%의 import 탐지기가 생긴 셈입니다.Code Module.ts 구현
Module 클래스는 이렇게 AST를 생성합니다.typescript// packages/@package/bundler/src/Module.ts import * as fs from 'fs'; import * as acorn from 'acorn'; export class Module { constructor(filePath: string) { this.content = fs.readFileSync(filePath, 'utf-8'); // 1. 코드를 AST로 변환 this.ast = acorn.parse(this.content, { ecmaVersion: 'latest', sourceType: 'module', }); } init() { // 2. AST를 순회하며 import 찾기 this.ast.body.forEach(node => { if (node.type === 'ImportDeclaration' && typeof node.source.value === 'string') { // 찾았다! 의존성 추가 this.dependencies.push(node.source.value); } }); } }
전체 코드 보기: Module.ts GitHub
5. 도구 2: Resolve - 진짜 파일 찾기
'./utils'라는 문자열을 찾아냈습니다. 하지만 이건 파일 경로의 힌트일 뿐, 실제 파일 시스템상의 위치는 아닙니다../utils가utils.js인가?utils.ts인가?- 아니면
utils/index.js인가?
이 힌트를 가지고 실제 파일을 찾아내는 과정을 Resolve라고 합니다. Node.js는 꽤 복잡한 Resolve 규칙을 가지고 있지만, 우리는 핵심적인 부분만 구현해 봅시다.
Code 확장자 추론하기
typescript// packages/@package/bundler/src/Graph.ts resolve(importPath: string, importer: string): string { // 1. 현재 파일(importer)이 있는 폴더 기준 const baseDir = path.dirname(importer); let fullPath = path.resolve(baseDir, importPath); // 2. 확장자가 없으면 .js 붙여보기 (간단한 버전) // (실제로는 .mjs, .json, 디렉토리 내 index.js 등도 고려해야 합니다) if (!fullPath.endsWith('.js')) { fullPath += '.js'; } return fullPath; }
'./utils'를 요청하면 /Users/.../src/utils.js라는 절대 경로를 얻을 수 있습니다.6. 도구 3: DFS & Graph - 지도를 그리자
이제 재료는 다 모였습니다.
- 파일 내용을 읽어서
fs import문을 찾아내고AST- 그 파일의 절대 경로를 알아냈습니다.
Resolve
index.js에서 시작해서 꼬리에 꼬리를 물고 찾아 들어가는 방식, 바로 DFS 깊이 우선 탐색입니다.그래프 구축 과정 Graph.ts
Graph 클래스는 Map 자료구조를 사용하여 전체 파일들의 관계를 저장합니다.typescript// packages/@package/bundler/src/Graph.ts export class Graph { // 방문한 모듈을 저장하는 저장소 (Visited Map) modules = new Map<string, Module>(); createModule(filePath: string): Module { // 1. 모듈 생성 및 AST 분석 const module = new Module(this.nextId++, filePath); module.init(); // import 구문 추출 // 2. 저장소에 등록 this.modules.set(filePath, module); // 3. 의존성 재귀 탐색 (DFS) module.dependencies.forEach(importPath => { const absolutePath = this.resolve(importPath, filePath); const childModule = this.createModule(absolutePath); // 4. 부모-자식 연결 (Mapping) module.mapping.set(importPath, childModule.id); }); return module; } }
index.js 하나만 넣어도 연결된 모든 파일이 modules 맵에 담기게 됩니다. 파일 시스템이라는 거대한 미로를 탐험해서 지도를 완성하는 것이죠.7. 하이라이트: "순환 참조"
하지만 DFS에는 치명적인 약점이 있습니다. 바로 순환 참조입니다.
A -> B -> A 무한 루프
a.js가 b.js를 부르고, b.js가 다시 a.js를 부른다면 번들러 내부에서는 호출 스택 이 계속 쌓이게 됩니다.이 과정을 시퀀스 다이어그램 으로 보면, 함수가 끝나지 않고 계속 깊어지는 것을 확인할 수 있습니다.
결국 Stack Overflow가 발생하며 번들러는 장렬히 전사합니다.
해결책: 방명록 Visited Map
Graph 클래스에 만들어둔 modules 맵이 바로 그 방명록 역할을 합니다.typescriptcreateModule(filePath: string): Module { // 0. 방명록 확인: 이미 왔던 파일이면, 기존 모듈 반환하고 끝! if (this.modules.has(filePath)) { return this.modules.get(filePath)!; } // ... (새 모듈 생성 로직) }
이 간단한 체크 하나로 번들러는 무한 루프의 늪에서 탈출할 수 있습니다.
실전 bundler-playground 예제
packages/@package/bundler-playground 에는 a.js -> c.js -> a.js 순환 참조 케이스가 들어있습니다.javascript// a.js import { c } from './c.js'; // c.js import { a } from './a.js'; // 범인!
c.js를 처리할 때 a.js를 다시 만나지만, modules.has() 덕분에 쿨하게 지나갑니다. 덕분에 그래프는 안전하게 완성됩니다.8. 에필로그: 이제 지도는 완성됐다
우리는 이제 코드를 텍스트가 아닌 데이터 AST로 바라보게 되었습니다. 그리고 그 데이터를 바탕으로 파일들의 복잡한 관계를 그래프 Graph로 그려냈습니다.
하지만 아직 파일들은 제각각 떨어져 있습니다. 다음 단계에서는 이 그래프를 바탕으로 파일들을 하나의 파일로 합치는 Bundling 과정을 다룹니다.
a.js의 const name과 b.js의 const name이 만나면 어떻게 될까요?다음 편, Step 3. 번들링과 스코프 격리에서 계속됩니다.
🔗 참고 자료
- 전체 소스 코드 Graph.ts
- AST Explorer AST 구조를 눈으로 확인해보세요!