Skip to content

Repository files navigation

write-path-bench — 비동기 쓰기 경로의 성능 증명

커뮤니티 서비스의 쓰기 활동(좋아요·댓글) 을 처리할 때, 라우터가 DB 갱신·알림까지 동기로 전부 처리하는 방식(naive)핵심 쓰기만 하고 나머지를 BullMQ 워커에 위임하는 방식(async) 을 동일한 하드웨어·부하 조건에서 k6로 실측 비교한 재현 가능한 벤치마크입니다.

이 레포는 블로그 글 피드 랭킹 시스템의 수리적 설계와 인프라적 고찰에서 정성적으로만 서술한 "응답성 극대화 / 처리량 향상 / 병목 제거" 주장을 수치로 증명하기 위한 아티팩트입니다.


📊 결과 (Results)

Naive(동기) vs Async(BullMQ 위임) — 지연 시간과 처리량 비교

지표 Naive (동기) Async (BullMQ 위임) 개선
처리량 (req/s) 126.4 509.3 4.03× ↑
p50 지연 (ms) 382.6 89.9 76.5% ↓
p95 지연 (ms) 493.2 145.1 70.6% ↓
p99 지연 (ms) 543.9 193.5 64.4% ↓
평균 지연 (ms) 392.9 97.9 75.1% ↓
에러율 (%) 0.00 0.00

측정: comment 쓰기 경로 · VUS=50, DURATION=30s, HOT_POSTS=10 · 2026-08-04


무엇을 증명하는가

Naive (before) Async (after)
라우터(요청 스레드) 핵심 INSERT + 카운트 UPDATE + 알림 대상 SELECT + 푸시 전송까지 전부 동기 핵심 INSERT + 큐 enqueue → 즉시 응답
무거운 후속 작업 요청 스레드가 직접 부담 (커넥션 점유 ↑) BullMQ 워커가 백그라운드로 병렬 처리
부하 급증 시 DB 커넥션/락 경합이 요청 지연으로 직결 요청 경로가 얇아 지연이 완만

핵심 주장: 동일 하드웨어에서 사용자 체감 응답(p99)을 크게 낮추고 처리량(RPS)을 끌어올린다. 절대 수치가 아니라 before/after 상대 델타를 판다 — 그래서 어떤 머신에서 돌려도 결론이 재현된다.


요청 경로를 구간별로 쪼개기 (Tracing)

위 표는 집계된 결과(aggregate)다 — "async가 4배 빠르다"는 증명하지만, "그 시간이 정확히 어느 구간에서 사라졌는가" 는 보여주지 못한다. 그래서 OpenTelemetry로 요청을 계측하고 Jaeger로 스팬 워터폴을 뜬다. 자동 계측(express·http·mysql2·ioredis)에 더해, 자동으론 안 잡히는 두 곳을 손으로 메웠다:

  • push.sendsetTimeout으로 모사한 외부 I/O는 스팬이 안 생겨 워터폴에서 빠진다 → 수동 스팬으로 감쌈
  • 큐 경계 — BullMQ enqueue→worker는 프로세스가 갈라져 트레이스가 끊긴다 → 라우터가 W3C 트레이스 컨텍스트를 job data(_otel)에 심고, 워커가 꺼내 같은 트레이스의 자식 스팬으로 실행

그러면 같은 POST /comment 요청 1건을 두 모드에서 실제로 이렇게 뜯어볼 수 있다 (아래는 로컬 Docker에서 뽑은 실 트레이스 — Jaeger 스팬 타이밍을 그대로 그린 것):

요청 경로 트레이스 워터폴 — naive vs async

핵심은 응답 반환 경계선(점선)의 위치다. 두 모드가 하는 일(INSERT·UPDATE·SELECT·알림·push)은 똑같다. 차이는 그 경계가 어디에 찍히느냐뿐이다:

응답 반환 시점 응답 경계 왼쪽(사용자가 대기) 오른쪽(백그라운드)
naive 61.7ms INSERT·UPDATE·SELECT·알림·push 전부
async 10.3ms INSERT + enqueue UPDATE·SELECT·알림·push (worker)

aggregate 표의 지연 델타가 생기는지가 여기서 눈으로 보인다 — async는 무거운 네 구간(UPDATE·SELECT·알림·push ≈ 49ms)이 요청 임계경로에서 통째로 빠지고, 그 일들은 큐 건너 write-path-worker 트레이스로 이어져 응답 이후에 처리된다.

위 수치는 무부하 단건 요청의 구조 분해(median 대표 트레이스)다 — "시간이 어디로 가나"를 본다. 결과 표의 p50/p95/p99는 50 VU 부하 하 집계로, 경합까지 반영된 별개 관점이다.

직접 보는 법: docker compose up 후 부하를 한 번 준 뒤 Jaeger UI 접속 → Service에서 write-path-app 선택 → 트레이스를 열면 위 워터폴이 뜬다. async 트레이스에는 write-path-worker 스팬이 같은 트레이스 안에 이어져 보인다. 위 SVG는 node bench/trace-waterfall.jsresults/trace-sample.json에서 재생성된다.

DNS/네임서버·L4 구간은 로컬 재현에서 사실상 상수라 벤치 대상이 아니다. 여기서 파는 건 앱 라우터 진입 이후의 구간별 소요다.


고찰 — 이 수치를 어떻게 읽을까 (주관)

재현 스택은 실제 서비스의 많은 구간(호스팅·클라 RTT·TLS·네임서버·리버스 프록시·실제 푸시)을 덜어냈다. 그런데도 단순 쓰기 하나에서 이만큼 벌어진다면, 저 구간들이 다 붙는 실제 인프라에선 격차가 오히려 더 커질 여지가 크다고 본다 — naive의 불리함은 결국 무거운 후속을 요청 경로에서 붙들고 있는 시간에서 나오고, 현실 구간이 붙을수록 그 시간과 경합은 커지지 줄지 않기 때문이다. (단, 절대 배수는 아니다 — DB 자체가 병목이면 양쪽 다 눌린다. **"요청 경로를 얇게 유지하는 이점은 복잡한 환경일수록 커진다"**는 방향의 이야기로 둔다.)

왜 Node.js에서 특히 그런가 — 이벤트 루프·커넥션 풀 관점 (펼치기)

Node는 프로세스당 이벤트 루프 하나가 JS를 단일 스레드로 돌린다. 흔한 오해와 달리, DB·Redis 같은 네트워크 I/O 자체는 OS 논블로킹 소켓(epoll/kqueue/IOCP)으로 이벤트 루프가 직접 처리하지 루프를 막지 않는다 — libuv의 기본 4개 스레드풀은 fs·dns.lookup·crypto·zlib용이지 네트워크 쿼리용이 아니다. 그러니 병목의 진짜 축은 "DB IO가 스레드를 잡아먹어서"가 아니라 다음 둘이다:

  1. 커넥션 풀 점유 — naive는 긴 경로(INSERT→UPDATE→SELECT→알림→push) 내내 좁은 풀(여기선 10)의 한 자리를 붙든다. push를 흉내 낸 30ms 대기 동안에도 커넥션을 쥔다. 50 VU가 몰리면 풀이 고갈되고 뒤 요청은 커넥션을 기다리며 줄을 선다 — p99가 튀는 실제 원인이 이거다.
  2. 단일 이벤트 루프라는 공유 자원 — DB IO가 루프를 막진 않아도, 각 await 사이의 JS 연속 작업 (결과 로우 매핑·핸들러 로직·직렬화)은 전부 그 하나의 루프 위에서 인터리빙된다. 요청 경로에 단계를 많이 쌓을수록 고동시성에서 루프가 처리할 연속 작업이 늘어 전체 지연으로 번진다.

그래서 async는 두 겹으로 이득이다. (1) 응답을 INSERT+enqueue 직후에 반환해 커넥션을 즉시 놓아주고, (2) 무거운 후속을 별도 워커 프로세스(= 별도 이벤트 루프·별도 코어)로 넘겨 요청 서빙 루프의 부담에서 통째로 뺀다. 워터폴의 초록 구간이 정확히 그 "다른 프로세스로 넘어간 일"이다.


재현 방법 (Reproduce)

# 1. 인프라 + 앱 기동 (MySQL, Redis, Jaeger, app, worker)
docker compose up -d --build

# 2. 시드 데이터 주입 (게시글/유저)
docker compose run --rm app node db/seed.js

# 3. 벤치 실행 — naive와 async를 순서대로 돌리고 결과를 results/에 저장
#    (windows) powershell -File bench/run.ps1
#    (linux/mac) bash bench/run.sh

bench 스크립트가 하는 일:

  1. MODE=naive로 앱을 띄우고 워밍업 → k6 부하 → results/naive.json 저장
  2. MODE=async로 앱을 재기동하고 워밍업 → k6 부하 → results/async.json 저장
  3. 두 결과를 파싱해 위 결과 표와 그래프를 자동 갱신

부하가 도는 동안(또는 끝난 뒤) Jaeger UI(:16686)에서 개별 요청의 구간별 워터폴을 확인할 수 있다 — 위 Tracing 절 참고.


저장소 구조

write-path-bench/
├─ docker-compose.yml      # mysql, redis, jaeger, app(MODE 토글), worker, k6
├─ .env.example           # 비밀값 없음 — 예시만
├─ db/
│  ├─ schema.sql          # 최소 스키마: users, posts, post_likes, comments, notifications
│  └─ seed.js             # 벤치용 게시글/유저 시드
├─ app/
│  ├─ server.js           # MODE 환경변수로 naive/async 라우터 분기
│  ├─ tracing.js          # OTel 부트스트랩 (--require로 프리로드 → 자동 계측 + OTLP→Jaeger)
│  ├─ trace.js            # push.send 수동 스팬 + 큐 경계 트레이스 컨텍스트 전파
│  ├─ routes/
│  │  ├─ like.naive.js    │  like.async.js
│  │  └─ comment.naive.js │  comment.async.js
│  ├─ queue.js  db.js  redis.js
├─ worker/
│  └─ worker.js           # BullMQ 워커: 무거운 DB 갱신 + 알림 (요청과 같은 트레이스로 이어짐)
├─ bench/
│  ├─ like.js  comment.js       # k6 스크립트
│  ├─ run.ps1  run.sh           # naive→async 오케스트레이션
│  └─ trace-waterfall.js        # Jaeger 트레이스 → 요청 경로 워터폴 SVG 생성
└─ results/               # k6 요약 + 표/그래프(chart.svg) + 트레이스(trace-waterfall.svg)

설계 노트

  • 공정성: naive와 async는 같은 스키마·같은 데이터·같은 부하에서 측정한다. 유일한 차이는 라우터가 일을 동기로 하느냐, 워커에 넘기느냐.
  • 자원 핀: docker-compose.yml에서 app/db/redis에 CPU·메모리 한도를 걸어 실행 간 변동을 줄인다. k6는 별도 컨테이너에서 부하를 생성한다.
  • 정직성: async는 "응답만 빠르고 실제 반영은 나중"이라는 최종 일관성(eventual consistency) 트레이드오프가 있다. 벤치 후 워커 큐가 모두 소진돼 최종 카운트가 naive와 일치함을 검증하는 정합성 체크를 포함한다.
  • 계측 공정성: OTel 트레이싱은 naive/async에 동일하게 걸린다(같은 자동 계측 + 같은 push.send 수동 스팬). 계측 오버헤드가 한쪽에만 유리하게 작용하지 않으므로 before/after 델타를 왜곡하지 않는다. 순수 처리량 수치가 궁금하면 --require ./app/tracing.js 없이 돌리면 계측이 통째로 빠진다.

관련 글

License

MIT © 2024–2026 정필선 (@pill27211) — see LICENSE.

About

동기 vs BullMQ 비동기 쓰기 경로 성능 벤치 — 처리량 4.03×↑, p99 −64%

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages