2. 기술 스택과 설계 철학
초기 선택과 대화는 2026년 3월의 기록이다. 구현 설명은 9월 26일 공개 main의 80042462와 대조했다. 아래 초기 테스트 성적은 당시 측정이며, 최신 실행 결과가 아니다.
Zig — "왜 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는 벡터 연산을 언어 수준에서 지원한다. 렉서에서 공백 스킵, 식별자 스캔을 여러 바이트씩 처리하는 코드를 표현할 수 있다. -
comptime(컴파일 타임 실행): 타입과 테이블을 빌드 시점에 구성하고 고정 조건을 검사할 수 있다. 다만 현재 JS/JSX/TS/TSX 처리는 아래 설명처럼 단일 파서의 모드 설정을 사용한다.
-
C/C++ 연동: Zig 빌드에서 C/C++ 소스와 라이브러리를 함께 다룰 수 있다. 초기에는 Hermes를 Flow 지원의 참고 대상으로 조사했다. 현재 Flow 타입 파싱은 Zig의
src/parser/flow.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
9월 26일 현재 CI의 빌드 도구 버전은 0.16.0이다. 저장소 README와 build.zig.zon에는 0.15.2가 남아 있어, 여기서는 실제 CI 설정을 기준으로 구분한다.
메모리 전략: Arena + Index 기반 AST (D004)
Arena Allocator — 왜 필요한가
트랜스파일러는 "파싱 → 변환 → 출력" 단계를 거친다. 각 단계에서 수만 개의 AST 노드, 문자열, 심볼 테이블을 할당한다. 일반적인 malloc/free로는 할당/해제 오버헤드가 크고, 메모리 단편화가 발생한다.
Arena allocator는 확보한 블록 안에서 순차적으로 할당하고, 객체별 free 대신 arena 수명 끝에 블록들을 함께 반납한다. 남은 공간에서의 할당은 포인터 이동으로 처리하지만, 공간이 모자라면 새 블록을 확보해야 한다. 개별 객체의 해제를 추적하는 부담은 줄어도 arena 자체의 해제와 외부 자원 정리는 여전히 필요하다.
AST는 노드 배열과 그 배열을 가리키는 NodeIndex로 참조를 표현한다. 메모리 수명은 arena가, 노드 관계는 인덱스가 맡는 구조다.
24바이트 고정 노드 — 왜 고정 크기인가
모든 AST 노드가 정확히 24바이트. 가변 크기 노드 대신 고정 크기를 선택한 이유:
- 배열 인덱스 접근:
nodes[i]로 O(1) 접근. 가변 크기면 오프셋 테이블이 필요 - 밀집 저장: 24바이트 노드 두 개는 48바이트다. 고정 크기로 연속 저장하지만 실제 캐시 효율은 정렬과 접근 패턴에 따라 달라진다
- 인덱스 기반 참조: 포인터 대신
NodeIndex(u32, 4바이트)로 참조한다. 노드 배열의 재할당으로 주소가 바뀌어도 인덱스를 유지할 수 있지만, 배열의 수명과 인덱스 유효성 검사는 필요하다 - 제로 코스트 변환: Cover Grammar에서 expression→pattern 변환 시 새 노드 할당 없이
setTag로 태그만 교체
현재 Node 정의는 tag·span·data로 구성되며, 컴파일 시점에 @sizeOf(Node) == 24를 검사한다. 인라인 data에 들어가지 않는 추가 정보는 extra_data 배열에 저장한다. 초기 main_token 중심 설명과 현재 필드 배치는 다르지만 24바이트 크기는 유지된다.
"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로 공백 스킵
소스 코드의 상당 부분은 공백이다. 다음은 16바이트를 벡터로 비교하는 방식을 설명한 예시다. 한 번에 비교하는 바이트 수가 실제 처리 속도의 배수를 뜻하지는 않는다:
약 208개의 토큰 종류를 u8 플랫 enum으로 관리한다 (oxc 방식). SIMD는 3/25에 성능 최적화 단계에서 추가됐다.
파서: 하나의 구현과 명시적 모드
파서란?
파서(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 안에 혼재
현재 구현의 입력 모드
9월 26일의 Parser는 하나의 struct다. SourceMode로 명시적 JavaScript·TypeScript·기존 관대한 JS 모드를 나누고, is_jsx와 is_flow를 별도로 둔다. 확장자와 loader 설정을 받아 이 모드를 구성한다.
초기 설계 설명에 적었던 "네 개의 독립 파서 바이너리"와 "런타임 모드 분기 없음"은 현재 코드의 구조가 아니다. JS·JSX·TS·TSX를 지원하는 것과 구현을 네 벌로 특수화하는 것은 구분해야 한다.
RegExp를 별도 파서로 다루려는 초기 판단도 같은 맥락이었다.
"C로 가자 이런건 좀 크더라도 유지보수나 안정성을 생각해야해" — 3/19, RegExp 풀 파서 선택
Context: packed struct(u8)
파서 컨텍스트란?
JS 파서는 "지금 어디에 있는가"를 추적해야 한다. yield는 generator 함수 안에서만 키워드이고, 밖에서는 일반 식별자다. await도 async 함수 안에서만 키워드다. in 키워드는 for-in 루프의 초기화식에서는 허용되지 않는다.
이 정보를 파서 컨텍스트라고 하며, 1바이트로 추적:
문법 파라미터 묶음 자체는 u8이다. 다만 현재 함수 경계의 저장·복원은 별도 SavedState를 사용하며 strict mode·loop·switch·class field·super 허용 상태도 함께 보존한다. 함수 문맥 전체가 단일 u8 복사로 끝나는 것은 아니다. packed 문법 파라미터로 정리한 초기 작업은 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단계 테스트 피라미드
아래 수치는 3월 개발 당시의 기록이다. 현재 성적으로 다시 집계한 표가 아니다.
각 레벨의 역할이 다르다:
- Test262는 "파서가
for (let x of y)...를 올바르게 파싱하는가"를 검증 - 적합성은 "TS의
enum Color { Red }가 올바른 JS IIFE로 변환되는가"를 검증 - 스모크는 "lodash-es를 번들링한 결과가
node로 실행하면 정상 동작하는가"를 검증 - E2E는 "번들 결과물을
<script>태그로 넣으면 브라우저에서 에러 없이 로드되는가"를 검증
9월에는 Node 실행 결과와 타겟별 번들·단일 파일 변환 결과를 비교하는 다운레벨 오라클이 추가됐다. 심볼 누락과 식별자 생성 경로 감사도 함께 사용한다. 현재 의미론 검증의 범위와 알려진 실패 처리는 3-22에 정리했다.
"왜 95? 100% 통과 목표가 아닌가요?" — 3/18, 최초 Test262 목표 설정 "테스트 먼저 작성하고, 코드 고쳐서 통과시켜" — 3/22, TDD 요구 "실제 실행도 해보면서 통과했다고 확인한거야?" — 3/23, 스모크 테스트 검증 "그정도로 실제 잘됐다고 보증할 수 있어??" — 테스트 충분성 의문
의사결정 문서화: D001~D082
모든 설계 결정에 번호를 매겼다. 82개.
각 결정에는 선택지, 트레이드오프, 최종 선택 이유가 기록되어 있다. 이 방식은 "왜 이렇게 구현했지?"하는 미래의 질문에 대한 답변을 미리 남겨두는 것이다.
"다시 한번 정리 깔끔하게 해줄래? 왜 선택했는지 어떤 번들러가 그렇게 하고 있는지 등" — 3/21, 모든 결정에 근거 요구