Babel 플러그인을 Oxc Transformer로 포팅할 때 AST 구조 차이와 방문자 패턴 다루기
Babel 플러그인을 한 번이라도 작성해본 사람이라면 알겠지만, 그 경험이 Rust 세계로 건너오는 순간 익숙했던 API의 형태가 상당 부분 낯설어집니다. 방문자 객체 하나에 노드·스코프·조작 메서드가 다 담겨 있던 세계에서, 노드 참조·수명 파라미터·아레나 할당기가 각각 다른 자리에 놓여 있는 세계로 옮겨갈 때, "어차피 AST 순회잖아"라는 첫인상은 대체로 오래가지 않습니다. 이 글은 Babel 플러그인을 Oxc Transformer로 옮길 때 반드시 이해해야 할 세 가지 구조적 차이 — 방문자 패턴의 표현 방식, AST 노드의 의도적 세분화, 아레나 메모리 모델 — 을 다룹니다.
Oxc는 Rust로 작성된 JavaScript/TypeScript 툴체인으로, 파서·린터·트랜스포머·리졸버 등을 하나의 크레이트 묶음으로 제공합니다. Rolldown이 내부 트랜스폼 엔진으로 Oxc를 사용한다는 점은 Oxc 프로젝트 공식 페이지에 명시되어 있으며, 이 사실만으로도 커스텀 Babel 플러그인을 운용 중인 팀이 언젠가 Oxc 기반 대안을 검토해야 할 개연성은 충분히 큽니다. 다만 어느 시점에 어떤 툴에 정확히 포함될지는 각 프로젝트의 공식 릴리스 노트를 확인하는 편이 정확합니다.
왜 포팅을 고민하게 되는가
Babel은 170개가 넘는 npm 패키지로 이루어진 생태계입니다. 플러그인 API가 풍부하고 커뮤니티도 성숙해 있습니다. Oxc의 매력은 성능 수치 하나로 요약되지 않고, AST를 단일 패스에서 여러 트랜스폼이 함께 소비하도록 설계됐다는 아키텍처적 선택에서 나옵니다.
Babel은 플러그인마다 독립적으로 AST를 순회합니다. Oxc는 여러 트랜스폼이 동일한 순회 패스에 자기 enter/exit 핸들러를 등록하고, 하나의 노드에 대해 등록된 순서대로 그 핸들러들이 실행됩니다. 캐시 지역성은 좋아지지만, 같은 노드를 여러 트랜스폼이 각각 어떻게 다루는가라는 상호작용 문제가 새로 등장합니다. 이 부분은 뒤에서 다시 다룹니다.
성능 수치와 벤치마크는 oxc-project/bench-transformer 저장소에서 재현 가능한 형태로 확인하는 편이 안전합니다. 개별 프로젝트가 보고한 개선 수치는 그 프로젝트의 워크로드에 강하게 의존하기 때문에, 여기서는 특정 사례 수치를 인용하지 않고 아키텍처 관점의 차이에 집중합니다.
사전 준비: 크레이트 구성과 트랜스폼 등록 흐름
포팅에 앞서 어떤 크레이트를 프로젝트에 넣고, 만든 Traverse 구현체를 어디에 연결하는지부터 정리합니다. 아래는 2026년 기준 Oxc의 공개 크레이트 이름을 사용한 개념적 예시이며, 실제 버전은 docs.rs에서 최신 상태를 확인해야 합니다.
# Cargo.toml (개념적 예시)
[dependencies]
oxc_allocator = "*"
oxc_ast = "*"
oxc_parser = "*"
oxc_span = "*"
oxc_traverse = "*"// 개념적 예시 — 실제 함수 시그니처는 docs.rs 확인 권장
use oxc_allocator::Allocator;
use oxc_parser::Parser;
use oxc_span::SourceType;
use oxc_traverse::traverse_mut;
fn run(source: &str) {
let allocator = Allocator::default();
let source_type = SourceType::default().with_module(true);
let ret = Parser::new(&allocator, source, source_type).parse();
let mut program = ret.program;
let mut my_transform = RemoveConsole;
traverse_mut(&mut my_transform, &allocator, &mut program /*, symbols, scopes */);
}전체 흐름은 다음과 같습니다.
핵심은 Allocator가 파이프라인 전체의 수명을 결정한다는 점입니다. 이후 등장하는 모든 'a는 이 Allocator의 수명입니다.
방문자 패턴: visitor 객체에서 Traverse 트레이트로
Babel의 방문자 패턴은 노드 타입 이름을 키로, 핸들러 함수를 값으로 넣는 방식입니다.
module.exports = () => ({
visitor: {
CallExpression(path) {
if (path.node.callee.name === 'console') {
path.remove();
}
},
Identifier: {
enter(path) { /* 진입 */ },
exit(path) { /* 탈출 */ },
}
}
});path 하나에 현재 노드, 부모, 스코프, replaceWith·remove·insertBefore 같은 조작 메서드가 모두 담깁니다.
Oxc에서는 이 역할을 Traverse 트레이트 구현체가 맡습니다.
use oxc_traverse::{Traverse, TraverseCtx};
use oxc_ast::ast::CallExpression;
struct RemoveConsole;
impl<'a> Traverse<'a> for RemoveConsole {
fn enter_call_expression(
&mut self,
node: &mut CallExpression<'a>,
ctx: &mut TraverseCtx<'a>,
) {
// 진입 시점 처리
}
fn exit_call_expression(
&mut self,
node: &mut CallExpression<'a>,
ctx: &mut TraverseCtx<'a>,
) {
// 탈출 시점 처리
}
}메서드 이름이 enter_* / exit_* 규칙을 따르고, 구현하지 않은 메서드는 트레이트의 기본 no-op 구현을 사용합니다. 필요한 노드 타입만 오버라이드하면 됩니다.
Babel의 path가 제공하던 기능이 Oxc에서는 둘로 분리됩니다. 노드 자체는 &mut node로 직접 넘어오고, 스코프·심볼 조회·삽입·UID 생성 같은 컨텍스트 기능은 ctx: &mut TraverseCtx<'a>로 옵니다. Babel에서 자주 쓰던 UID 생성은 Oxc 쪽에서 다음 형태로 이동합니다(정확한 메서드 이름과 반환 타입은 버전마다 달라질 수 있으므로 oxc_traverse docs.rs에서 확인하는 편이 안전합니다).
// 개념적 예시
// Babel: path.scope.generateUidIdentifier('temp') -> Identifier 노드 반환
// Oxc: ctx를 통해 새 바인딩용 이름/심볼을 생성
// 반환 타입은 보통 BoundIdentifier 계열(이름 Atom + SymbolId)
// BindingIdentifier나 IdentifierReference로 변환해 노드에 삽입Babel의 generateUidIdentifier는 어느 위치에도 그대로 꽂을 수 있는 Identifier 하나를 돌려주지만, Oxc는 다음 절에서 볼 이유로 위치별 노드 타입이 다르기 때문에 바인딩용/참조용 노드를 별도로 만들어 넣습니다. 이 차이가 포팅에서 반복적으로 부딪히는 지점입니다.
AST 구조: Identifier 하나에서 세 가지 타입으로
실제로 포팅 중 가장 많은 컴파일 오류가 나오는 지점이 여기입니다. Babel(ESTree)은 Identifier 노드 하나로 모든 이름을 표현하고, 선언인지 참조인지는 부모 컨텍스트로 판단합니다. Oxc는 용도에 따라 이를 세 개로 나눕니다.
| 위치 | Babel/ESTree | Oxc |
|---|---|---|
const x = ... 의 x |
Identifier |
BindingIdentifier |
console.log(x) 의 x |
Identifier |
IdentifierReference |
obj.property 의 property |
Identifier |
IdentifierName |
AssignmentExpression.left 타입 |
Pattern |
AssignmentTarget |
이 설계의 실용적 이득은 명확합니다. 선언 위치에 참조용 노드를, 참조 위치에 바인딩용 노드를 잘못 삽입하는 실수를 컴파일러가 타입 오류로 잡습니다. Babel에서 런타임에나 발견되던 부류의 버그가 빌드 단계에서 차단됩니다.
포팅 시의 실용적인 접근은, Babel 코드의 Identifier 처리 지점마다 "이 식별자가 선언인가, 참조인가, 정적 이름인가"를 먼저 확인하는 것입니다. 그러면 어떤 Oxc 타입을 써야 할지 결정됩니다.
Oxc의 노드 생성은 @babel/types에 해당하는 AstBuilder가 담당합니다. AstBuilder는 아레나 참조를 수명 'a로 들고 있어서, 만든 노드도 자동으로 'a 수명이 됩니다.
// 개념적 예시 — 실제 필드/메서드 이름은 docs.rs 확인 권장
use oxc_traverse::TraverseCtx;
fn make_something<'a>(ctx: &mut TraverseCtx<'a>) {
// TraverseCtx가 AstBuilder를 노출하는 방식은 버전마다 달라질 수 있습니다.
// 필드(ctx.ast) 형태이거나 접근자(ctx.ast()) 형태일 수 있으므로 확인 필요.
// let builder = ctx.ast;
// builder.expression_call(...) 같은 메서드로 노드 생성
}메모리 모델: GC 없는 세계에서의 AST
Babel은 JavaScript GC 위에서 동작하므로 노드 수명을 신경 쓸 일이 거의 없습니다. Oxc는 다릅니다.
oxc_allocator::Allocator는 bump-pointer 방식의 아레나입니다. 모든 AST 노드는 이 아레나 안에 할당되고, 아레나가 드롭될 때 한 번에 해제됩니다. 개별 힙 할당 오버헤드가 사라지는 것이 Oxc가 빠른 이유 중 하나입니다.
'a 수명 파라미터는 이 아레나의 수명을 가리킵니다. AstBuilder가 아레나 참조를 'a로 들고, 그 위에서 만든 노드가 'a 수명을 가지며, Traverse 트레이트 구현체도 'a를 받습니다. 그래서 impl<'a> Traverse<'a> for MyTransform이라는 형태가 반복됩니다. Rust에 익숙하지 않을수록 이 수명 전파가 진입 장벽이 되는 것은 사실입니다.
가장 흔한 우회 유혹은 'static으로 밀어붙이거나 clone으로 회피하는 것인데, 두 방향 모두 아레나 밖에 있는 값과 아레나 안 노드를 섞으면서 더 큰 수명 오류를 나중에 만듭니다. 처음에는 컴파일러가 요구하는 대로 'a를 붙여가며 오류 메시지를 따라가는 편이 결과적으로 더 빠릅니다.
포팅 예시: console.log 문 제거
간단한 예로 흐름을 확인합니다.
Babel 원본
module.exports = () => ({
visitor: {
ExpressionStatement(path) {
const { expression } = path.node;
if (
expression.type === 'CallExpression' &&
expression.callee.type === 'MemberExpression' &&
expression.callee.object.type === 'Identifier' &&
expression.callee.object.name === 'console'
) {
path.remove();
}
}
}
});Oxc 포팅 접근
여기서 두 가지가 Babel과 다릅니다. 첫째, 멤버 표현식의 object는 Expression enum이고, 그 안에 있는 식별자는 IdentifierReference입니다. 둘째, ExpressionStatement 자체를 제거하려면 부모의 body: Vec<Statement>를 직접 변형해야 합니다. Babel의 path.remove()에 정확히 대응하는 단일 호출이 트래버스 컨텍스트에 없으므로, 실무에서 자주 쓰이는 방식은 다음 중 하나입니다.
enter_program/exit_program(또는 블록 단위 컨테이너의 exit)에서program.body를 순회하며 조건에 맞는Statement::ExpressionStatement를retain으로 걸러내기- 순회 중에 삭제 대상의 위치를 수집해 두었다가 컨테이너 종료 시점에 일괄 제거
컨테이너 단위로 처리하면 enter_expression_statement에서 즉시 노드를 없애느라 순회 중 부모 벡터를 변형하는 상황을 피할 수 있습니다.
// 개념적 예시 — Statement/Expression enum variant 이름과
// AST 필드 구조는 실제 oxc_ast 문서에서 확인 필요
use oxc_ast::ast::{Program, Statement, Expression};
use oxc_traverse::{Traverse, TraverseCtx};
struct RemoveConsole;
impl<'a> Traverse<'a> for RemoveConsole {
fn exit_program(&mut self, program: &mut Program<'a>, _ctx: &mut TraverseCtx<'a>) {
program.body.retain(|stmt| !is_console_call_statement(stmt));
}
}
fn is_console_call_statement(stmt: &Statement) -> bool {
let Statement::ExpressionStatement(expr_stmt) = stmt else { return false };
let Expression::CallExpression(call) = &expr_stmt.expression else { return false };
// Oxc AST에서 call.callee는 Expression이며,
// 멤버 표현식은 Expression enum 안에 자체 variant로 존재.
// variant 이름(StaticMemberExpression, ComputedMemberExpression 등)은
// oxc_ast 최신 문서에서 확인해야 합니다.
match &call.callee {
// 개념적 매칭: 정적 멤버 접근의 object가 console인 경우
// Expression::StaticMemberExpression(m) => matches_console(&m.object),
// Expression::ComputedMemberExpression(m) => matches_console(&m.object),
_ => false,
}
}핵심은 코드 한 줄이 아니라 책임의 이동입니다. Babel에서는 path.remove()가 부모를 찾아 알아서 지워줬다면, Oxc에서는 "이 노드를 제거한다"는 결정을 컨테이너 소유자(부모 노드)의 코드가 명시적으로 내리도록 옮겨야 합니다. 문(statement) 삽입도 같은 원리로, 순회 도중이 아니라 컨테이너의 exit_* 시점에 일괄 적용하는 것이 안전합니다.
트레이드오프 정리
| 항목 | Babel | Oxc |
|---|---|---|
| 방문자 등록 방식 | visitor 객체 (런타임) |
Traverse 트레이트 (컴파일 타임) |
| 노드 조작 API | path 통합 객체 |
&mut node + TraverseCtx 분리 |
| 식별자 표현 | Identifier 단일 타입 |
BindingIdentifier / IdentifierReference / IdentifierName |
| 메모리 관리 | GC 자동 | 아레나 수동, 'a 수명 전파 |
| 플러그인 순회 | 플러그인마다 별도 패스 | 단일 패스, 등록된 순서대로 핸들러 호출 |
| 동적 플러그인 주입 | 가능 (런타임) | 대체로 컴파일 타임 정적 조합 |
| 타입 안전성 | 런타임 오류 가능 | 컴파일 타임 방지 |
포팅 중 반복적으로 마주치는 함정을 몇 가지로 요약합니다.
-
Babel의
Identifier를 그대로 찾으려는 시도. Oxc AST enum에는 위치별 타입만 존재하므로, Babel 코드를 직역해Identifier라는 이름의 variant를 매칭하려 하면 컴파일 오류가 납니다. 위치에 따라BindingIdentifier/IdentifierReference/IdentifierName중 무엇이 올지 먼저 판별해야 합니다. -
path.replaceWith()/path.remove()의 직역. 두 함수 모두 단일 호출로 대응되지 않습니다. 부모 노드를&mut로 직접 변형하거나, 컨테이너 단위(예:exit_program)에서 일괄 처리하는 방식으로 재구성해야 합니다. -
'a수명의 임시방편 처리.'static으로 피하거나clone으로 우회하면 나중에 더 큰 수명 문제가 옵니다. 아레나 수명을 처음부터 정직하게 전파하는 편이 결국 빠릅니다. -
단일 패스에서의 트랜스폼 간 상호작용. 여러 Babel 플러그인을 포팅해 하나의 파이프라인에 얹으면, 동일 노드에 대해 등록된 여러
enter/exit핸들러가 순서대로 실행됩니다. Babel에서 별개 패스로 안전하게 분리돼 있던 로직이 한 패스 안에서 서로의 결과에 노출된다는 뜻이므로, 삽입·치환의 순서에 의존하는 로직은 재검토가 필요합니다.
마무리하며
Oxc 포팅의 어려움은 대부분 "API 이름을 무엇으로 바꾸느냐"가 아니라 두 시스템의 설계 철학이 다르다는 사실을 받아들이는 데 있습니다. Babel은 런타임 유연성을, Oxc는 컴파일 타임 정확성과 아레나 기반 성능을 택했습니다. Identifier가 세 개로 쪼개진 것도, path가 &mut node와 TraverseCtx로 분리된 것도, 'a가 도처에 등장하는 것도 모두 그 선택의 자연스러운 귀결입니다.
현실적인 포팅 전략은 Oxc 프로젝트의 기여자 가이드가 제안하는 방향과 크게 다르지 않습니다. 첫 이터레이션에서는 Babel 로직을 되도록 그대로 Rust로 옮기고, Babel의 테스트 케이스를 통과시키는 것을 목표로 잡습니다. Rust다운 리팩터링과 성능 최적화는 그 다음입니다. 완벽하게 Rust스럽게 짜려다가 첫걸음에서 막히기보다, 동작하는 포팅을 먼저 만들고 나서 다듬는 편이 훨씬 현실적입니다.
참고 자료
- Oxc Transformer Alpha 릴리스 블로그 (2024-09-29)
- oxc/crates/oxc_transformer/README.md — 기여자 가이드
- oxc_traverse 공식 Rust 문서 (docs.rs)
- oxc_ast 공식 Rust 문서 (docs.rs)
- oxc_allocator 공식 Rust 문서 (docs.rs)
- Oxc 프로젝트 공식 사이트
- oxc-project/oxc — GitHub 저장소
- oxc-project/bench-transformer — 벤치마크 저장소
- traverse: port scope.push API from Babel — GitHub Issue #5049