Bun이 빠른 건 맞다 — 그런데 당신의 이벤트 루프가 문제다

9 min read
BunNode.js이벤트 루프성능데이터베이스
Bun이 빠른 건 맞다 — 그런데 당신의 이벤트 루프가 문제다

런타임보다 먼저 봐야 할 것들

Bun이 Node.js보다 빠르다는 건 사실이다. 벤치마크도 있고, 체감도 있다. 그런데 프로덕션 서버를 Bun으로 전환했는데 p99 레이턴시가 그대로인 팀이 꽤 많다. 이유는 간단하다. 대부분의 API 서버에서 병목은 런타임이 아니라 다른 곳에 있기 때문이다.

비유하면 도로를 더 넓혔는데 신호등이 문제였던 거다. 차선이 아무리 늘어나도 신호가 막히면 차는 움직이지 않는다.


이벤트 루프가 막히는 4가지 패턴

데이터베이스 쿼리 레이턴시

가장 흔한 원인이다. 쿼리 하나에 50ms가 걸리면, Bun이든 Node든 그 시간은 그대로다. 런타임은 JavaScript 실행 시간을 줄이는 것이지, 네트워크 왕복을 줄이는 게 아니다. 비유하면 택배 기사를 더 빠른 사람으로 교체해도, 배송 거리 자체는 변하지 않는 것과 같다.

[💡 잠깐! 이 용어는?] p99 레이턴시: 전체 요청 중 가장 느린 1%가 경험하는 응답 시간. 평균보다 실제 사용자 경험을 더 잘 반영하는 지표.

Promise.all과 커넥션 풀 고갈

src/api/user.ts
// 위험한 패턴
const [user, orders, reviews] = await Promise.all([
  db.query('SELECT * FROM users WHERE id = ?', [userId]),
  db.query('SELECT * FROM orders WHERE user_id = ?', [userId]),
  db.query('SELECT * FROM reviews WHERE user_id = ?', [userId]),
])

Promise.all로 3개 쿼리를 병렬 실행하면 효율적으로 보인다. 그런데 동시 요청이 50개라면 커넥션 150개가 필요하다. 기본 커넥션 풀은 10개다. 결과는 대기열 폭증과 p99 스파이크다.

src/api/user.ts
// 더 나은 패턴: 한 번의 JOIN
const result = await db.query(`
  SELECT u.*, o.id as order_id, r.id as review_id
  FROM users u
  LEFT JOIN orders o ON o.user_id = u.id
  LEFT JOIN reviews r ON r.user_id = u.id
  WHERE u.id = ?
`, [userId])

잘 작성된 JOIN 쿼리 하나가 세 번의 병렬 왕복보다 빠른 경우가 많다.

[💡 잠깐! 이 용어는?] 커넥션 풀(Connection Pool): 데이터베이스 연결을 미리 만들어두고 재사용하는 방식. 풀 크기보다 많은 동시 요청이 오면 대기열이 생긴다.

동기 CPU 블로킹

이벤트 루프가 멈추는 가장 직접적인 원인 중 하나가 동기 CPU 작업이다. Node.js와 Bun은 모두 단일 스레드 이벤트 루프 기반이라, CPU를 오래 잡는 코드가 실행되면 다른 요청은 기다려야 한다. 이 상태에서는 런타임이 아무리 빠르더라도 차이가 없다.

src/utils/crypto.ts
// 이벤트 루프를 블로킹하는 코드
function hashPassword(password: string): string {
  return crypto.scryptSync(password, 'salt', 64).toString('hex')
}
 
// 비동기로 처리해야 한다
async function hashPassword(password: string): Promise<string> {
  return new Promise((resolve, reject) => {
    crypto.scrypt(password, 'salt', 64, (err, key) => {
      if (err) reject(err)
      else resolve(key.toString('hex'))
    })
  })
}

scryptSync, JSON.parse(대용량), 복잡한 정규식 등 동기 CPU 작업은 이벤트 루프 전체를 멈춘다. 이 상태에서는 Bun이든 Node든 차이가 없다.

[💡 잠깐! 이 용어는?] *Sync 함수: scryptSync, readFileSync처럼 Sync 접미어가 붙은 함수들. 작업이 완료될 때까지 메인 스레드를 차단하므로, 이 함수가 실행되는 동안 다른 요청은 이벤트 루프에서 대기한다.

N+1 쿼리 패턴

데이터를 가져올 때 반복문 안에서 쿼리를 한 번씩 더 실행하는 패턴이다. 간단해 보이지만 가장 치명적인 성능 저하를 일으킨다. 비유하면 마트에서 물건 20가지를 살 때 매번 계산대에 들렀다 오는 것과 같다. 한 번에 다 담아서 계산하면 될 것을 20번 왕복하는 거다.

[💡 잠깐! 이 용어는?] N+1 쿼리: 첫 쿼리로 N개 항목을 가져온 뒤, 각 항목마다 1개씩 추가 쿼리를 실행하는 패턴. 총 N+1번의 데이터베이스 접근이 발생한다.

src/api/posts.ts
// N+1 문제
const posts = await db.query('SELECT * FROM posts LIMIT 20')
for (const post of posts) {
  post.author = await db.query('SELECT * FROM users WHERE id = ?', [post.authorId])
}
// 21번의 쿼리가 발생한다
 
// 해결: 일괄 조회
const posts = await db.query('SELECT * FROM posts LIMIT 20')
const authorIds = posts.map(p => p.authorId)
const authors = await db.query('SELECT * FROM users WHERE id IN (?)', [authorIds])

Bun이 실제로 빠른 영역

그렇다면 Bun은 언제 쓰면 좋을까? 런타임 전환이 의미 있는 시나리오는 구체적으로 정해져 있다.

상황Bun의 이점체감 효과
콜드 스타트빠른 시작 시간서버리스/엣지 함수
파일/네트워크 I/OJSC 엔진 최적화파일 처리 서버
툴링 (bun install, bun test)빌드 속도개발 체감 개선
가벼운 프록시 서버낮은 메모리경량 게이트웨이

반면 V8(Node.js)은 지속적인 서버 부하에서 GC 예측 가능성이 더 높다. Bun의 JSC 엔진은 단기 작업에 탁월하지만, 장기 실행 서버에서는 메모리 동작이 덜 예측 가능하다는 보고가 있다.

[💡 잠깐! 이 용어는?] GC(Garbage Collection): 더 이상 사용하지 않는 메모리를 자동으로 정리하는 과정. GC가 예측 가능하다는 건 언제, 얼마나 오래 멈추는지를 파악할 수 있다는 뜻이다. 예측 불가능한 GC는 p99 레이턴시 스파이크의 원인이 된다.


마이그레이션 전 체크리스트

런타임을 바꾸기 전에 이것부터 확인한다.

  • p99 레이턴시의 원인이 CPU인가, I/O인가? 프로파일러로 확인한다.
  • 쿼리에 EXPLAIN ANALYZE를 돌려봤는가? 인덱스 누락이 런타임보다 더 큰 차이를 만든다.
  • 커넥션 풀 크기가 동시 요청 수에 맞는가? 로그에서 pool wait time을 확인한다.
  • 핸들러 내에 동기 CPU 작업이 있는가? 모든 *Sync 호출을 검색한다.
  • N+1 패턴이 있는가? ORM 로그나 쿼리 카운터로 검증한다.

안전한 첫 번째 Bun 마이그레이션: bun installbun test부터 시작한다. 툴링은 사이드 이펙트가 없고 즉각적인 속도 개선을 확인할 수 있다.


마무리

Bun이 빠르다는 사실은 변하지 않는다. 하지만 프로덕션 서버의 응답 시간은 런타임 선택 이전에 결정되는 요소들이 더 크게 작용한다. 쿼리 최적화, 커넥션 풀 설정, 이벤트 루프 블로킹 제거 — 이 세 가지를 먼저 정리한 뒤 프로파일러가 "런타임 I/O가 병목"이라고 말할 때 전환을 검토하는 게 순서다.


참고:

관심 있을 만한 포스트

Bun vs Node.js vs Deno — 뭐가 다른지, 그래서 뭘 쓰면 좋은지 (2026 기준)

런타임 3대장 비교: 호환성(Node), 속도/번들(Bun), 올인원/보안(Deno). 팀/프로덕트 상황별 선택 기준과 체크리스트까지 정리.

BunNode.js

Tsonic — TypeScript를 네이티브 바이너리로 컴파일하는 실험

TypeScript → C# → NativeAOT 파이프라인으로 네이티브 실행 파일을 만드는 Tsonic. 어떻게 동작하고, 어떤 한계가 있는지 살펴봤다.

TypeScriptNativeAOT

React Compiler의 한계 — 뭘 최적화하고 뭘 못 하는가

React Compiler가 자동 메모이제이션으로 해결하는 것과 해결하지 못하는 것. 컴파일러 기반 UI 프레임워크의 능력 경계를 정리했다.

ReactReact Compiler

달리는 차의 엔진을 바꾸다 — Nx 모노레포에서 Bun 도입까지의 여정

Nx 18에서 21까지 버전 업그레이드와 Bun 패키지 매니저 도입을 동시에 진행한 컬리의 마이그레이션 전략과 실전 이슈를 정리한다.

NxBun

Electrobun v1 — Bun으로 14MB짜리 데스크톱 앱을 만든다

Electron의 번들 크기 문제를 Bun 런타임과 네이티브 웹뷰로 해결하려는 새 프레임워크 Electrobun v1이 출시됐다.

ElectrobunBun

Action-Reducer-State의 귀환 — 프론트엔드 패턴이 서버를 점령한 이유

프론트엔드에서 익숙한 Redux의 Action-Reducer-State 패턴을 서버 사이드에 적용한 당근마켓의 이벤트 소싱 라이브러리 Ventyd를 분석한다.

이벤트 소싱Redux

AI 코딩의 맹점 — Artifacts 없이 에이전트는 기억을 잃는다

PRD, ADR, TDD가 AI 코딩 워크플로우에서 왜 선택이 아닌 필수인지, 실전 구조와 함께 살펴본다.

AI 코딩Artifacts

Next-Translate 3.0 — Turbopack과 App Router를 위한 i18n 재건

1년간 공백 후 돌아온 Next-Translate 3.0이 Turbopack 지원, 비동기 params, App Router 안정화를 한 번에 처리하는 방법.

Next.jsi18n