Node.js 모듈 해석을 Rust로 다시 쓴 oxc-resolver — 어디서 체감되고, 어디서 조심해야 하나
프론트엔드 빌드 시간을 줄이려고 고민해보셨다면, 번들러가 import './components/Button'을 처리할 때 내부에서 벌어지는 일에 한 번쯤 눈이 갔을 겁니다. 확장자 추론, package.json의 exports 필드 탐색, tsconfig의 path alias 매핑이 모듈 하나당 한 번씩 돌고, 이게 수십만 번 반복되면 체감 빌드 시간의 상당 지분을 차지합니다.
oxc-resolver는 그 해석 단계를 Rust로 다시 구현한 라이브러리입니다. webpack의 enhanced-resolve를 포팅한 터라 API 모양은 거의 같지만, 내부는 완전히 다릅니다. 이 글은 oxc-resolver의 동작 원리, 실제 적용 지점, 그리고 숫자만 보고 넘어가면 안 되는 트레이드오프를 정리합니다.
모듈 해석 한 번에 벌어지는 일
이 흐름이 모듈 하나당 한 번씩 돌고, 각 분기마다 파일시스템 접근이 뒤따릅니다. JavaScript로 구현된 enhanced-resolve는 이 과정에서 V8 힙에 중간 문자열과 객체를 끊임없이 만들어내고, 그만큼 GC가 개입합니다.
Rust가 유리한 지점을 정확히 짚으면
Rust도 String, Vec 등을 통해 힙을 쓰므로 "힙 할당이 없다"는 설명은 틀렸습니다. 핵심은 GC가 없고 해제 시점이 소유권 규칙으로 컴파일 타임에 결정된다는 점입니다. 수십만 번 반복되는 해석 루프에서 pause가 끼지 않고, 할당·해제가 예측 가능하게 돌아간다는 뜻이지요.
성능 수치는 조심해서 다뤄야 합니다. 프로젝트 저장소 README는 enhanced-resolve 대비 큰 폭의 성능 차를 주장하고, CodSpeed의 지속적 벤치마크에서 각 벤치 함수별 수치를 확인할 수 있습니다. 다만 그 수치는 특정 픽스처·캐시 조건에서 측정된 결과이므로, 본인 워크로드의 모듈 수·tsconfig 복잡도·파일시스템 환경에서 직접 재보는 과정이 반드시 필요합니다. 이 글에서 특정 배수를 단정하지 않는 이유도 그래서입니다.
구조와 기본 API
oxc-resolver는 oxc(JavaScript Oxidation Compiler) 프로젝트의 하위 컴포넌트입니다. Rust 크레이트(oxc_resolver)와 npm 패키지(oxc-resolver) 두 형태로 배포되고, Node.js에서는 NAPI-RS로 네이티브 바이너리에 접근합니다.
설치부터 봅니다.
npm install oxc-resolverResolverFactory에 옵션을 주입하고, sync()로 경로를 해석합니다.
import { ResolverFactory } from 'oxc-resolver';
const resolver = new ResolverFactory({
alias: { '@': '/project/src' },
extensions: ['.ts', '.tsx', '.js'],
tsconfig: {
configFile: './tsconfig.json',
references: 'auto',
},
conditionNames: ['import', 'node'],
});
const result = resolver.sync('/project/src', './components/Button');
if (result.error) {
console.error(result.error);
} else {
console.log(result.path);
}저도 처음엔 enhanced-resolve의 콜백 흐름에 익숙해서 동기 API가 어색했는데, 빌드 툴 관점에서는 제어 흐름이 오히려 단순해집니다.
tsconfig를 플러그인 없이 처리
enhanced-resolve에서는 tsconfig-paths를 별도로 끼워야 했습니다. oxc-resolver는 tsconfig 옵션 하나로 paths, baseUrl, references, extends를 함께 다룹니다.
const resolver = new ResolverFactory({
tsconfig: {
configFile: './tsconfig.json',
references: 'auto',
},
});
const result = resolver.sync('/project', '@utils/format');모노레포에서 references: 'auto'가 특히 쓸모 있습니다. 각 패키지의 tsconfig.json을 자동으로 따라가며 cross-package alias를 정리해줍니다.
실무 적용 지점
ESLint 린트 속도 개선
가장 체감이 빠른 진입점입니다. eslint-plugin-import 계열을 쓰고 있다면 resolver만 교체해도 린트 시간이 눈에 띄게 줄어듭니다. 아래는 eslint-import-resolver-oxc의 사용 형태를 보여주기 위한 개념적 예시이며, 실제 export 이름과 설정 키는 2026년 10월 기준 해당 패키지의 README에서 최신 버전에 맞게 확인한 뒤 쓰시는 걸 권합니다. eslint-plugin-import-x의 resolver-next 지원 버전도 마찬가지로 플러그인 측 CHANGELOG로 확인해야 합니다.
// 개념적 예시 — 실제 export/옵션 명은 패키지 README로 확인
import { createOxcImportResolver } from 'eslint-import-resolver-oxc';
export default {
settings: {
'import-x/resolver-next': [
createOxcImportResolver({
tsconfig: { configFile: 'tsconfig.json' },
}),
],
},
};기존 eslint-import-resolver-typescript를 그대로 대체하는 자리라, 교체 비용 자체는 설정 파일 몇 줄 수준입니다.
커스텀 빌드/정적 분석 스크립트
사내에서 enhanced-resolve를 직접 쓰는 번들러나 분석기를 만들어 쓰고 계시다면, 옵션 구조가 거의 그대로 대응돼 교체 비용이 낮습니다. 아래는 개념적 비교를 위한 코드이며, enhanced-resolve의 정확한 호출 형태는 해당 저장소의 README에서 확인해주세요.
// 개념적 비교 — 실제 호출 형태는 각 패키지 문서 참고
import enhanced from 'enhanced-resolve';
const legacy = enhanced.create.sync({
alias: { '@': '/project/src' },
extensions: ['.ts', '.js'],
});
import { ResolverFactory } from 'oxc-resolver';
const resolver = new ResolverFactory({
alias: { '@': '/project/src' },
extensions: ['.ts', '.js'],
});눈에 띄는 차이는 fileSystem 옵션이 없다는 점입니다. 파일시스템 캐싱이 Rust 쪽 내부에서 처리됩니다.
Vite / Rolldown 맥락
Rolldown과 함께 Vite 라인업에 oxc-resolver가 편입되고 있다는 흐름 자체는 분명합니다. 다만 2026년 10월 시점에서 Vite 8의 정식 릴리즈 상태와 Rolldown 통합 세부사항은 공식 블로그 쪽이 가장 정확하므로, 마이그레이션 결정을 내리기 전에 Vite 공식 블로그와 rolldown.rs에서 현재 릴리즈 라인을 확인하는 걸 권합니다. 자동으로 쓰게 되는 영역이지만, 디버깅 상황에서 "지금 돌고 있는 해석기가 뭐냐"를 아는 것과 모르는 것은 체감이 다릅니다.
rolldown-vite 발표 글과 Vite 측 발표에는 초기 도입 기업의 빌드 시간 단축 사례가 올라와 있는데, 이는 번들러 전체 교체 효과이지 해석기 하나의 효과가 아니라는 점은 기억해둘 만합니다.
트레이드오프
요약 테이블
| 항목 | 내용 | 비고 |
|---|---|---|
| 성능 | enhanced-resolve 대비 성능 우위 주장 |
수치는 자체 벤치 — 본인 워크로드에서 재측정 필요 |
| 메모리 모델 | GC pause 없이 소유권 규칙으로 해제 시점 결정 | 할당 자체가 없다는 뜻이 아님 |
| tsconfig 내장 | paths, references, extends를 플러그인 없이 처리 |
tsconfig-paths 제거 가능 |
| API 호환성 | enhanced-resolve와 옵션 구조 유사 |
드롭인에 가까운 교체 |
| 멀티플랫폼 | Linux x64/arm64, macOS, Windows, WASM 바이너리 제공 | Node 외 런타임은 WASM 경로 |
| 생태계 편입 | Rolldown/Vite 라인에 내부 해석기로 편입 중 | 자동 사용 영역 |
| 버전 변동 | npm상 메이저 번호가 짧은 기간에 크게 올라감 | oxc 모노레포의 통합 버전 정책에 따른 번호 점프 영향 |
| NAPI 경계 | JS ↔ Rust 호출 건당 오버헤드 존재 | 극단적으로 잦은 호출 시 배치 고려 |
| 에러 가시성 | Rust 내부 오류가 JS 스택 트레이스에 명확히 드러나지 않을 수 있음 | result.error 체크 필수 |
| 플랫폼 엣지 | Windows 경로 처리 등 이슈 트래커에 간헐 보고 | 이슈 검색 후 도입 권장 |
버전 번호가 짧은 사이에 크게 올라간 점은 "API가 매번 뒤집혔다"기보다는 oxc 모노레포 전체의 통합 릴리즈 정책에서 메이저 번호가 동기화되는 영향이 큽니다. 그래도 실무에선 핀 고정과 CHANGELOG 확인을 권합니다.
어떤 상황에서 손을 대야 하나
실무에서 걸리기 쉬운 지점
버전 핀 고정. package.json에서 정확한 버전으로 고정하고, 메이저 번호가 뛰었을 때는 CHANGELOG의 breaking 섹션을 꼭 훑어보세요. oxc 쪽은 통합 릴리즈라 서브 패키지와 묶여 올라가는 경우가 있습니다.
NAPI 경계 비용. 해석 호출 1건은 빠르지만, JS에서 Rust로 넘어가는 호출 자체에 오버헤드가 있습니다. 수백만 건 단위 호출이면 배치 처리나 호출 지점 통합을 검토할 만합니다. 다만 일반적인 빌드 툴 시나리오에선 문제가 되지 않습니다.
에러 디버깅. alias 설정이 어긋났을 때 메시지가 모호하게 떨어지는 경우가 있습니다. result.error를 항상 분기해서 어떤 입력에 대해 실패했는지 함께 로그로 남겨두면 추적이 훨씬 수월합니다.
정리
oxc-resolver의 가치는 "N배 빠르다"는 숫자 자체가 아니라, 모듈 해석이라는 자주 반복되고 자주 간과되는 파이프라인 한 부품을 GC가 끼지 않는 Rust 레이어로 가져왔다는 점에 있습니다. 번들러 전체를 바꾸지 않고도 해석기 하나만 교체해 체감 효과를 얻을 수 있는 자리가 있고, Rolldown/Vite를 쓰는 쪽에서는 선택이 아니라 기본 전제로 들어옵니다.
도입 순서를 굳이 추천한다면, 가장 적은 비용으로 측정 가능한 ESLint resolver 교체에서 시작해 사내 분석 스크립트의 enhanced-resolve 자리를 살펴보고, 그 다음 번들러 선택 재검토로 넘어가는 흐름이 안전합니다. 숫자에 끌려가지 말고, 본인 리포지토리에서 전후를 재는 게 늘 먼저입니다.
참고 자료