Bun 플러그인 API로 빌드 파이프라인에 직접 손 대기 — `onLoad`·`onResolve`가 열어주는 것들
번들러 설정 파일을 열 때마다 마음이 무거워지는 순간이 있습니다. 특정 파일 형식 하나 처리하려고 플러그인 문서를 왕복하고, 로더 순서를 뒤엎고, 결국 스택오버플로에서 남의 설정을 복붙하는 그 흐름 말입니다. Bun의 플러그인 API를 처음 열어봤을 때 의외였던 건 화려한 기능보다도, 훅 이름 몇 개만 알면 그 흐름을 잘라내고 원하는 지점에 코드를 삽입할 수 있게 설계됐다는 점이었습니다.
Bun의 플러그인 시스템에서 눈여겨볼 지점은 런타임(bun run)과 번들러(bun build)가 같은 플러그인 등록 방식을 공유한다는 것입니다. 다만 뒤에서 다시 다루겠지만, 모든 훅이 양쪽에서 다 동작하는 것은 아닙니다. 이 글에서는 onResolve와 onLoad 두 훅으로 변환 파이프라인을 어떻게 짜는지, 그리고 esbuild에서 넘어올 때 어디서 걸려 넘어지는지를 정리합니다.
훅 시스템의 구조: 어느 단계에서 무엇을 할 수 있나
Bun은 Bun.plugin() (또는 bun.build 설정의 plugins 배열)로 플러그인을 등록하고, setup(build) 콜백에서 훅에 접근합니다. 일반 JavaScript/TypeScript 플러그인 작성자가 실제로 다룰 훅은 onStart, onResolve, onLoad, onEnd 네 개입니다.
각 훅의 역할과 지원 범위를 정리하면 다음과 같습니다.
| 훅 | 개입 시점 | 주요 반환값 | 지원 범위 |
|---|---|---|---|
onStart() |
번들 시작 직후 1회 | — | 번들러 전용 |
onResolve() |
모듈 경로 해석 전 | { path, namespace } |
런타임·번들러 공용 |
onLoad() |
파일 읽기 전 | { contents, loader } |
런타임·번들러 공용 |
onEnd() |
번들 완료 후 | — | 번들러 전용 |
이 표에 굳이 넣지 않은 훅이 하나 더 있습니다. onBeforeParse는 존재하지만 NAPI 네이티브 애드온 전용이라 일반 JS/TS 플러그인 코드에서는 등록할 수 없습니다. "Rust나 C++로 파서 스레드에 붙는 변환을 얹고 싶다"는 요구가 아니라면 이 훅은 없다고 생각하고 접근해도 됩니다.
한 가지 실무적 함정도 짚어둡니다. build.config를 수정하고 싶다면 onStart 콜백 안이 아니라 setup() 함수 본문에서 직접 해야 합니다. onStart 안에서 config를 건드려도 조용히 무시됩니다.
네임스페이스: 가상 모듈의 주소 체계
onResolve와 onLoad를 연결하는 핵심 메커니즘이 네임스페이스입니다. 기본값은 "file"이고, 커스텀 네임스페이스는 Bun 내부 모듈 해석 그래프에서만 사용되는 식별자입니다. 소스 코드의 import 문 자체가 yaml:./config.yaml 형태로 재작성되는 게 아니라, onResolve가 반환한 namespace 값을 기준으로 이후 onLoad가 매칭될지 결정된다는 뜻입니다. 사용자는 평소처럼 import cfg from "./config.yaml"을 쓰면 됩니다.
filter 정규식에 대해 하나 더 알아두면 좋은 것이, 임포트 경로에 . 또는 :이 포함되어야 훅이 안정적으로 매칭됩니다. 확장자도 콜론도 없는 베어(bare) 식별자(import 'something')를 직접 가로채려면 별도 처리가 필요합니다.
세 가지 변환 시나리오
시나리오 1: YAML 파일을 JS 모듈로 임포트하기
설정이나 시드 데이터를 YAML로 두고 코드에서 바로 임포트하고 싶을 때의 기본형입니다. onResolve로 .yaml 파일을 커스텀 네임스페이스로 라우팅하고, onLoad에서 실제 파싱을 수행합니다.
import { plugin } from "bun";
import { parse } from "js-yaml";
plugin({
name: "yaml-loader",
setup(build) {
build.onResolve({ filter: /\.ya?ml$/ }, (args) => ({
path: args.path,
namespace: "yaml",
}));
build.onLoad({ filter: /\.ya?ml$/, namespace: "yaml" }, async (args) => {
const text = await Bun.file(args.path).text();
const parsed = parse(text);
return {
contents: `export default ${JSON.stringify(parsed)};`,
loader: "js",
};
});
},
});같은 패턴을 구현한 bun-plugin-yml(stacksjs)이 커뮤니티에 이미 있으니, 자체 데이터 형식이나 특수 병합 규칙이 필요한 경우에만 직접 구현하면 됩니다.
시나리오 2: 경로 별칭으로 모노레포 해석하기
tsconfig.paths에 의존하지 않고 onResolve로 모노레포 내부 패키지를 직접 매핑하는 패턴입니다. symlink 해석이 꼬이는 CI 환경에서 특히 유용합니다.
import { plugin } from "bun";
import path from "path";
const MONOREPO_ROOT = path.resolve(import.meta.dir, "../../");
plugin({
name: "monorepo-resolver",
setup(build) {
build.onResolve({ filter: /^@company\// }, (args) => {
const packageName = args.path.replace("@company/", "");
const localPath = path.join(
MONOREPO_ROOT,
"packages",
packageName,
"src/index.ts"
);
return {
path: localPath,
namespace: "file",
};
});
},
});args.importer를 함께 활용하면 "어느 파일에서 임포트했는지"를 기준으로 패키지별 해석 로직을 분기할 수도 있습니다. Bun의 ESM 환경에서는 __dirname이 정의되지 않으므로 import.meta.dir(또는 import.meta.dirname)을 써야 한다는 점을 잊지 마세요.
시나리오 3: 빌드 타임 코드 생성
onLoad에서 GraphQL 스키마를 읽어 TypeScript 소스를 생성해 반환하는 패턴입니다. 여기서는 graphql 패키지의 buildSchema로 스키마를 파싱한 뒤, 최상위 타입 이름을 뽑아 간단한 인터페이스를 만드는 개념적 예시를 보여줍니다.
import { plugin } from "bun";
import { buildSchema, GraphQLObjectType } from "graphql";
plugin({
name: "graphql-codegen",
setup(build) {
build.onResolve({ filter: /\.graphql$/ }, (args) => ({
path: args.path,
namespace: "graphql-types",
}));
build.onLoad(
{ filter: /\.graphql$/, namespace: "graphql-types" },
async (args) => {
const source = await Bun.file(args.path).text();
const schema = buildSchema(source);
const lines: string[] = [];
for (const type of Object.values(schema.getTypeMap())) {
if (type.name.startsWith("__")) continue;
if (type instanceof GraphQLObjectType) {
const fields = Object.values(type.getFields())
.map((f) => ` ${f.name}: unknown;`)
.join("\n");
lines.push(`export interface ${type.name} {\n${fields}\n}`);
}
}
return {
contents: lines.join("\n\n"),
loader: "ts",
};
}
);
},
});프로덕션에서는 GraphQL Code Generator처럼 스칼라 매핑과 리졸버 시그니처까지 다루는 도구가 훨씬 낫지만, "어느 시점에 어떤 파일을 가로챈다"는 골격은 이게 전부입니다.
esbuild와 비교하며 솔직하게
Bun 플러그인 API는 esbuild 훅 구조를 의도적으로 참고했고, 훅 시그니처 수준에서는 서로 닮아 있습니다. 다만 API 커버리지가 완전히 일치하지는 않기 때문에, esbuild 플러그인을 그대로 옮겼을 때 "동작하는 것"과 "포팅이 필요한 것"이 갈립니다.
| 항목 | Bun 플러그인 | esbuild 플러그인 | Vite 플러그인 |
|---|---|---|---|
| API 진입점 | setup(build) 하나 |
setup(build) 하나 |
각 훅 메서드 분리 |
| 런타임 재사용 | 런타임·번들러 공용 훅 존재 | 번들러 전용 | 개발서버·빌드 분리 |
| TypeScript 지원 | 네이티브 (트랜스파일 불필요) | 별도 설정 필요 | 별도 설정 필요 |
onDispose |
미지원 | 지원 | 해당 없음 |
resolve() 메서드 |
미지원 | 지원 | 해당 없음 |
initialOptions / metafile |
부분·상이 | 지원 | 해당 없음 |
| 생태계 규모 | 상대적으로 작음 | 보통 | 가장 큼 |
즉 onResolve/onLoad/onStart/onEnd의 기본 시그니처에 머무는 esbuild 플러그인은 Bun에서 무리 없이 재사용할 여지가 있지만, onDispose, build.resolve(), initialOptions 조작, metafile 후처리 같은 esbuild 전용 API에 걸린 플러그인은 별도 포팅이 필요합니다.
실무에서 자주 나오는 실수 세 가지
첫째, 개발 서버와 프로덕션 빌드에서 다른 플러그인을 적용하는 경우입니다. 두 경로의 변환이 어긋나면 소스맵이 밀리고 스택 트레이스가 잘못된 줄을 가리키게 됩니다. 등록 지점을 하나의 공유 설정 파일로 모으는 편이 안전합니다.
둘째, 런타임 플러그인에서 onResolve를 비동기로 쓸 때의 함정입니다. 커뮤니티에서 동기/비동기 처리 차이로 인한 이슈가 보고된 적이 있으므로(참고 자료의 재현 리포지토리 참조), 런타임 컨텍스트에서는 가능한 한 동기 반환을 유지하는 편이 안전합니다.
셋째, 베어 모듈 식별자 필터링입니다. import 'virtual-module'처럼 경로에 .이나 :이 없으면 필터가 매칭되지 않습니다. 가상 모듈 식별자는 virtual:module-name처럼 콜론을 넣는 관례로 설계하는 편이 다루기 쉽습니다.
플러그인을 짜기 전에 잠깐 멈추고 볼 것들
의사결정을 흐름도로 도식화하고 싶은 유혹이 있지만, 실제로는 훅을 조합해야 하는 경우가 대부분입니다(예: 경로를 다른 네임스페이스로 보내면서 동시에 원본 파일 내용을 교체). 그래서 여기서는 트리 대신 점검 목록으로 정리합니다.
- 이미 동작하는 esbuild 플러그인이 있다면 그 플러그인이
onDispose,build.resolve(),initialOptions,metafile을 건드리는지부터 확인하세요. 해당 API를 쓰지 않는다면 Bun에서 그대로 얹어보는 게 가장 빠릅니다. - 단순한 트랜스파일만 필요하고 모듈 그래프에 개입할 이유가 없다면
Bun.Transpiler를 프로그래매틱하게 호출하는 편이 플러그인 등록보다 가볍습니다. - 경로 매핑과 내용 변환이 함께 필요하다면
onResolve에서 네임스페이스를 부여하고onLoad에서 매칭하는 조합이 표준적입니다. 한쪽만으로 해결하려 하면 예외 처리가 복잡해집니다. bun build --compile로 단일 실행 파일을 만드는 워크플로에서는 플러그인이 빌드 타임에 환경변수 인라이닝이나 설정 파일 임베딩을 처리하는 위치가 됩니다. 런타임에서 이 파일들이 존재하지 않을 수 있다는 점을 기준으로 훅을 설계하세요.
정리하며: onResolve와 onLoad가 감당하는 범위
일반 플러그인 작성자에게 실질적으로 열려 있는 훅은 사실상 onResolve와 onLoad 둘입니다. onBeforeParse는 네이티브 애드온의 영역이고, onStart/onEnd는 번들러 컨텍스트에서만 부수 작업으로 붙습니다. 그런데 이 글에서 살펴본 세 시나리오(파일 형식 확장, 모노레포 경로 재해석, 스키마 기반 코드 생성)가 이미 두 훅의 조합만으로 성립합니다. 새로운 파일 형식을 언어처럼 다루고 싶을 때, 모듈 해석 규칙을 프로젝트에 맞게 뒤틀고 싶을 때, 빌드 타임에 소스 코드를 만들어 넣고 싶을 때 — 세 요구사항이 하나의 API 표면에서 동일한 형태로 해결됩니다.
그래서 Bun 플러그인 API의 진짜 특징은 "훅이 많다"가 아니라 "적게 열어놓았고, 열어놓은 자리에 필요한 게 대부분 있다"에 가깝습니다. esbuild의 세밀한 통제나 Vite의 넓은 생태계가 필요한 상황이라면 여전히 그쪽을 선택할 이유가 있지만, 백엔드/풀스택 파이프라인에서 커스텀 변환 한두 개를 얹기 위해 훅 시스템을 다시 배우고 싶지 않다면, 이 두 훅을 먼저 진지하게 검토해볼 만합니다.
참고 자료
- Plugins — Bun 번들러 공식 문서
- Plugins — Bun 런타임 공식 문서
- PluginBuilder.onResolve API 레퍼런스
- Bun.PluginBuilder TypeScript 인터페이스
- The Bun Bundler — 공식 블로그
- esbuild vs Bun 번들러 비교
- bun-plugin-yml — GitHub
- bun-css-loader — GitHub
- bun-style-loader — GitHub
- Bun Bundling: 8 Edge Cases That Bite — Medium
- bun runtime plugin onResolve sync/async 버그 재현 — GitHub