2. 기술 스택과 설계 철학
Zig 0.15.2 — "왜 Zig인가"
트랜스파일러에 Zig가 필요한 이유
트랜스파일러는 소스 코드를 입력받아 다른 소스 코드를 출력하는 프로그램이다. 수십만 줄의 코드를 밀리초 단위로 처리해야 하므로, 언어 선택이 성능에 직접적인 영향을 미친다.
기존 도구들의 언어:
- esbuild: Go — goroutine 병렬 처리, GC 오버헤드 있지만 개발 속도 빠름
- SWC: Rust — 안전성 보장, 매크로 강력, 학습 곡선 높음
- oxc: Rust — SWC보다 최적화에 집중, arena allocator 적극 활용
- Bun: Zig — C/C++ 수준 성능, SIMD 직접 제어, WASM 네이티브
Zig를 선택한 이유
"zig로 swc 같은걸 만들면 어떤 이득이 있을까요?" — 3/18 최초 질문 "설계를 어떻게 해야 oxc보다 앞설 수 있죠? 번은 앞서나요?" — 성능 목표
-
SIMD
@Vector직접 제어: Zig는 SIMD 벡터 연산을 언어 수준에서 지원한다. 렉서에서 공백 스킵, 식별자 스캔을 16바이트씩 한 번에 처리할 수 있다. Rust에서는packed_simd크레이트가 필요하지만 Zig는 내장이다. -
comptime(컴파일 타임 실행): Zig의 가장 강력한 기능.
comptime파라미터로 JS/JSX/TS/TSX 4개의 파서를 컴파일 타임에 각각 생성한다. 런타임에 "지금 TS 모드인가?" 같은 분기가 전혀 없다. Rust의 제네릭/트레잇으로도 비슷하게 할 수 있지만, Zig의 comptime은 임의의 코드를 실행할 수 있어 더 유연하다. -
C/C++ 직접 링크:
@cImport로 C/C++ 라이브러리를 직접 링크할 수 있다. 이것은 Flow 지원에 핵심적이다 — Hermes(Meta의 Flow 파서, C++ 128개 파일)를 별도 빌드 시스템 없이build.zig로 직접 컴파일할 수 있다. -
WASM 빌드 네이티브:
zig build -Dtarget=wasm32-wasi로 별도 툴체인 없이 WASM 바이너리를 생성한다. 브라우저에서 트랜스파일러를 실행하거나, Node.js NAPI 바인딩 없이 WASM으로 배포할 수 있다. -
수동 메모리 관리 + errdefer: GC 없이 Arena allocator로 메모리를 관리하되,
errdefer로 에러 경로에서의 메모리 해제를 보장한다. Rust의 RAII와 비슷하지만 더 명시적이다.
"그리고 그렇게 했을때 속도는 얼마나, 안정성은 얼마나 장단점을 가질 수 있을까? 설계가 아니라 언어레벨에서는?" — 3/18
처음에는 Zig 0.14.0으로 시작했고, 3월 21일에 0.15.2로 업그레이드:
"지그 버전업 진행해주세요 최신버전으로" — 3/21
메모리 전략: Arena + Index 기반 AST (D004)
Arena Allocator — 왜 필요한가
트랜스파일러는 "파싱 → 변환 → 출력" 단계를 거친다. 각 단계에서 수만 개의 AST 노드, 문자열, 심볼 테이블을 할당한다. 일반적인 malloc/free로는 할당/해제 오버헤드가 크고, 메모리 단편화가 발생한다.
Arena allocator는 큰 메모리 블록을 미리 잡아두고, 그 안에서 순차적으로 할당만 한다. 해제는 단계가 끝날 때 한 번에 한다. 개별 free가 없으므로:
- 할당이 O(1) (포인터만 이동)
- 해제가 O(1) (블록 전체를 한 번에)
- 메모리 단편화 없음
errdefer누락으로 인한 메모리 누수 원천 차단 (Arena가 알아서 해제)
24바이트 고정 노드 — 왜 고정 크기인가
모든 AST 노드가 정확히 24바이트. 가변 크기 노드 대신 고정 크기를 선택한 이유:
- 배열 인덱스 접근:
nodes[i]로 O(1) 접근. 가변 크기면 오프셋 테이블이 필요 - 캐시 라인 최적화: 64바이트 캐시 라인에 2~3개 노드가 들어감. 트리 순회 시 캐시 히트율 극대화
- 인덱스 기반 참조: 포인터(8바이트) 대신
NodeIndex(u32, 4바이트)로 참조. use-after-free 원천 차단 + 메모리 절약 - 제로 코스트 변환: Cover Grammar에서 expression→pattern 변환 시 새 노드 할당 없이
setTag로 태그만 교체
16바이트 data 영역에 들어가지 않는 추가 정보는 extra_data 배열에 저장한다 (3/22에 D082에서 결정).
"24, 32로 갔을때 차이점 및 성능 트레이드오프 다 말해줘" — 3/18 "근데 bun에는 지나?" — Bun도 24바이트 "24바이트로 가시죠" — 최종 결정
이 결정은 프로젝트 전체에서 한 번도 변경하지 않았다. 3/22에 extra_data vs 노드 플래그 논쟁(40개+ 메시지)이 있었지만, 24바이트 노드 자체는 흔들리지 않았다.
@panic("OOM") → 에러 전파 (3/20)
초기에는 메모리 할당 실패 시 @panic("OOM")으로 처리했다 (Zig 생태계 일반 패턴). 3/20에 90개 이상의 @panic("OOM")을 에러 전파로 교체:
"나머지 스킵한 항목들도 언젠간해야되는건지? 걍유지해도 되는지 궁금해" "하면 좋은건 하자" — 선제적 개선
렉서: SIMD 가속 (3/25 추가)
렉서란?
렉서(Lexer/Tokenizer)는 소스 코드 텍스트를 토큰 스트림으로 변환하는 첫 번째 단계다. const x = 42;를 [kw_const, identifier("x"), =, number(42), ;]로 변환한다.
렉서가 처리해야 하는 것들:
- 공백/탭/줄바꿈 스킵
- 식별자 스캔 (
myVariable,async,yield) - 숫자 리터럴 (정수, 소수, 2진수, 8진수, 16진수, BigInt — 11종)
- 문자열 리터럴 (escape sequence, template literal 중첩)
- 정규식 리터럴 (나누기 연산자
/와 구별) - 주석 (
//,/* */,@__PURE__추적) - JSX 모드 (HTML-like 토큰)
SIMD로 공백 스킵
소스 코드의 상당 부분은 공백이다. SIMD로 16바이트(16문자)를 한 번에 비교하면 공백 스킵이 16배 빨라진다:
약 208개의 토큰 종류를 u8 플랫 enum으로 관리한다 (oxc 방식). SIMD는 3/25에 성능 최적화 단계에서 추가됐다.
파서: comptime 제네릭
파서란?
파서(Parser)는 토큰 스트림을 **AST(추상 구문 트리)**로 변환한다. const x = 1 + 2;가 VariableDeclaration(name="x", init=BinaryExpression("+", 1, 2))라는 트리 구조가 된다.
JS/TS 파서가 어려운 이유:
- ECMAScript 스펙의 복잡성: Cover Grammar (expression→pattern 재해석), Automatic Semicolon Insertion (세미콜론 자동 삽입), 컨텍스트에 따라 의미가 달라지는 키워드 (
yield,await,let) - TypeScript 확장: 제네릭
<T>vs JSX<Component>모호성, 타입 어노테이션, 데코레이터 - JSX 확장: HTML-like 구문이 JS 안에 혼재
comptime으로 4개 파서 자동 생성
컴파일 결과물은 4개의 독립적인 파서 바이너리다. .ts 파일을 파싱할 때 JSX 관련 코드가 바이너리에 아예 존재하지 않는다. 이것이 Zig comptime의 위력이다.
RegExp 파서도 동일한 패턴:
"C로 가자 이런건 좀 크더라도 유지보수나 안정성을 생각해야해" — 3/19, RegExp 풀 파서 선택
Context: packed struct(u8)
파서 컨텍스트란?
JS 파서는 "지금 어디에 있는가"를 추적해야 한다. yield는 generator 함수 안에서만 키워드이고, 밖에서는 일반 식별자다. await도 async 함수 안에서만 키워드다. in 키워드는 for-in 루프의 초기화식에서는 허용되지 않는다.
이 정보를 파서 컨텍스트라고 하며, 1바이트로 추적:
함수 경계에서 save/restore가 단일 u8 복사로 끝난다. 이 리팩터링은 3/19에 수행:
"Context bitflags 리팩터링 시작해줘" — 3/19 세 번째 세션
번들러 아키텍처
번들러란?
번들러는 여러 모듈(파일)을 하나 또는 소수의 파일로 합친다. 브라우저는 수백 개의 HTTP 요청을 보내는 것보다 하나의 큰 파일을 로드하는 것이 빠르다.
번들러가 해야 하는 일:
- 모듈 해석(Resolve):
import lodash from 'lodash'→node_modules/lodash/index.js경로 찾기 - 의존성 그래프 구축: 모든 import/require를 따라가며 트리 구조 생성
- Scope Hoisting: 여러 모듈의 코드를 하나의 스코프로 합치기 (변수 이름 충돌 해결 필요)
- Tree-Shaking: 사용하지 않는 export 제거 (번들 크기 절감)
- Code Splitting: 공유 모듈을 별도 청크로 분리 (동적 import 지원)
- CJS/ESM 상호운용: CommonJS(
require)와 ES Module(import)을 함께 처리
esbuild + oxc의 하이브리드:
"처음부터 호이스팅 하는게 낫지 않아요? 어차피 거기로 갈건데 중간 과정을 왜" — 3/21 "어 HMR 고려하고 있어서 양방향으로 가자" — 3/21, 모듈 그래프 양방향 결정
테스트 전략
5단계 테스트 피라미드
각 레벨의 역할이 다르다:
- Test262는 "파서가
for (let x of y)...를 올바르게 파싱하는가"를 검증 - 적합성은 "TS의
enum Color { Red }가 올바른 JS IIFE로 변환되는가"를 검증 - 스모크는 "lodash-es를 번들링한 결과가
node로 실행하면 정상 동작하는가"를 검증 - E2E는 "번들 결과물을
<script>태그로 넣으면 브라우저에서 에러 없이 로드되는가"를 검증
"왜 95? 100% 통과 목표가 아닌가요?" — 3/18, 최초 Test262 목표 설정 "테스트 먼저 작성하고, 코드 고쳐서 통과시켜" — 3/22, TDD 요구 "실제 실행도 해보면서 통과했다고 확인한거야?" — 3/23, 스모크 테스트 검증 "그정도로 실제 잘됐다고 보증할 수 있어??" — 테스트 충분성 의문
의사결정 문서화: D001~D082
모든 설계 결정에 번호를 매겼다. 82개.
각 결정에는 선택지, 트레이드오프, 최종 선택 이유가 기록되어 있다. 이 방식은 "왜 이렇게 구현했지?"하는 미래의 질문에 대한 답변을 미리 남겨두는 것이다.
"다시 한번 정리 깔끔하게 해줄래? 왜 선택했는지 어떤 번들러가 그렇게 하고 있는지 등" — 3/21, 모든 결정에 근거 요구