Rolldown 플러그인에서 FFI 비용을 줄이는 Hook Filter 사용법
2026년 3월, Vite 8이 정식 출시되면서 우리가 익숙하게 쓰던 빌드 환경이 꽤 크게 바뀌었습니다. 개발 서버는 esbuild, 프로덕션 빌드는 Rollup — 이 이중 구조가 사라지고 Rolldown이라는 단일 Rust 번들러 하나로 통합됐거든요. Vite 팀 공식 블로그의 Vite 8 출시 발표에서는 대규모 프로젝트의 빌드 시간 개선 사례가 소개되었습니다(구체적인 수치와 측정 조건은 원문 참조).
그런데 막상 "그러면 내가 직접 Rolldown 플러그인을 써서 이 성능을 뽑으려면 어떻게 해야 하지?"라는 질문에 대한 답은 생각보다 잘 안 보입니다. Rollup 플러그인이랑 비슷하다는 건 알겠는데, 도대체 Rust랑 JS가 어떻게 연결되는 건지, hook filter는 왜 필수인지, 어디서 성능이 새는지 — 이 글에서는 그 부분을 파고들어 보려고 합니다.
Rolldown 1.0이 2026년 5월에 SemVer 안정 API로 정식 출시됐으니, 지금이야말로 플러그인 구조를 이해하고 손대볼 타이밍이기도 합니다.
Rolldown이 뭐길래 Vite의 번들러가 됐나
배경: 두 번들러의 이중 생활
Vite 7 이전까지 Vite의 내부 구조를 들여다보면 약간 이상한 점이 있었습니다. 개발 서버에서는 esbuild로 사전 번들링을 하고, vite build 할 때는 Rollup을 쓰는 이중 구조였어요. 덕분에 개발 중에는 동작하던 게 빌드에서 다르게 나오는 환경 불일치 버그를 종종 마주쳤습니다. 저도 처음엔 왜 dev랑 prod 동작이 다른지 몰라서 이슈를 꽤 오래 디버깅한 기억이 있어요.
Rolldown은 이 두 역할을 Rust 하나로 대체하기 위해 VoidZero(Evan You가 설립한 회사)가 만든 번들러입니다. 내부적으로는 Oxc(Oxidation Compiler) 라는 Rust 컴포넌트가 파싱, 트랜스파일, 미니파이, 소스맵 생성을 담당합니다.
중요한 부분이 저 FFI 경계입니다. Rolldown은 Napi-rs를 통해 Node.js와 Rust 사이의 바인딩을 만드는데, JS 플러그인 훅은 이 경계를 넘어서 실행됩니다. 이 경계를 넘는 비용이 Rolldown 플러그인에서 가장 중요하게 관리해야 할 포인트입니다.
Rollup 호환 설계의 의도
Rolldown의 플러그인 API는 Rollup과 의도적으로 동일하게 설계됐습니다. resolveId, load, transform, renderChunk, buildEnd 같은 훅 이름과 시그니처가 그대로입니다. 덕분에 대부분의 Rollup 플러그인이 큰 수정 없이 얹히지만, Rollup 내부의 비공개 API나 비표준 컨텍스트에 의존하는 플러그인은 별도 수정이 필요할 수 있습니다. 실제로 어느 정도 호환이 되는지는 사용 중인 플러그인 문서와 Vite 8 공식 블로그의 호환성 안내를 확인하는 게 안전합니다.
플러그인이 Rust 파이프라인과 연결되는 방식
JS 플러그인의 실행 흐름
Rolldown의 JS 플러그인 훅은 싱글스레드 JavaScript 런타임에서 실행됩니다. Rust 코어는 여러 모듈의 훅 호출을 동시에 큐잉·배치할 수 있지만, 결국 JS 이벤트 루프가 하나이기 때문에 응답 처리 자체는 직렬화됩니다. 즉, "요청 스케줄링은 병렬이지만 JS 쪽 실행은 싱글스레드"라는 두 층위를 구분해서 이해하는 게 좋습니다.
문제는 모듈 수가 많아지면 이 FFI 왕복 자체가 누적된다는 점입니다. 이 오버헤드를 최소화하기 위해 Rolldown이 도입한 게 Hook Filter입니다. 배경과 설계 의도는 Why Plugin Hook Filter 문서에 잘 정리돼 있습니다.
Hook Filter: 가장 중요한 최적화
filter 옵션은 Rolldown이 플러그인 핸들러를 JS로 넘기기 전에 Rust 쪽에서 먼저 걸러주는 메커니즘입니다. 필터 조건에 맞지 않는 모듈은 JS 런타임에 아예 진입하지 않습니다. FFI 비용 자체가 발생하지 않는 거죠.
// 안 좋은 패턴: 모든 파일에 대해 FFI 경계를 통과함
export function myTransformPlugin() {
return {
name: 'my-transform',
transform(code, id) {
if (!id.endsWith('.ts') && !id.endsWith('.tsx')) return null
return transformMyCode(code, id)
}
}
}// 개선 패턴: .ts/.tsx 파일만 JS로 넘긴다
export function myTransformPlugin() {
return {
name: 'my-transform',
transform: {
filter: {
id: /\.(ts|tsx)$/,
},
handler(code, id) {
return transformMyCode(code, id)
}
}
}
}두 코드는 결과적으로 같은 동작을 하지만, 첫 번째는 .vue, .css, .json 같은 모든 파일에 대해 FFI를 거칩니다. 두 번째는 .ts/.tsx에만 JS 핸들러를 실행합니다. Rollup 시절 습관 그대로 옮겨오면 저처럼 처음엔 이 부분을 놓치기 쉬웠는데, 대규모 저장소일수록 이 차이가 체감됩니다.
filter에는 id(파일 경로 패턴)와 moduleType 두 가지 축을 지정할 수 있습니다. moduleType이 받는 정확한 열거값 목록과 확장자·content-type 매핑 규칙은 Rolldown 공식 문서에서 자신이 쓰는 버전에 맞춰 확인해두는 편이 안전합니다. 확장자 기반이냐 실제 로드된 타입 기반이냐에 따라 매칭 결과가 달라질 수 있기 때문입니다.
훅마다 지원하는 필터 종류도 다릅니다. renderChunk 훅도 2026년 기준으로 코드 필터를 지원합니다(PR #4351 참고). 특정 파일 패턴의 청크에만 훅을 적용하고 싶을 때 유용합니다.
플러그인 작성 시나리오
시나리오 1: 간단한 코드 변환 플러그인
특정 import 지정자를 다른 경로로 리다이렉트하는 플러그인입니다. resolveId는 모든 모듈 해석마다 호출되므로, 처리할 대상 지정자를 빨리 좁혀서 반환하는 게 좋습니다.
export function aliasRewritePlugin(aliases) {
return {
name: 'alias-rewrite',
// source: import 문에 쓰인 지정자 문자열 ('foo/bar' 등)
resolveId(source) {
const match = aliases[source]
if (match) return match
return null
}
}
}여기서 첫 번째 파라미터를 id가 아니라 source로 이름 붙였습니다. Rollup·Rolldown 문서 기준으로 resolveId의 첫 인자는 아직 해석되지 않은 모듈 지정자이고, 해석 이후 load(id)에서 받는 id(해석된 경로)와는 구분되는 값입니다. 파라미터 이름 하나 차이지만, 이 두 개를 섞으면 나중에 훅을 추가할 때 실수하기 쉽습니다.
resolveId의 filter.id 지원 여부는 훅별로 다를 수 있어서, 사용 중인 Rolldown 버전 릴리스 노트를 확인하는 걸 권합니다.
시나리오 2: 가상 모듈 플러그인
빌드 타임에 동적으로 생성되는 모듈을 만드는 패턴입니다. 예를 들어 virtual:app-config를 import하면 환경 값에 기반한 설정 객체를 내보내는 모듈을 만들어주는 경우입니다.
const VIRTUAL_ID = 'virtual:app-config'
const RESOLVED_ID = '\0virtual:app-config'
export function virtualConfigPlugin(config) {
return {
name: 'virtual-app-config',
resolveId(source) {
if (source === VIRTUAL_ID) return RESOLVED_ID
return null
},
load: {
filter: {
id: /^\0virtual:app-config$/,
},
handler(id) {
return `export default ${JSON.stringify(config)}`
}
}
}
}\0 prefix는 Rolldown이 가상 모듈을 실제 파일과 구분하는 데 쓰는 Rollup 계열의 관례입니다. 가상 모듈 ID를 load 훅의 filter에 넣어두면 실제 파일들에 대한 FFI 호출을 원천 차단할 수 있습니다.
시나리오 3: buildEnd 훅으로 빌드 후처리
export function reportPlugin() {
return {
name: 'build-report',
// error?: Error — 실패 시에만 전달됨
buildEnd(error) {
if (error) {
console.error('빌드 실패:', error.message)
return
}
console.log('빌드 완료')
}
}
}buildEnd처럼 빌드 단위 훅은 모듈 수와 무관하게 한 번만 호출되므로 FFI 비용 걱정은 사실상 없습니다. 시그니처 표기 시 error는 옵셔널이라는 점을 문서화해두면 타입 오해가 줄어듭니다.
시나리오 4: unplugin으로 멀티 번들러 지원
Rolldown 전용 플러그인을 Rollup, Webpack, Rspack, esbuild에서도 쓰고 싶다면 unplugin을 활용할 수 있습니다. unplugin의 어댑터는 시점에 따라 조금씩 변하기 때문에, 실제로 어떤 번들러 어댑터가 노출되는지는 unplugin 릴리스 노트에서 확인해야 합니다. 이 글 시점(2026년 기준)에서 안정적으로 제공되는 어댑터는 .vite(), .rollup(), .webpack(), .esbuild(), .rspack()입니다.
Rolldown은 Rollup 호환 API를 그대로 쓰기 때문에, 전용 어댑터가 없어도 대개 .rollup() 어댑터를 그대로 얹거나, Vite 8을 통해 사용할 때는 .vite() 어댑터를 쓰는 게 가장 무난합니다.
import { createUnplugin } from 'unplugin'
// 개념적 예시: 실제 어댑터 목록은 사용 중인 unplugin 버전 기준으로 확인
const myPlugin = createUnplugin((options) => {
return {
name: 'my-universal-plugin',
transform: {
filter: {
id: /\.(ts|tsx)$/,
},
handler(code, id) {
return transformCode(code, id, options)
}
}
}
})
export const vitePlugin = myPlugin.vite
export const rollupPlugin = myPlugin.rollup
export const webpackPlugin = myPlugin.webpack
export const esbuildPlugin = myPlugin.esbuild
export const rspackPlugin = myPlugin.rspack.rollup() 어댑터가 Rolldown에서도 그대로 로드되는지는 실제 프로젝트에서 한 번 확인해보는 걸 권합니다. Rollup 호환 API 덕분에 대부분 문제없이 동작합니다.
Raw AST Transfer와 Native MagicString
Raw AST Transfer
Rust가 생성한 Oxc AST를 JS 플러그인에 직접 전달하는 기능입니다. 직렬화 오버헤드를 줄여서 AST 레벨 변환을 효율적으로 하려는 아이디어인데, 2026년 기준으로 아직 실험적 단계입니다. API 변경 가능성이 있으므로 프로덕션 플러그인에서는 아직 쓰지 않는 편이 안전합니다.
Native MagicString
JS 버전의 MagicString을 Rust 구현체로 대체해 소스맵 생성과 코드 변환 성능을 향상시키는 기능입니다. transform 훅에서 코드를 조작할 때 Rolldown은 내부적으로 이 Rust 구현체를 활용합니다. 대부분의 경우 사용자가 직접 신경 쓸 부분은 아니지만, 소스맵 정확도 이슈가 생겼을 때 원인 파악의 실마리가 될 수 있습니다.
결정 흐름으로 정리
훅을 새로 추가할 때, 아래 정도의 결정 흐름만 머릿속에 두고 시작해도 대부분의 성능 함정을 피할 수 있습니다.
여기서 놓치기 쉬운 지점 하나는 Rust 레벨 통합에 대한 과도한 기대입니다. React Compiler를 Rolldown에 Rust 레벨로 직접 통합하려다 바이너리 크기 증가 문제로 해당 통합을 되돌린 사례가 있습니다. Rust 레벨 통합은 성능 이득도 있지만 바이너리 크기, 빌드 복잡도 같은 트레이드오프가 따릅니다. JS 플러그인 + hook filter 조합으로 먼저 충분한 성능을 확보하고, 진짜 병목이 확인됐을 때 더 깊은 통합을 고려하는 게 현실적인 순서라고 봅니다.
마무리하며
Rolldown 플러그인 작성의 핵심은 결국 어디서 JS 런타임으로 제어권이 넘어가는가를 의식하는 것입니다. Rollup에서 플러그인을 짜던 것과 문법은 거의 같지만, 모든 훅 호출이 FFI 경계를 넘는다는 점이 다릅니다.
transform처럼 모든 모듈에 호출되는 훅에 filter를 달아 Rust에서 미리 걸러내는 것, 그리고 가상 모듈 패턴에서 \0 prefix와 함께 load 훅 filter를 조합하는 것 — 이 두 가지만 잘 챙겨도 JS 플러그인이 Rust 파이프라인의 발목을 잡을 일은 크게 줄어듭니다.
여기서 한 걸음 더 나아가고 싶다면 세 가지 방향이 있습니다. 첫째, unplugin 기반으로 라이브러리를 게시해서 Vite·Rolldown·Rollup·Rspack에 동시에 배포하는 것. 둘째, tsdown처럼 Rolldown을 라이브러리 번들러로 감싸는 도구에서 같은 플러그인을 그대로 재사용하는 것. 셋째, hook filter로도 해결되지 않는 병목이 있다면 Rolldown 코어에 Rust 레벨 기능으로 기여하는 것. 세 번째 길은 진입 장벽이 있지만, 정말 성능 병목이 특정 훅에 집중돼 있다면 가장 정직한 답이 되기도 합니다.
참고 자료
- Announcing Rolldown 1.0 | VoidZero
- Vite 8.0 is out! | Vite 공식 블로그
- Vite 8 Beta: The Rolldown-powered Vite | Vite 공식 블로그
- Why Plugin Hook Filter | Rolldown 공식 문서
- PluginContext Interface | Rolldown 레퍼런스
- GitHub — rolldown/rolldown
- Rolldown Pulls Rust React Compiler Integration After Binary Size Increase | Socket
- renderChunk hook code filter PR #4351 | rolldown/rolldown
- unplugin GitHub
- tsdown 공식 사이트