- Published on
Bun 1.1에서 Node API 호환이 깨질 때 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 런타임을 Bun 1.1로 올리는 순간, 어제까지 잘 돌던 코드가 갑자기 깨지는 경험을 하곤 합니다. 특히 문제는 “Node API 호환(Node compatibility)” 영역에서 자주 터집니다. 에러 메시지는 TypeError: xxx is not a function, ERR_MODULE_NOT_FOUND, Cannot find module 'node:fs', process is not defined, Buffer is not defined처럼 다양하지만, 실제 원인은 대체로 몇 가지 패턴으로 수렴합니다.
이 글에서는 Bun 1.1에서 Node API 호환이 깨질 때 디버깅을 “재현 가능한 최소 케이스 만들기 → 호환성 경계 확인 → 번들/모듈 해석 문제 분리 → 런타임 차이 추적 → 임시 우회와 근본 해결” 순으로 정리합니다.
참고로, 런타임/플랫폼 이슈는 한 번 꼬이면 원인을 찾기 어려운데, 이런 상황에서의 진단 방식은 캐시/재검증 문제를 분해하듯 접근하는 게 효과적입니다. (유사한 디버깅 사고방식은 Next.js 14 RSC 캐시 꼬임·stale 데이터 해결법에서도 도움 됩니다.)
1) 먼저 “Bun 문제”인지 “번들/환경 문제”인지 분리하기
Bun에서 Node API 호환이 깨지는 것처럼 보일 때, 실제로는 다음 중 하나인 경우가 많습니다.
- 번들러가 Node 내장 모듈을 브라우저용으로 바꿔치기(polyfill)하거나 제거함
- ESM/CJS 해석 차이로 인해 import 방식이 달라짐
- **런타임 전역 객체(process/Buffer)**가 기대와 다름(특히 프론트 번들/edge 환경)
- 의존성이 Node 특정 동작에 의존(예:
fs동기 API,child_process,worker_threads등)
가장 먼저 할 일은 “Bun이 직접 실행하는 서버 코드”에서 깨지는지, “번들된 결과물”에서 깨지는지 분리하는 것입니다.
체크리스트
- 서버 진입점을 번들 없이 그대로 실행해보기
- 동일 코드를 Node로 실행해 비교하기
- 깨지는 모듈을 최소 재현 코드로 분리하기
아래처럼 아주 작은 스크립트를 만들어 런타임 차이를 확인합니다.
// repro.ts
import fs from "node:fs";
import path from "node:path";
console.log("runtime", {
bun: typeof (globalThis as any).Bun !== "undefined",
node: typeof (globalThis as any).process !== "undefined" && !!(globalThis as any).process.versions?.node,
});
console.log("node builtins", {
fsExistsSync: typeof fs.existsSync,
pathSep: path.sep,
});
console.log("globals", {
process: typeof (globalThis as any).process,
Buffer: typeof (globalThis as any).Buffer,
});
실행:
bun repro.ts
node repro.ts
여기서부터가 출발점입니다. 번들러/프레임워크를 끼지 않은 상태에서도 깨지면 런타임/호환성 이슈 가능성이 높고, 번들/프레임워크를 끼면 깨지면 대부분 설정/타겟/폴리필 문제입니다.
2) “Node 내장 모듈 import”가 깨질 때: node: 프리픽스와 조건부 exports
Bun은 Node 호환을 강하게 가져가지만, 의존성이 조건부 exports(package.json의 exports)를 사용하면 런타임별로 다른 엔트리가 선택됩니다.
예를 들어 어떤 패키지가:
{
"name": "some-lib",
"exports": {
".": {
"node": "./dist/node.js",
"bun": "./dist/bun.js",
"default": "./dist/browser.js"
}
}
}
이런 구조라면 Bun에서 bun 조건을 타고 들어가면서, 그 엔트리가 Node API에 기대거나 반대로 브라우저 타겟으로 잘못 들어갈 수 있습니다.
디버깅 포인트
- 실제로 로드된 파일이 무엇인지 확인
- 의존성의
exports조건이 Bun에서 어떻게 해석되는지 확인
Bun에서는 트레이스/로그 기반으로 “어떤 파일이 로드되는지”를 확인하는 식으로 좁힙니다. 프레임워크가 개입하면 로더가 바뀌므로, 우선 직접 import로 확인하세요.
// which-entry.ts
import pkg from "some-lib";
console.log(pkg);
그리고 node_modules/some-lib/package.json의 exports를 직접 열어 어떤 조건이 걸려 있는지 확인합니다.
흔한 해결
- 특정 조건 엔트리가 잘못됐다면 패키지 버전 업/다운
- 임시로는 alias/override(번들러의 resolve alias)로 node 엔트리 강제
- 최후에는 patch-package로 exports 수정
3) ESM/CJS 호환 깨짐: default import, require, named export 불일치
Node API 호환처럼 보이지만 실제로는 모듈 시스템 불일치가 원인인 경우가 정말 많습니다.
대표 증상:
TypeError: x.default is not a functionNamed export 'X' not found. The requested module is a CommonJS module
최소 재현: CJS 모듈을 ESM처럼 import
// cjs-lib.cjs
module.exports = function hello() {
return "hello";
};
// use.ts
import hello from "./cjs-lib.cjs";
console.log(hello());
런타임/번들러에 따라 동작이 달라질 수 있습니다. 이때는 다음 중 하나로 정리합니다.
- CJS는
createRequire로 가져오기 - ESM으로 통일
default/named export 매핑을 명시적으로 맞추기
// use-require.ts (ESM에서 CJS 가져오기)
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const hello = require("./cjs-lib.cjs");
console.log(hello());
포인트: “Node API가 깨졌다”가 아니라 “모듈 로딩 방식이 달라졌다”일 수 있습니다. Bun 1.1로 올리면서 로더/해석이 더 엄격해졌거나, 반대로 특정 호환 레이어가 바뀌었을 가능성을 염두에 두세요.
4) process/Buffer/global이 없다는 에러: 서버 런타임 vs 브라우저 번들 혼동
process is not defined, Buffer is not defined는 Node 런타임이라면 보통 나오지 않습니다. 이런 에러가 나온다면 높은 확률로:
- 해당 코드는 브라우저 번들로 나가고 있는데 Node 전역을 참조함
- Edge/Worker 계열 런타임에서 실행 중
- 번들러가 Node polyfill을 넣지 않음
디버깅 방법: 실행 위치부터 확정
- 문제 파일이 서버 전용인지(SSR/서버 핸들러) 브라우저 번들인지 분리
- 프레임워크(Next.js 등)라면
server-only/use client경계를 확인
서버에서만 써야 하는 코드를 확실히 서버로 격리하는 예:
// server/crypto.ts
import "server-only";
import crypto from "node:crypto";
export function sha256(input: string) {
return crypto.createHash("sha256").update(input).digest("hex");
}
클라이언트에서 필요하면 Web Crypto로 대체:
// client/crypto.ts
export async function sha256(input: string) {
const data = new TextEncoder().encode(input);
const hash = await crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, "0")).join("");
}
이런 종류의 문제는 캐시/빌드 산출물이 꼬여서 “원래 서버로 가야 할 코드가 클라로 넘어간 것처럼 보이는” 상황에서도 발생합니다. 프레임워크를 함께 쓰는 경우, 빌드 캐시를 비우고 재현을 정리하는 과정이 중요합니다(접근 방식은 Next.js App Router 캐시 꼬임·재검증 버그 해결 같은 글과 결이 같습니다).
5) fs/child_process/worker_threads 같은 ‘진짜 Node 전용’ API가 문제일 때
Bun이 Node API를 많이 구현했더라도, 다음은 현실적으로 차이가 나기 쉽습니다.
child_process의 미묘한 옵션 동작/시그널 처리worker_threads와 메시지/전송 객체 처리fs의 watcher, 권한, 심볼릭 링크, Windows 경로 처리- 네이티브 애드온(.node 바이너리) 로딩
빠른 분기: “대체 가능/불가능” 판단
- 단순 파일 읽기/쓰기면:
Bun.file,Bun.write등 Bun 네이티브 API로 우회 가능 - 프로세스 실행이 필요하면: Bun의
Bun.spawn로 바꿔보기 - 네이티브 애드온이면: Bun에서 곤란할 수 있어 Node 실행으로 fallback 전략 필요
예: child_process.spawn를 Bun.spawn로 치환
// spawn.ts
const proc = Bun.spawn(["git", "rev-parse", "HEAD"], {
stdout: "pipe",
stderr: "pipe",
});
const out = await new Response(proc.stdout).text();
const err = await new Response(proc.stderr).text();
if (proc.exitCode !== 0) {
throw new Error(`git failed: ${err}`);
}
console.log(out.trim());
여기서도 중요한 건 “호환성 버그”인지 “API 설계 차이”인지 분리하는 것입니다. 동일한 동작을 기대하면 안 되는 영역이 존재합니다.
6) 의존성 하나만 Bun에서 깨질 때: lockfile/해상도/트랜스파일 체인 점검
Bun 1.1로 올리면서 다음이 바뀌면, Node API 호환처럼 보이는 문제가 생길 수 있습니다.
- lockfile 갱신으로 의존성 버전이 미세하게 변경
exports해석 변화로 다른 빌드 엔트리 선택- 트랜스파일(특히 TS/JS target) 결과가 변경
실전 절차
- lockfile 고정 후 재현
- 깨지는 패키지 하나만 isolate
- 해당 패키지의 배포 포맷 확인(ESM/CJS, exports, types)
가장 단순하지만 효과적인 방법은 패키지 버전을 1개씩 고정해 보는 것입니다.
# 예: 의심 패키지 버전 고정
bun add some-lib@1.2.3
bun install --frozen-lockfile
그리고 재현이 사라지면, “Bun 1.1의 Node 호환”이 아니라 “의존성 업데이트로 인한 런타임 차이”일 수 있습니다.
7) 테스트 전략: Node와 Bun을 동시에 돌려 회귀를 잡기
호환성 문제는 한 번 고쳐도 재발합니다. 그래서 CI에서 Node와 Bun을 동시에 테스트하는 게 비용 대비 효과가 큽니다.
예: package.json 스크립트
{
"scripts": {
"test:node": "node --test",
"test:bun": "bun test",
"test:all": "bun run test:bun && bun run test:node"
}
}
핵심은 “Bun에서만 깨지는 케이스”를 지속적으로 잡아내는 것입니다. 배포 파이프라인에서 런타임이 바뀌면 권한/인증/환경값처럼 예기치 못한 곳에서 문제가 터지는데, 이런 문제도 결국 재현-격리-원인분리가 답입니다(배포 측면의 트러블슈팅 흐름은 GitHub Actions OIDC AWS 배포 AccessDenied 해결 같은 글의 접근을 참고할 만합니다).
8) 디버깅 플레이북: 15분 안에 원인 후보를 3개로 줄이기
마지막으로, 실제 현장에서 시간을 아끼는 순서로 정리합니다.
1) 재현을 최소화
- 프레임워크/번들 제거한 단일 파일에서 재현되는가?
- 된다면 Bun vs Node 비교로 런타임 차이를 확정
2) 깨지는 지점이 “모듈 로딩”인지 먼저 본다
exports조건부 엔트리 확인- ESM/CJS import 형태 확인
3) 실행 위치(서버/클라/edge)를 확정
process/Buffer에러는 대부분 실행 위치 문제- 서버 전용 코드는 확실히 서버로 격리
4) Node 전용 API면 대체/우회 결정
fs/child_process/네이티브 애드온은 대체 전략 필요- Bun 네이티브 API로 우회 가능하면 우회
5) lockfile/의존성 변화 확인
- Bun 업그레이드와 동시에 의존성이 바뀌었는지 확인
- 문제 패키지 버전 고정으로 빠르게 분기
결론
Bun 1.1에서 “Node API 호환이 깨졌다”는 현상은, 실제로는 (1) 모듈 해석(ESM/CJS/exports) 문제, (2) 실행 환경 혼동(서버 vs 클라/edge), (3) Node 전용 기능 의존, (4) 의존성 해상도 변화 중 하나일 확률이 높습니다.
중요한 건 감으로 추측하지 말고, 번들 제거 → 최소 재현 → Node/Bun 비교 → 모듈 엔트리/exports 확인 → 실행 위치 확정 순서로 원인을 체계적으로 줄이는 것입니다. 이 루틴만 잡아도 “호환성 이슈”를 하루 종일 붙잡는 일이 크게 줄어듭니다.