상시 트래픽 시뮬레이터 — 실측(best-effort) 리포트
Overview
로컬 단일 머신에서 백엔드 3 replica + nginx LB 스택을 실제 기동해 부하를 걸고, 도달 가능한 실제 TPS와 병목 지점을 계측했습니다. 20000 TPS(마케팅 스파이크)·3000 TPS(B2C)는 설계 문서(TDD.md)가 “단일 로컬 머신에서는 물리적 미달 예상 — 도전 목표”로 명시한 값이며, 이 리포트는 달성이 아니라 실제 한계와 병목의 관측이 목적입니다.
측정일: 2026-07-06. 측정자: 실측 파이프라인(B 단계).
측정 환경
| 항목 | 값 |
|---|---|
| 호스트 | darwin/arm64, 컨테이너 가용 메모리 15.6GiB, 멀티코어(피크 CPU 관측상 8+ 코어) |
| 런타임 | Docker Desktop (~/.docker/run/docker.sock), 프로젝트 격리 -p sports-loadtest |
| 앱 | sports-backend:loadtest × 3 replica, nginx-lb(host 8088) 단일 진입점 |
| 데이터스토어 | mysql:8.0 · mongo:7.0 · redis:7-alpine(noeviction) · confluentinc/cp-kafka:8.2.2(KRaft) |
| 플래그 | LIMITED_DROP_ENABLED=true, PARTNER_AUTH_ENABLED=true, Redis 게이트 ON, 세마포어 permits=200 |
| 부하 도구 | k6 v1.4.0 (host 설치) |
| 제외 | 관측 스택(Grafana/Prometheus/Loki/Tempo/otel-collector) — Docker Hub pull 차단(bitnami/kafka:3.7 manifest unknown, node/mailhog 미캐시) + ALERT_WEBHOOK_TOKEN 미주입. 병목은 k6 summary + docker stats로 판정 |
base compose의
bitnami/kafka:3.7은 2025년 Bitnami 퍼블릭 이미지 정책 변경으로 익명 pull이 막혀, 캐시된confluentinc/cp-kafka:8.2.2(KRaft 동등 구성)로 교체해 기동했습니다. 이 교체는 실측용 git-ignored 오버레이(docker-compose.loadtest-slim.yml)에만 있고 커밋 대상 compose는 변경하지 않았습니다.
결과 1 — 조회 경로(GET /facilities) 지속 가능 TPS
warm 상태(JIT 컴파일 완료) 고정 도착률(constant-arrival-rate, open model) 측정:
| 목표 rps | 달성 rps | p95 | max | 에러율 | 판정 |
|---|---|---|---|---|---|
| 22 (LOAD-01, 30VU closed) | 21.9 | 259ms | p99 879ms | 0% | 여유 |
| 60 | 59.9 | 459ms | 1.69s | 0% | 지속 가능(권장 상한) |
| 100 | 99.4 | 1.65s | 3.67s | 0% | 지연 열화(에러 0) |
| 150 | 144.8(목표 미달) | 1.97s | 8.12s | 0% | 열화 심화, 달성률 하락 |
| 200+ (ramp) | 붕괴 ~96/s | 60s(timeout) | — | 47~100% | 붕괴 |
- 지속 가능 지점(knee): p95<500ms 기준 약 60 rps. 100
150 rps는 에러 0이나 p95 1.62s로 열화. 200 rps 이상에서 급격히 붕괴. - 붕괴 시 요청이 실패하지 않고 60s 클라이언트 타임아웃까지 큐잉됩니다(아래 “부하 셰딩 부재” 참고).
조회 경로 병목 = 백엔드 JVM CPU
부하 피크 구간 docker stats 관측:
| 컨테이너 | 피크 CPU | 고부하 창 CPU |
|---|---|---|
| backend-1/2/3 | 254~271% (각 ~2.6코어, 3개 합 ~7.8코어) | 189~267% |
| mongodb | 159% | 53% |
| kafka | 128% | 58% |
| mysql | 54% | 29% |
| redis | 9.5% | 8% |
→ 3개 JVM replica가 호스트 CPU 대부분을 소진하며 애플리케이션 티어가 CPU 바운드입니다. 데이터스토어는 여유(mysql 29%·redis 8%). 조회 경로 확장은 수직이 아니라 **수평(더 많은 replica·더 많은 하드웨어)**이 답입니다.
결과 2 — 한정판 오버셀 방지 게이트 (핵심 정확성 검증)
limitedQuantity=2000, perUserLimit=1 회차에 400 rps × 30s(총 4206 요청, 수요 >> 한도)를 몰아넣고 Redis 게이트를 검증했습니다.
| 지표 | 값 | 판정 |
|---|---|---|
Redis reserved:* 키 수 (drop 2) | 정확히 2000 | 한도와 일치 |
Redis buyer:* 키 수 | 2000 | 1인 1건, 중복 없음 |
| 오버셀 | 0건 | ✅ 게이트 PASS |
| 클라이언트 관측 202(accepted) | 228 | (5xx로 대부분 응답 유실) |
| 409 SoldOut(정상 실패) | 0 | 아래 병목으로 5xx가 선점 |
| 5xx(서버 오류) | 3972 | ⚠ 병목 |
오버셀 방지는 완전히 동작합니다 — 4206건이 몰려도 Redis 원자 카운터가 정확히 2000건만 입장시켰고, 한 건도 초과하지 않았습니다.
쓰기 경로 병목 = HikariCP 커넥션 풀 고갈
5xx의 실제 원인(로그):
HikariPool-1 - Connection is not available, request timed out after 30018ms
(total=10, active=9, idle=1, waiting=372)
- 풀 크기가 Spring Boot 기본값 10(replica당) — 3 replica 합쳐도 30 커넥션뿐입니다.
- 400 rps 쓰기에서 372건이 커넥션을 기다리다 30s 타임아웃 → 5xx 폭주. 시나리오 헤더가 예측한 병목 “④ HikariCP 커넥션 풀 고갈”이 그대로 재현됐습니다.
- 세마포어(permits=200)·Stock.@Version 경합은 이 풀 병목에 가려 관측 지점에 도달하지 못했습니다(풀이 먼저 무너짐).
발견된 결함·후속 (follow-up)
| # | 발견 | 심각도 | 근거 |
|---|---|---|---|
| F1 | 예약-주문 불일치(슬롯 누수): Redis 예약 2000건 vs DB goods_orders 1357건 → 643 슬롯이 예약됐으나 주문 미생성. 게이트 통과 후 DB 저장이 풀 타임아웃으로 실패했는데 Redis 예약이 요청 내에서 보상(취소)되지 않음 → 재고 과소판매(under-sell) | 높음 | reserved=2000, goods_orders PENDING=1357 |
| F2 | 부하 셰딩 부재: 과부하 시 요청이 fast-fail하지 않고 60s 타임아웃까지 큐잉. 한 번 무너지면 백로그가 남아 재기동 전까지 지속 열화(붕괴 후 idle에도 47.9s 지연·actuator 타임아웃·CPU 50~161%) | 높음 | 붕괴 후 단건 지연 47.9s, 커넥션 대기 372 |
| F3 | DB 커밋 vs 클라이언트 응답 불일치: DB 주문 1357건 > 클라이언트 202 관측 228건 — 주문이 커밋됐는데 클라이언트는 5xx를 받음(같은 요청 후속 쿼리의 풀 타임아웃/응답 유실). Idempotency-Key가 재시도 중복은 막지만, “실패로 보이는 성공”은 UX·정산 혼선 | 중간 | goods_orders 1357 vs 202 228 |
| F4 | OTLP span export가 localhost:4318(collector 부재)로 지속 실패해 로그 오염. MANAGEMENT_OTLP_TRACING_EXPORT_ENABLED=false로도 OTel SDK exporter가 멈추지 않음 | 낮음 | 백엔드 로그 HttpExporter 반복 |
권장 조치 (우선순위)
- HikariCP
maximum-pool-size상향(예: replica당 30~50) +connection-timeout하향(예: 3s) — 30s 행 대신 빠른 실패. MySQLmax_connections와 균형. - 부하 셰딩 도입(F2) — Tomcat
maxThreads상한 + 초과 거부, 또는 bulkhead/circuit-breaker. 과부하를 fast-fail로 전환해 백로그 누적·지속 열화를 차단. - 예약→주문 원자성/보상(F1) — Redis 예약 후 DB 저장 실패 시 예약을 요청 내에서 취소(saga cancel) 하거나 예약을 outbox 기반으로 확정. 643 슬롯 누수 제거.
- 3000/20000 TPS는 수평 확장 전제 — 앱 티어 CPU 바운드. 단일 머신 물리 한계 확인(설계 예측과 일치, PRD “target, record if unmet” 충족).
- 조회 단건 CPU 비용 조사 — 저부하에서도 p95 259ms/요청당 CPU 큼. facilities Mongo 쿼리·인덱스, Security 필터 체인, 직렬화 점검.
재현·정리
# 기동 (실측 오버레이는 git-ignored)
export DOCKER_CONFIG=/tmp/docker-noauth DOCKER_HOST="unix://$HOME/.docker/run/docker.sock" \
DOCKER_TAG=loadtest MYSQL_ROOT_PASSWORD=root DB_PASSWORD=root DB_USERNAME=root
docker compose -p sports-loadtest \
-f docker-compose.yml -f docker-compose.lb.yml -f docker-compose.loadtest-slim.yml \
up -d mysql mongodb redis kafka backend nginx-lb
# provision → k6 (QA_API_URL=http://localhost:8088)
# 정리 (sports-loadtest 프로젝트만 — stock-*/qa-*/doodlin- 컨테이너·볼륨은 절대 건드리지 않음)
docker compose -p sports-loadtest downk6 산출물: qa/load/results/{facility-search,saturation,oversell}.json, docker-stats.log.
부록 — 머신 최대 튜닝 재측정 (10코어/32GiB)
“가용 스펙 최대로 실 운영형 트래픽” 목표로 스택을 튜닝해 재측정했습니다.
튜닝: backend 3→4 replica, HikariCP 풀 10→40 + connection-timeout 30s→5s, MySQL max_connections 151→500 + buffer pool 1G, JVM 고정 힙 1GB(mem_limit 2.56g), OTel exporter 완전 비활성, 외부 mock 전량 기동(mock-pg 결제·kakao·data-go-kr·solapi·minio·mailhog).
조회 경로 재측정 (warm, 4 replica):
| 목표 rps | 달성 rps | p95 | 에러 | 판정 |
|---|---|---|---|---|
| 100 | 99.9 | 262ms | 0% | 여유 |
| 200 | 199.7 | 122ms | 0% | 여유 |
| 300 | 298.5 | 922ms | 0% | knee 부근 |
| 400 | 398.7 | 2.04s | 0% | 열화 |
| 500 | 498.4 | 1.03s | 0% | 0에러 상한 |
- 4 replica로 조회 knee가 3 replica 대비 크게 상승(60→250~300 rps at p95<500ms, 0에러 상한 ≥500 rps).
- backend CPU 피크 4개 합 ~10.7코어(10코어 호스트 과점유 — 여전히 앱 CPU 바운드). mysql 63%·redis 19%로 DB 여유.
추가 발견 결함:
| # | 발견 | 심각도 |
|---|---|---|
| F5 | /products/{id}·목록·popular 응답 DTO ProductWithStockResponse.imageUrl가 non-null인데 image_url NULL인 상품에서 NPE 500. synthetic 데이터 placeholder로 우회했으나 근본은 DTO nullable 처리 | 높음 |
| F6 | /products/popular 500(clean 상태에서도 재현) — 데이터 patch 후에도 실패. 별도 원인, 미해결 | 높음 |
| F7 | 쓰기 curve는 결제 PG(mock-pg)·이미지(MinIO)·외부 mock 전량 기동이 전제 — 하나라도 없으면 해당 경로 5xx/hang. 오프라인 단일 머신에서 완전 재현이 취약 | 중간 |
24h 상시 운용의 제약(F2 재확인): 부하 셰딩이 없어, 곡선 피크가 머신 한계를 한 번이라도 넘으면 요청이 60s까지 큐잉되고 재기동 전까지 영구 열화합니다(idle에도 47s 지연 관측). 무인 24h 운용은 곡선 피크를 머신 한계보다 충분히 낮게(PEAK_SCALE 보수적) 고정하거나, 로드셰딩(F2)을 먼저 구현해야 안전합니다.
부록 2 — 실 운영형 24h 상시 부하 기동 (결함 수정 후)
F2/F5/F6 결함을 TDD로 수정(브랜치 fix/loadtest-robustness, Kotest 63개 통과)해 이미지 재빌드 후, 실 운영형 24h 일주기 곡선을 상시 기동했습니다.
적용한 결함 수정:
- F5 — 상품 응답 DTO(
ProductWithStockResponse·PopularProductSnapshot·PopularProductCacheDto)imageUrl→ nullable. (엔티티Product.imageUrl자체 nullable 전환은 후속.) - F6 —
GlobalExceptionHandler에MissingServletRequestParameterException→ 400 핸들러 추가(필수 param 누락이 500→400). - F2 —
LoadSheddingFilter(infrastructure): Semaphore 기반 동시요청 상한 초과 시 즉시 503+Retry-After.load-shedding.max-concurrent-requests(기본 200)·enabled(기본 true),/actuator/**제외, 인증 필터 앞단 등록. → 과부하 시 60s 큐잉·영구 열화 대신 fast-fail.
상시 부하 구성:
- 곡선:
qa/load/k6/b2c-diurnal-read.js·b2c-diurnal-write.js·b2b-diurnal.js3종 동시,TIME_SCALE=1(실시간 24h),PEAK_SCALE=0.05. - 피크(점심·저녁): read 105 / write 45 / b2b 5 rps. 심야 저점부터 시작해 곡선을 따라 램프(배민형 식사시간 피크 유지).
PEAK_SCALE은 머신 지속 한계(4 replica read 0에러 상한 ~500 rps) 대비 보수적으로 잡아 무인 24h 안정성 확보. 상향은PEAK_SCALEenv로.- reseed 루프(
qa/load/reseed/reseed.sh, 600s)로 소진성 자원(슬롯·재고·좌석) 멱등 복원 — 24h 연속 쓰기에도 인벤토리 고갈 없음. - 런처:
qa/load/run-sim.sh(git-ignored). 중지:pkill -f 'k6 run.*diurnal'+ reseed pid kill.
메모리 안정화: backend Xmx 768m + mem_limit 1.8g × 4 replica(초기 2.56g에서 1 replica OOM(137) 관측 후 하향) → 컨테이너당 ~950MB 사용, 15.6GiB Docker VM 내 안전 공존.
기동 직후 헬스: 4 replica healthy, 조회 API 16~31ms(200), read 곡선 timeout 0, reseed 사이클 정상. 심야 저점(≈5 rps)에서 램프 시작.
Document History
| 날짜 | 변경 내용 |
|---|---|
| 2026-07-06 | 최초 작성 — 조회 경로 knee·CPU 병목, 오버셀 게이트 PASS, HikariCP 병목, F1~F4 후속 |
| 2026-07-06 | 부록 추가 — 머신 최대 튜닝(4 replica/pool 40) 재측정, 조회 knee 250 |
| 2026-07-06 | 부록 2 — F2/F5/F6 결함 TDD 수정 후 실 운영형 24h 곡선(PEAK_SCALE=0.05, reseed 루프) 상시 기동 |