Bun이 빠른 건 맞다 — 그런데 당신의 이벤트 루프가 문제다
Bun으로 바꿔도 p99가 개선되지 않는 이유. 런타임 선택보다 먼저 봐야 할 진짜 병목 지점들.
런타임보다 먼저 봐야 할 것들
Bun이 Node.js보다 빠르다는 건 사실이다. 벤치마크도 있고, 체감도 있다. 그런데 프로덕션 서버를 Bun으로 전환했는데 p99 레이턴시가 그대로인 팀이 꽤 많다. 이유는 간단하다. 대부분의 API 서버에서 병목은 런타임이 아니라 다른 곳에 있기 때문이다.
비유하면 도로를 더 넓혔는데 신호등이 문제였던 거다. 차선이 아무리 늘어나도 신호가 막히면 차는 움직이지 않는다.
이벤트 루프가 막히는 4가지 패턴
데이터베이스 쿼리 레이턴시
가장 흔한 원인이다. 쿼리 하나에 50ms가 걸리면, Bun이든 Node든 그 시간은 그대로다. 런타임은 JavaScript 실행 시간을 줄이는 것이지, 네트워크 왕복을 줄이는 게 아니다. 비유하면 택배 기사를 더 빠른 사람으로 교체해도, 배송 거리 자체는 변하지 않는 것과 같다.
[💡 잠깐! 이 용어는?] p99 레이턴시: 전체 요청 중 가장 느린 1%가 경험하는 응답 시간. 평균보다 실제 사용자 경험을 더 잘 반영하는 지표.
Promise.all과 커넥션 풀 고갈
// 위험한 패턴
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 스파이크다.
// 더 나은 패턴: 한 번의 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를 오래 잡는 코드가 실행되면 다른 요청은 기다려야 한다. 이 상태에서는 런타임이 아무리 빠르더라도 차이가 없다.
// 이벤트 루프를 블로킹하는 코드
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번의 데이터베이스 접근이 발생한다.
// 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/O | JSC 엔진 최적화 | 파일 처리 서버 |
툴링 (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 install과bun test부터 시작한다. 툴링은 사이드 이펙트가 없고 즉각적인 속도 개선을 확인할 수 있다.
마무리
Bun이 빠르다는 사실은 변하지 않는다. 하지만 프로덕션 서버의 응답 시간은 런타임 선택 이전에 결정되는 요소들이 더 크게 작용한다. 쿼리 최적화, 커넥션 풀 설정, 이벤트 루프 블로킹 제거 — 이 세 가지를 먼저 정리한 뒤 프로파일러가 "런타임 I/O가 병목"이라고 말할 때 전환을 검토하는 게 순서다.
참고:
같은 카테고리 · Runtime
비슷한 주제의 최신 글
태그가 겹치는 글
공통 태그가 많을수록 위에 보인다