Bun 런타임 내부 구조 심화 — JavaScriptCore·Zig FFI·번들러·HTTP·프로덕션

이 글의 핵심

Bun을 설치 도구나 빠른 러너로만 보면 장애가 났을 때 어느 계층의 문제인지 구분하기 어렵습니다. JS 엔진과 Zig 런타임의 경계, 처리량을 비교할 때 주의할 점, bun:ffi와 WASM의 선택 기준을 짚고, graceful shutdown과 단일 바이너리 배포, 자주 겪는 실패 모드를 담은 트러블슈팅 플레이북으로 마무리합니다.

이 글의 핵심

이 글은 Bun을 도구 모음이 아니라 하나의 런타임 시스템으로 읽기 위한 내부 관점 정리입니다. Node.js가 V8과 libuv 조합으로 서버 JavaScript의 기준을 만든 것과 달리, Bun은 JavaScriptCore(JSC)를 중심에 두고 Zig로 작성한 런타임 코어, 트랜스파일러와 번들러, HTTP 서버를 하나의 바이너리에 묶습니다. 아래에서는 JSC와의 결합 방식, FFI와 네이티브 모듈, 트랜스파일러와 번들러의 역할 분담, Node HTTP 스택과의 구조 차이, 프로덕션 운영 패턴을 차례로 살펴봅니다.

Bun은 버전마다 내부 구현이 빠르게 바뀝니다. 아래 설명은 아키텍처를 이해하기 위한 모델이며, 세부 플래그와 API 동작은 공식 문서와 릴리스 노트로 확인하세요.


JavaScriptCore(JSC) 통합이 의미하는 것

엔진 선택: V8이 아닌 JSC

Node.js와 Deno, Chromium 계열 도구는 V8을 쓰지만 Bun은 Safari에 들어가는 WebKit의 JavaScriptCore를 씁니다. 이는 단순히 JIT 하나를 바꾼 것이 아니라, 메모리 관리와 객체 모델, 네이티브 바인딩 방식이 V8 기반 런타임과 달라진다는 뜻입니다. V8의 C++ API에 직접 의존하는 네이티브 애드온이 Bun에서 그대로 동작하지 않는 이유가 여기에 있습니다. Bun은 자주 쓰이는 V8 API 일부를 JSC 위에 흉내 내어 호환성을 넓히고 있지만 전체를 구현하지는 않습니다.

JS 엔진과 Zig 런타임의 경계

Bun의 구조는 세 계층으로 나눠 이해할 수 있습니다. JSC는 ECMAScript 실행, JIT 컴파일, 가비지 컬렉션을 맡습니다. Zig와 C++로 작성된 런타임 계층은 파일 시스템, 소켓, 프로세스, 번들러, 패키지 해석처럼 OS와 I/O에 가까운 작업을 맡습니다. 그 사이의 바인딩이 JavaScript 객체와 네이티브 구현을 연결하며, Bun.serve, Bun.file, fetch 구현이 이 경계 위에 있습니다.

실무에서 중요한 점은 두 가지입니다. 첫째, 같은 JavaScript 코드라도 엔진에 따라 마이크로 벤치마크 결과와 메모리 프로파일이 다를 수 있습니다. 둘째, JSC의 GC와 Bun의 I/O 처리 방식이 Node(libuv 중심)와 같지 않으므로, Node에서 잘 돌던 고부하 서버를 옮길 때 지연 분포와 메모리 피크를 다시 측정해야 합니다. 프로파일링 도구도 다릅니다. Node에서 쓰던 V8 힙 스냅샷 도구 대신 Bun의 JSC 기반 힙 스냅샷(bun:jsc의 generateHeapSnapshot)이나 Safari Web Inspector 연결(bun --inspect)을 써야 합니다.

Web 표준 API

Bun은 fetch, WebSocket, ReadableStream 같은 Web 표준 API를 런타임에 내장하고, 서버 API도 표준 Request/Response를 중심으로 설계했습니다. Node.js는 역사적으로 자체 http 모듈과 스트림 구현이 두껍고, 전역 fetch는 Node 18에서야 들어왔습니다. 그래서 표준 fetch 기반으로 짠 코드는 Bun으로 옮기기 쉽고, Node 전용 스트림이나 서버 내부 동작에 깊게 묶인 코드는 이전 비용이 커질 수 있습니다.

이벤트 루프와 CPU 바운드 작업

Bun이 내부적으로 I/O와 JS 실행을 몇 개의 스레드로 나누는지는 버전에 따라 달라질 수 있지만, JavaScript 코드가 하나의 스레드에서 실행된다는 원칙은 Node와 같습니다. 따라서 CPU 바운드 작업은 이벤트 루프를 막지 않도록 Worker나 별도 프로세스로 넘기고, 큰 JSON 파싱이나 암호화처럼 비용이 큰 작업은 스트리밍·청크 처리로 나눠야 합니다. Node의 libuv와 동작이 1:1로 같다고 가정하지 말고 P99 지연은 스테이징에서 다시 측정해야 합니다.


FFI와 네이티브 모듈

bun:ffi: C ABI를 직접 호출하는 경로

Bun은 JavaScript에서 공유 라이브러리의 심볼을 직접 호출하는 bun:ffi 모듈을 제공합니다. 별도의 바인딩 코드를 컴파일할 필요 없이, 선언한 시그니처를 바탕으로 Bun이 내장 C 컴파일러(TinyCC)로 호출용 래퍼를 즉석에서 생성합니다. 공식 문서는 이 모듈을 실험적 기능으로 표시하고 있으므로 프로덕션 핵심 경로에 쓸 때는 버전 업그레이드마다 검증이 필요합니다.

// libdemo.c를 공유 라이브러리로 빌드했다고 가정:
//   int32_t add_i32(int32_t a, int32_t b) { return a + b; }
import { dlopen, FFIType, suffix } from "bun:ffi";

// suffix는 플랫폼별 확장자(dylib / so / dll)
const lib = dlopen(`./libdemo.${suffix}`, {
  add_i32: {
    args: [FFIType.i32, FFIType.i32],
    returns: FFIType.i32,
  },
});

console.log(lib.symbols.add_i32(40, 2)); // 42

lib.close();

핵심은 인자 타입과 반환 타입을 FFI 선언에 정확히 적는 것입니다. 선언이 C 함수와 어긋나도 Bun은 이를 검사할 방법이 없으므로, 잘못된 시그니처는 원인을 찾기 어려운 메모리 손상이나 세그폴트로 이어집니다. 프로덕션에서는 C 헤더와 1:1로 대응하는 래퍼 모듈을 두고, FFI 경계를 단위 테스트로 고정하는 편이 안전합니다. 포인터를 넘길 때는 JS 쪽 버퍼가 GC로 해제되지 않도록 수명을 직접 관리해야 한다는 점도 주의해야 합니다.

Node 네이티브 애드온(N-API, node-gyp)과의 관계

Node 생태계의 네이티브 패키지는 대부분 N-API 또는 V8 API로 C/C++ 코드를 연결합니다. Bun은 N-API를 직접 구현하고 있어 N-API만 쓰는 .node 애드온은 대부분 그대로 로드됩니다. 반면 V8 API에 직접 의존하는 애드온은 Bun이 지원하는 범위 안에서만 동작합니다.

그래서 이전 전에 의존성 트리에 .node 바이너리가 있는지 확인하고, 배포 대상 OS·아키텍처 조합에서 로딩과 기본 동작을 CI로 검증하는 것이 좋습니다. 같은 패키지라도 optionalDependencies로 플랫폼별 바이너리를 내려받는 구조(예: @esbuild/linux-x64)가 많으므로, 개발 머신에서 동작했다고 컨테이너에서도 동작한다고 볼 수 없습니다.

Zig를 애플리케이션 코드에 쓰는 경우

런타임 코어가 Zig로 작성되었다는 사실과, 애플리케이션 코드를 Zig로 작성하는 것은 별개입니다. 애플리케이션에서 네이티브 코드가 필요하다면 C ABI로 내보낸 공유 라이브러리를 bun:ffi로 여는 것이 가장 단순한 경계입니다. 이 경우 구현 언어는 Zig, Rust, C 중 팀이 유지보수할 수 있는 것을 고르면 되고, Zig 전문성이 없는 팀이라면 Rust나 C가 더 현실적일 수 있습니다.


트랜스파일러와 번들러

트랜스파일: 실행 직전의 변환

Bun은 TypeScript와 JSX를 별도 tsc 없이 실행합니다. 파일을 불러올 때 내장 트랜스파일러가 타입 표기를 지우고 JSX를 함수 호출로 바꾼 뒤 JSC에 넘기며, 일정 크기 이상의 파일은 변환 결과를 디스크 캐시에 저장해 다음 실행 때 재사용합니다. Node에서 ts-node나 외부 트랜스파일러를 조합하던 것보다 도구 체인이 단순해집니다.

여기서 중요한 점은 이 트랜스파일러가 타입 검사를 전혀 하지 않는다는 것입니다. 타입 오류가 있어도 코드는 실행되므로, 타입 안전은 tsc --noEmit이나 에디터의 언어 서버로 따로 확인해야 합니다. 또 tsconfig.json의 paths는 읽지만, emitDecoratorMetadata처럼 타입 정보가 필요한 변환은 지원 범위를 확인해야 합니다.

Bun.build: 번들러의 책임

번들러는 엔트리에서 모듈 그래프를 수집하고, 의존성을 해석하고, 트리 셰이킹과 청크 분할을 거쳐 결과물을 만듭니다. Bun의 번들러는 런타임과 같은 저장소에서 개발되므로, 결과물을 어디서 실행할지(target: "browser" | "bun" | "node")를 명시하는 것이 중요합니다.

// 서버 앱 번들: Bun 런타임에서 실행할 결과물
const result = await Bun.build({
  entrypoints: ["./src/main.ts"],
  outdir: "./dist",
  target: "bun",
  minify: true,
  sourcemap: "external",
});

if (!result.success) {
  for (const log of result.logs) console.error(log);
  process.exit(1);
}

빌드 실패 시 Bun.build가 예외를 던질지, success: false와 로그를 돌려줄지는 버전과 throw 옵션에 따라 다릅니다(Bun 1.2부터는 기본적으로 예외를 던짐). 어느 쪽이든 실패를 확실히 처리하도록 작성해 두지 않으면 CI에서 실패를 놓칠 수 있습니다. 서버 번들과 브라우저 번들은 설정을 분리하는 것이 좋습니다. 서버 번들은 bun:*나 node:* 모듈을 포함할 수 있지만 브라우저 번들은 그럴 수 없고, 크기와 동적 import 정책도 다릅니다. 한 설정으로 둘 다 처리하려 하면 실행 시점에야 드러나는 모듈 누락이 생깁니다.

트랜스파일과 번들은 다른 일

트랜스파일은 TypeScript·JSX 문법을 실행 가능한 JavaScript로 바꾸는 일이고, 번들은 모듈 그래프를 배포용 산출물로 재구성하는 일입니다. 개발 중에는 bun run으로 소스를 바로 실행하고, 배포할 때는 bun build로 산출물을 만든다고 구분해 두면 설정이 섞이지 않습니다.


HTTP 서버: Node.js와의 구조 비교

Node: libuv 이벤트 루프와 req/res

Node.js의 HTTP 서버는 libuv 이벤트 루프 위에서 소켓을 처리하고, http.createServer의 req/res 스트림 객체를 핸들러에 넘깁니다. Express를 비롯한 생태계 전체가 이 req/res와 미들웨어 체인을 전제로 만들어졌습니다.

Bun: fetch 핸들러 모델

Bun.serve는 Fetch 표준의 Request를 받아 Response를 돌려주는 함수 하나로 서버를 구성합니다.

Bun.serve({
  port: 3000,
  fetch(req) {
    const url = new URL(req.url);
    if (url.pathname === "/health") {
      return new Response("ok");
    }
    return new Response("not found", { status: 404 });
  },
});

요청을 표준 객체로 다루기 때문에 Hono처럼 Web 표준 기반 프레임워크는 어댑터 없이 그대로 동작합니다. Express 같은 Node 프레임워크는 Bun.serve가 아니라 Bun이 구현한 node:http 호환 계층 위에서 동작하므로, 대부분 돌아가지만 Node 내부 동작에 의존하는 일부 미들웨어에서 차이가 날 수 있습니다.

처리량과 지연을 비교할 때

“Hello World” 벤치마크에서 Bun HTTP 서버가 빠르게 나오는 경우가 많지만, 실제 서비스의 응답 시간은 DB 쿼리, 외부 API 호출, JSON 직렬화가 좌우하는 경우가 많습니다. 런타임을 비교할 때는 평균 처리량보다 실제 동시 연결 수에서의 P99 지연을 보고, CPU 바운드 작업이 핸들러에 섞여 있지 않은지, 요청 바디 파싱과 스트리밍 경로가 프레임워크별로 어떻게 다른지까지 같은 조건으로 맞춰야 의미 있는 결과가 나옵니다.

WebSocket

Bun은 Bun.serve의 websocket 옵션으로 WebSocket 서버를 내장 제공하고, 토픽 기반 pub/sub(ws.subscribe, server.publish)도 기본으로 지원합니다. Node에서는 ws 같은 라이브러리를 조합하는 것이 일반적이었습니다. 어떤 런타임을 쓰든 Nginx나 Cloudflare 같은 프록시의 Upgrade 헤더 전달, 유휴 타임아웃, 백프레셔 설정이 연결 안정성에 직접 영향을 주므로 함께 점검해야 합니다.


프로덕션 운영 패턴

멀티코어 활용

하나의 Bun 프로세스는 JavaScript를 한 스레드에서 실행하므로, 멀티코어를 쓰려면 프로세스를 여러 개 띄워야 합니다. Linux에서는 Bun.serve에 reusePort: true를 주면 여러 프로세스가 같은 포트를 공유하고 커널이 연결을 분산합니다. 컨테이너 환경이라면 Kubernetes나 systemd 같은 플랫폼에서 인스턴스 수를 늘리는 방식이 가장 단순하고, 이를 위해 세션 같은 상태는 Redis 등 외부 저장소로 빼 두어야 합니다.

Graceful shutdown

배포나 스케일 다운 때 진행 중인 요청을 끝낼 시간을 주지 않으면 사용자는 연결 끊김 오류를 보게 됩니다.

const server = Bun.serve({
  port: Number(process.env.PORT ?? 3000),
  fetch(req) {
    return new Response("ok");
  },
});

let shuttingDown = false;

const shutdown = async () => {
  if (shuttingDown) return;
  shuttingDown = true;

  // 드레인이 너무 오래 걸리면 강제로 끊고 종료
  const force = setTimeout(() => {
    server.stop(true); // true: 활성 연결을 즉시 닫음
    process.exit(1);
  }, 25_000);

  // 인자 없이 호출하면 새 연결 수락을 멈추고 진행 중인 요청이 끝나기를 기다림
  await server.stop();
  clearTimeout(force);
  process.exit(0);
};

process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

Kubernetes라면 SIGTERM을 받은 직후에도 잠시 동안 로드밸런서가 트래픽을 보낼 수 있으므로, 먼저 헬스체크를 실패로 바꾸고 몇 초 기다린 뒤 stop()을 호출하는 순서가 더 안전합니다. 강제 종료 타임아웃은 terminationGracePeriodSeconds(기본 30초)보다 짧게 잡아야 합니다.

설정과 관측

Bun은 .env 파일을 자동으로 읽어 process.env와 Bun.env에 넣습니다. 편리하지만, 운영 환경에서 실수로 남은 .env 파일이 환경 변수를 덮어쓰지 않는지 확인해야 합니다. 설정은 시작 시점에 한곳에서 읽고 검증해 두면 누락을 빨리 발견할 수 있습니다. 로그는 JSON 형식에 요청 ID를 넣어 요청 단위로 추적할 수 있게 하고, 처리량·오류율·지연(RED 지표)은 런타임과 관계없이 같은 방식으로 수집합니다.

단일 바이너리 배포

bun build --compile은 Bun 런타임과 애플리케이션 코드를 하나의 실행 파일로 묶습니다. 대상 머신에 Bun이나 node_modules를 설치할 필요가 없어 배포가 단순해지지만, 런타임이 포함되므로 파일 크기는 수십 MB 이상입니다. --target=bun-linux-x64-musl처럼 대상 OS·아키텍처·libc를 지정해 교차 컴파일할 수 있는데, Alpine(musl) 이미지에서 glibc용 바이너리를 실행하면 시작조차 되지 않으므로 대상과 정확히 맞춰야 합니다. FFI로 여는 공유 라이브러리나 일부 네이티브 애드온은 실행 환경에 따로 있어야 할 수 있으므로, CI에서 배포와 같은 베이스 이미지로 빌드·실행 테스트를 하는 것이 안전합니다.


트러블슈팅

네이티브 모듈이 로드되지 않을 때는 .node 바이너리가 플랫폼·아키텍처·libc마다 다르다는 점부터 확인합니다. 개발 머신(macOS arm64)에서 설치한 node_modules를 그대로 Linux 컨테이너에 복사하면 맞지 않는 바이너리가 들어갑니다. 컨테이너 안에서 bun install을 실행하고, 패키지가 V8 API에 직접 의존한다면 순수 JS 대체 패키지가 있는지 찾아봅니다.

FFI 호출에서 세그폴트나 이상한 값이 나온다면 거의 항상 시그니처 불일치입니다. int와 long, size_t의 크기가 플랫폼마다 다르다는 점과 구조체를 값으로 넘기는 함수는 지원되지 않는다는 점을 확인하고, 문제를 재현하는 최소 C 라이브러리를 만들어 경계를 좁힙니다.

HTTP 응답은 빠른데 DB나 외부 API 구간에서만 느리다면 런타임이 아니라 네트워크, 커넥션 풀, TLS 핸드셰이크 쪽 병목일 가능성이 큽니다. 같은 조건에서 Node로도 측정해 보면 런타임 차이인지 아닌지 구분할 수 있습니다.

배포 후 메모리가 계속 늘어난다면 JSC 힙 스냅샷을 두 시점에서 받아 비교하고, 응답을 버퍼링하지 않고 스트리밍하는지, 동시 연결 수가 예상보다 많지 않은지 확인합니다. 메모리 상한이 걸린 컨테이너에서는 --smol 플래그로 GC를 더 자주 돌려 피크 메모리를 낮추는 선택지도 있습니다.

리버스 프록시 뒤에서 502가 간헐적으로 나면 프록시의 업스트림 keepalive 타임아웃이 Bun 서버의 유휴 타임아웃(idleTimeout)보다 길지 않은지 봅니다. 서버가 먼저 연결을 닫았는데 프록시가 그 연결을 재사용하려 하면 502가 납니다. WebSocket이라면 Upgrade 헤더 전달과 프록시의 읽기 타임아웃도 함께 확인합니다.


같이 보면 좋은 글