ESLint 규칙을 Rust로 옮기면 무슨 일이 벌어지는가 — Oxc AST, 방문자 패턴, 그리고 JS 플러그인의 현실
린팅 파이프라인 속도 때문에 괴로웠던 적이 있습니다. 저장할 때마다 ESLint가 돌아가는데, 파일이 수천 개가 되자 피드백 루프가 몇 초씩 걸리기 시작했거든요. 그때 처음 Oxlint를 접했고, "Rust로 짜면 얼마나 빠르길래?"라는 호기심으로 직접 규칙 하나를 포팅해 봤습니다. 결론부터 말하면, 생각보다 훨씬 다른 세계였습니다.
이 글은 Oxc의 AST 구조가 ESLint와 어떻게 다른지, Rust의 방문자 패턴이 어떻게 동작하는지, 그리고 JS 플러그인 시스템이 실제로 어디까지 쓸 수 있는지를 현업 시각으로 풀어냅니다. 순수하게 Rust로 규칙을 짜는 쪽과, JS 플러그인으로 기존 ESLint 생태계를 그대로 가져오는 쪽, 두 경로 모두 다룰 예정입니다.
Oxlint의 JS 플러그인은 2025년 10월 9일 Preview 발표로 처음 공개되었고, 2026년 3월 11일 Alpha 단계로 승격되며 정식 도입에 한 걸음 다가섰습니다. 이 글은 Alpha 시점(2026년 3월 기준)을 기준으로 씁니다.
대상 독자는 ESLint 규칙을 직접 만들어본 경험이 있거나, 린팅 파이프라인 성능에 관심 있는 개발자입니다. Rust 기초는 있으면 좋지만 없어도 흐름을 따라오는 데 큰 문제는 없을 겁니다.
ESLint와 Oxlint, 실행 모델이 근본적으로 다르다
ESLint의 규칙 구조
ESLint 규칙의 핵심은 create() 함수가 반환하는 객체입니다. 키는 AST 노드 타입 문자열, 값은 그 노드가 방문될 때 호출될 핸들러 함수죠.
// ESLint 규칙 기본 구조
module.exports = {
create(context) {
return {
CallExpression(node) {
if (isConsoleLog(node)) {
context.report({ node, message: "console.log 제거 필요" });
}
},
};
},
};이 구조는 동적 디스패치 기반입니다. 런타임에 문자열 키를 보고 어떤 핸들러를 부를지 결정하고, 노드마다 그 비용을 지불합니다.
Oxlint의 Rule 트레이트
Oxlint에서 규칙 작성자가 직접 다루는 것은 Rule 트레이트입니다. 세 가지 훅 중 하나 이상을 선택할 수 있습니다.
// Oxlint 규칙 기본 구조 (oxc_linter 크레이트)
impl Rule for NoConsoleLog {
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
if let AstKind::CallExpression(call_expr) = node.kind() {
if is_console_log(call_expr) {
ctx.diagnostic(no_console_log_diagnostic(call_expr.span));
}
}
}
}run()은 개념적으로 모든 AST 노드마다 호출됩니다. 다만 실제로는 규칙이 관심 있는 노드 타입만 골라 디스패치하는 필터링이 프레임워크 안쪽에 존재합니다. Oxlint 아키텍처 소개 자료(Inside Oxlint)에 따르면 각 규칙이 어떤 AstKind에 반응할지를 비트셋 형태로 미리 표시해 두고, 프레임워크가 노드 순회 중 그 표에 없는 규칙은 아예 호출하지 않는 식으로 동작합니다. 이 사전 계산이 정확히 Rust const 평가 시점에 이루어지는지 프로세스 초기화 시점에 이루어지는지는 구현 세부에 해당하므로, 이 글에서는 "노드 순회 이전에 이미 결정되어 있다"까지만 짚어 두겠습니다.
run_once(ctx)는 파일 전체를 한 번만 스캔할 때 씁니다. no-duplicate-imports처럼 모든 import 구문을 모아서 한 번에 판단해야 하는 규칙에 적합합니다. run_on_symbol(symbol_id, ctx)는 심볼 하나 단위로 호출됩니다. 예를 들어 "선언되었지만 어디서도 사용되지 않는 변수"를 잡는 no-unused-vars 계열 규칙은 노드를 순회하면서 매번 판단하는 대신, 심볼 테이블에 등록된 각 심볼에 대해 딱 한 번씩 참조 개수를 확인하는 편이 자연스럽습니다.
중요한 점은 규칙 작성자가 이 세 훅만 구현하면 된다는 것입니다. AST를 실제로 걸어 다니면서 훅을 호출해 주는 방문자 로직은 Oxlint 프레임워크 내부의 몫입니다.
AST 구조: 메모리 아레나와 'a 수명 매개변수
솔직히 처음 Oxc AST를 봤을 때 가장 당황스러웠던 게 'a였습니다. 함수 시그니처마다 <'a>가 붙어있고, AstNode<'a>, LintContext<'a>… "이게 왜 다 수명 매개변수를 달고 있지?"
이유는 메모리 아레나 때문입니다.
oxc_ast 크레이트는 AST 노드를 힙에 개별적으로 할당하지 않습니다. 파일 파싱 시작 시 단일 아레나(Arena)를 만들고, 모든 노드를 그 위에 올립니다. 파싱이 끝나면 아레나를 통째로 버립니다. GC 없이, per-node 해제 비용 없이.
// 개념적 예시 — 실제 Oxc 내부 구조의 간략화
pub struct Parser<'a> {
allocator: &'a Allocator, // 아레나 할당자
// ...
}
// 파싱 결과의 노드들은 모두 'a 수명을 가짐
// 여기서 Vec은 표준 std::vec::Vec이 아니라
// oxc_allocator가 제공하는 아레나 기반 커스텀 Vec입니다
pub struct Program<'a> {
pub body: Vec<'a, Statement<'a>>,
// ...
}'a는 "이 데이터는 아레나가 살아있는 동안만 유효하다"는 컴파일 타임 보장입니다. 규칙 함수가 <'a>를 받는 건 이 아레나 수명을 그대로 전달받는 것이죠.
또 하나 눈에 띄는 설계는 span을 usize 대신 u32로 저장한다는 점입니다. 대부분의 소스 파일은 4GB를 넘지 않으니, u32로도 충분하고 메모리는 절반이 됩니다. 작은 것 같아도 노드가 수십만 개가 되면 차이가 납니다.
복잡한 시맨틱 분석은 oxc_semantic이 별도 패스로 처리합니다. 심볼 테이블, 스코프 체인, 변수 참조 추적이 여기에 해당합니다. 규칙에서 ctx.semantic().symbols()로 접근할 수 있습니다.
방문자 패턴: Visit, VisitMut, Traverse — 그리고 규칙 작성자의 위치
Oxc는 세 가지 방문자 트레이트를 제공합니다.
| 트레이트 | 특징 | 주 용도 |
|---|---|---|
Visit |
불변 참조, AST를 읽기만 함 | 린팅, 분석 |
VisitMut |
가변 참조, AST를 수정 가능 | 트랜스파일, 자동 수정 |
Traverse |
enter/exit 훅 제공, 컨텍스트 전달 | 복잡한 변환, codegen |
한 가지 짚어야 할 점은 린터 규칙 작성자는 이 세 트레이트를 직접 구현하지 않는다는 것입니다. 규칙 작성자가 만나는 것은 Rule 트레이트뿐이고, 실제 AST 순회는 Oxlint 프레임워크 내부가 Visit 계열 로직으로 수행합니다. 규칙은 순회 결과를 노드 단위 콜백처럼 전달받을 뿐이죠.
그럼 Traverse는 언제 쓰이는가? 트랜스파일러나 코드젠 계열 도구, 즉 AST를 실제로 변형하고 그 과정에서 스코프 진입·이탈을 정확히 추적해야 하는 도구가 주 고객입니다.
// Traverse 트레이트 활용 개념적 예시 (oxc_traverse 크레이트 기반)
// 린터 규칙이 아니라, 변환기 계층에서 쓰는 패턴입니다
impl<'a> Traverse<'a> for MyTransformer {
fn enter_function(&mut self, func: &mut Function<'a>, ctx: &mut TraverseCtx<'a>) {
self.scope_depth += 1;
}
fn exit_function(&mut self, func: &mut Function<'a>, ctx: &mut TraverseCtx<'a>) {
self.scope_depth -= 1;
}
}no-useless-assignment 포팅에서 배운 것
커뮤니티에서 실제로 시도된 eslint/no-useless-assignment 포팅 사례를 보면 몇 가지 패턴이 눈에 들어옵니다.
첫째, ESLint 원본 코드를 그대로 복사하는 건 불가능합니다. JavaScript의 create() 반환 구조와 Rust의 Rule 트레이트는 일대일 매핑이 안 됩니다. 소유권과 차용 모델 때문에 로직 자체를 Rust 방식으로 재사고해야 합니다.
둘째, 심볼 추적은 ctx.semantic()을 통해 합니다. 아래 코드에서 is_never_read_after_assign은 실제로 존재하는 함수가 아니라 흐름을 보여주기 위한 placeholder입니다. 실제 포팅에서는 이 자리에 심볼의 참조 리스트를 순회하며 제어 흐름을 따지는, 훨씬 긴 로직이 들어갑니다.
// 실행 가능한 예시가 아닌, 흐름 설명용 의사코드입니다
// AssignmentTarget은 SimpleAssignmentTarget과 AssignmentTargetPattern으로
// 갈라지고, 식별자는 SimpleAssignmentTarget 쪽에 있습니다
impl Rule for NoUselessAssignment {
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
if let AstKind::AssignmentExpression(assign_expr) = node.kind() {
if let AssignmentTarget::SimpleAssignmentTarget(
SimpleAssignmentTarget::AssignmentTargetIdentifier(ident)
) = &assign_expr.left {
let symbols = ctx.semantic().symbols();
// (아래는 존재하지 않는 함수, 개념 설명용)
if is_never_read_after_assign(ident, symbols, ctx) {
ctx.diagnostic(useless_assignment_diagnostic(assign_expr.span));
}
}
}
}
}ctx.semantic().symbols()는 파일 전체의 심볼 테이블을 제공합니다. 변수가 어디서 정의되고 어디서 참조되는지 이미 분석된 상태이기 때문에, 규칙에서 직접 AST를 재순회할 필요가 없습니다.
자동 수정이 필요한 규칙은 oxc_codegen이 개입합니다. AST를 수정한 뒤 소스 코드로 다시 생성하는 역할입니다.
JS 플러그인 호환성: 어디까지 되고 어디서 막히는가
앞서 언급한 것처럼 Oxlint JS Plugins Alpha가 2026년 3월에 발표되었습니다. 이는 2025년 10월 Preview에서 이어진 승격이며, 이름의 변경이 아니라 릴리스 단계의 진전입니다.
핵심 메커니즘은 Raw Transfer입니다. Rust와 Node.js 사이에 데이터를 복사하지 않고 전달하는 방식으로, 이 덕분에 JS 플러그인을 써도 성능 하락이 최소화됩니다. Alpha 발표 블로그의 벤치마크에 따르면, Node.js 저장소(6,298개 파일, Mac Mini M4 기준)에서 Oxlint + JS 플러그인 조합이 21초, ESLint가 1분 43초를 기록했습니다. 이 두 값을 나눠 보면 대략 4.9배 차이입니다. 원문에서 명시적으로 언급된 배수 표기가 아니라 계산값이라는 점만 참고해 주세요.
Raw Transfer의 요구 환경(Node.js 버전, 파일 크기 제한 등)이나 Node 22 미만에서의 동작 세부는 JS 플러그인 사용 가이드에서 확인하시길 권합니다. 이 글에서는 "환경 조건이 있으므로 도입 전 확인이 필요하다"까지만 짚어 두겠습니다.
현재 호환 경계
| 상황 | 지원 여부 |
|---|---|
| 일반 ESLint 플러그인 | Alpha 단계, 상당수 동작 (플러그인별 검증 필요) |
@typescript-eslint 타입-어웨어 규칙 |
JS 플러그인에서 현재 미지원 |
| 공식 테스트 대상 외 플러그인 | 동작 보장 없음, 개별 확인 필요 |
| Rust 네이티브 규칙 | 안정적, 타입-어웨어는 Preview 존재 |
@typescript-eslint 중에서도 타입 정보를 필요로 하는 규칙들(no-floating-promises, no-unsafe-argument 등)은 JS 플러그인 경로에서 아직 지원되지 않습니다. 타입-어웨어 린팅은 2025년 8월 Rust 네이티브 경로에서 Preview가 열렸지만, JS 플러그인 쪽과는 아직 연결되지 않았습니다.
점진적 전환: eslint-plugin-oxlint의 역할
완전히 갈아엎지 않아도 됩니다. eslint-plugin-oxlint라는 브리지 패키지가 있습니다. Oxlint가 이미 처리하는 규칙을 ESLint 설정에서 자동으로 비활성화해 줍니다. 두 도구를 병렬로 쓰면서 점진적으로 이동할 수 있는 경로입니다.
// .eslintrc.js
module.exports = {
plugins: ["oxlint"],
extends: ["plugin:oxlint/recommended"],
// Oxlint가 처리하는 규칙들이 자동으로 꺼집니다
};다만 한 가지 함정이 있습니다. eslint-stylistic 같은 일부 플러그인이 ESLint 자체를 peerDependency로 요구해서, Oxlint 전용 프로젝트에서도 ESLint가 함께 설치되는 상황이 발생합니다. 이건 플러그인 생태계 측의 문제라 Oxlint 팀이 직접 해결하기 어렵습니다.
Rust 규칙 포팅 vs JS 플러그인, 무엇을 기준으로 고를 것인가
| 기준 | Rust 네이티브 규칙 | JS 플러그인 |
|---|---|---|
| 성능 | 네이티브 실행, 프레임워크 필터링으로 오버헤드 최소 | Raw Transfer로 격차 축소 (2026-03 Alpha 벤치 기준 상당한 격차 유지) |
| 구현 난이도 | Rust 학습 비용, 완전 재작성 필요 | 기존 JS 규칙 재사용 가능 |
| 타입-어웨어 규칙 | Preview 단계 지원 | 현재 미지원 |
| 안정성 | 안정적 | Alpha 단계 |
| 적합한 상황 | 범용 규칙, 성능 임계 케이스 | 팀 전용 규칙, ESLint 마이그레이션 |
구체적인 배수를 두 경로에 대해 나란히 인용하고 싶었지만, Rust 네이티브 경로에 대해 신뢰할 수 있는 조건부 벤치마크(파일 수·하드웨어·버전 명시)를 참고자료에서 찾지 못했습니다. JS 플러그인 경로만 위에서 인용한 6,298 파일 벤치가 있어, 그 값 하나만 근거로 두는 편이 정직하다고 판단했습니다.
마무리: 도입 판단 체크리스트
Oxc의 실질적 차별점은 마케팅 문구가 아니라 이 글에서 살펴본 세 가지에 있습니다. 파싱 단계부터 아레나에 노드를 밀어 넣는 메모리 모델, 노드 순회 이전에 관심 규칙만 골라내는 사전 필터링, 그리고 파서·시맨틱·린터·코드젠이 같은 아레나를 공유하는 단일 파이프라인 구조입니다. 이 셋이 맞물려서 "빠르다"라는 인상이 만들어집니다.
도입을 고민 중이라면 아래 항목을 팀 상황에 대입해 보시는 편이 실용적입니다.
- 타입-어웨어 규칙 의존도가 높다면: JS 플러그인 경로는 아직 이르고, Rust 네이티브 타입-어웨어 Preview를 지켜보는 편이 안전합니다.
- 팀 내 Rust 경험자가 없다면: 코어 기여보다
eslint-plugin-oxlint로 병렬 구성부터 시작하는 편이 학습 부담이 적습니다. - 팀 전용 커스텀 규칙이 많다면: JS 플러그인 Alpha가 현실적인 후보지만, 프로덕션 필수 파이프라인이라면 Alpha 상태를 감안한 롤백 경로를 설계해 두는 게 좋습니다.
- 환경 요건 확인: JS 플러그인의 실행 환경 요구사항은 공식 사용 가이드에서 도입 전 반드시 훑어 보세요.
Rust 학습이 가능한 상황이라면, 단순한 규칙 하나(예: no-console-log)를 직접 포팅해 보는 경험은 그 자체로 얻는 게 많습니다. run() 훅 하나에 패턴 매칭 몇 줄이면 동작하는 걸 보면, Oxlint 규칙 작성이 처음 짐작한 것보다 훨씬 접근 가능하다는 감각을 얻게 됩니다.
참고 자료
- Oxc 공식 사이트
- Adding Linter Rules — Oxc 공식 기여 가이드
- AST 구조 공식 문서
- Oxlint JS Plugins Preview 발표 (2025-10-09)
- Oxlint JS Plugins Alpha 발표 (2026-03-11)
- Oxlint Type-Aware Preview (2025-08-17)
- JS Plugins 사용 가이드
- Writing JS Plugins 가이드
- Migrate from ESLint 가이드
- eslint-plugin-oxlint (npm)
- Implementing eslint/no-useless-assignment in oxlint
- Inside Oxlint: Linter Architecture and Rule System
- oxc_ast crate docs
- oxc-project/oxc GitHub 저장소