커뮤니티 서비스의 쓰기 활동(좋아요·댓글) 을 처리할 때, 라우터가 DB 갱신·알림까지 동기로 전부 처리하는 방식(naive) 과 핵심 쓰기만 하고 나머지를 BullMQ 워커에 위임하는 방식(async) 을 동일한 하드웨어·부하 조건에서 k6로 실측 비교한 재현 가능한 벤치마크입니다.
이 레포는 블로그 글 피드 랭킹 시스템의 수리적 설계와 인프라적 고찰에서 정성적으로만 서술한 "응답성 극대화 / 처리량 향상 / 병목 제거" 주장을 수치로 증명하기 위한 아티팩트입니다.
| 지표 | 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 상대 델타를 판다 — 그래서 어떤 머신에서 돌려도 결론이 재현된다.
위 표는 집계된 결과(aggregate)다 — "async가 4배 빠르다"는 증명하지만, "그 시간이 정확히 어느 구간에서 사라졌는가" 는 보여주지 못한다. 그래서 OpenTelemetry로 요청을 계측하고 Jaeger로 스팬 워터폴을 뜬다. 자동 계측(express·http·mysql2·ioredis)에 더해, 자동으론 안 잡히는 두 곳을 손으로 메웠다:
push.send—setTimeout으로 모사한 외부 I/O는 스팬이 안 생겨 워터폴에서 빠진다 → 수동 스팬으로 감쌈- 큐 경계 — BullMQ enqueue→worker는 프로세스가 갈라져 트레이스가 끊긴다 → 라우터가 W3C 트레이스 컨텍스트를 job data(
_otel)에 심고, 워커가 꺼내 같은 트레이스의 자식 스팬으로 실행
그러면 같은 POST /comment 요청 1건을 두 모드에서 실제로 이렇게 뜯어볼 수 있다
(아래는 로컬 Docker에서 뽑은 실 트레이스 — Jaeger 스팬 타이밍을 그대로 그린 것):
핵심은 응답 반환 경계선(점선)의 위치다. 두 모드가 하는 일(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.js로 results/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가 스레드를 잡아먹어서"가 아니라 다음 둘이다:
- 커넥션 풀 점유 — naive는 긴 경로(INSERT→UPDATE→SELECT→알림→push) 내내 좁은 풀(여기선 10)의 한 자리를 붙든다. push를 흉내 낸 30ms 대기 동안에도 커넥션을 쥔다. 50 VU가 몰리면 풀이 고갈되고 뒤 요청은 커넥션을 기다리며 줄을 선다 — p99가 튀는 실제 원인이 이거다.
- 단일 이벤트 루프라는 공유 자원 — DB IO가 루프를 막진 않아도, 각
await사이의 JS 연속 작업 (결과 로우 매핑·핸들러 로직·직렬화)은 전부 그 하나의 루프 위에서 인터리빙된다. 요청 경로에 단계를 많이 쌓을수록 고동시성에서 루프가 처리할 연속 작업이 늘어 전체 지연으로 번진다.
그래서 async는 두 겹으로 이득이다. (1) 응답을 INSERT+enqueue 직후에 반환해 커넥션을 즉시 놓아주고, (2) 무거운 후속을 별도 워커 프로세스(= 별도 이벤트 루프·별도 코어)로 넘겨 요청 서빙 루프의 부담에서 통째로 뺀다. 워터폴의 초록 구간이 정확히 그 "다른 프로세스로 넘어간 일"이다.
# 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.shbench 스크립트가 하는 일:
MODE=naive로 앱을 띄우고 워밍업 → k6 부하 →results/naive.json저장MODE=async로 앱을 재기동하고 워밍업 → k6 부하 →results/async.json저장- 두 결과를 파싱해 위 결과 표와 그래프를 자동 갱신
부하가 도는 동안(또는 끝난 뒤) 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없이 돌리면 계측이 통째로 빠진다.
MIT © 2024–2026 정필선 (@pill27211) — see LICENSE.