상시 트래픽 시뮬레이터 — 실측(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달성 rpsp95max에러율판정
22 (LOAD-01, 30VU closed)21.9259msp99 879ms0%여유
6059.9459ms1.69s0%지속 가능(권장 상한)
10099.41.65s3.67s0%지연 열화(에러 0)
150144.8(목표 미달)1.97s8.12s0%열화 심화, 달성률 하락
200+ (ramp)붕괴 ~96/s60s(timeout)47~100%붕괴
  • 지속 가능 지점(knee): p95<500ms 기준 약 60 rps. 100150 rps는 에러 0이나 p95 1.62s로 열화. 200 rps 이상에서 급격히 붕괴.
  • 붕괴 시 요청이 실패하지 않고 60s 클라이언트 타임아웃까지 큐잉됩니다(아래 “부하 셰딩 부재” 참고).

조회 경로 병목 = 백엔드 JVM CPU

부하 피크 구간 docker stats 관측:

컨테이너피크 CPU고부하 창 CPU
backend-1/2/3254~271% (각 ~2.6코어, 3개 합 ~7.8코어)189~267%
mongodb159%53%
kafka128%58%
mysql54%29%
redis9.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:* 키 수20001인 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
F3DB 커밋 vs 클라이언트 응답 불일치: DB 주문 1357건 > 클라이언트 202 관측 228건 — 주문이 커밋됐는데 클라이언트는 5xx를 받음(같은 요청 후속 쿼리의 풀 타임아웃/응답 유실). Idempotency-Key가 재시도 중복은 막지만, “실패로 보이는 성공”은 UX·정산 혼선중간goods_orders 1357 vs 202 228
F4OTLP span export가 localhost:4318(collector 부재)로 지속 실패해 로그 오염. MANAGEMENT_OTLP_TRACING_EXPORT_ENABLED=false로도 OTel SDK exporter가 멈추지 않음낮음백엔드 로그 HttpExporter 반복

권장 조치 (우선순위)

  1. HikariCP maximum-pool-size 상향(예: replica당 30~50) + connection-timeout 하향(예: 3s) — 30s 행 대신 빠른 실패. MySQL max_connections와 균형.
  2. 부하 셰딩 도입(F2) — Tomcat maxThreads 상한 + 초과 거부, 또는 bulkhead/circuit-breaker. 과부하를 fast-fail로 전환해 백로그 누적·지속 열화를 차단.
  3. 예약→주문 원자성/보상(F1) — Redis 예약 후 DB 저장 실패 시 예약을 요청 내에서 취소(saga cancel) 하거나 예약을 outbox 기반으로 확정. 643 슬롯 누수 제거.
  4. 3000/20000 TPS는 수평 확장 전제 — 앱 티어 CPU 바운드. 단일 머신 물리 한계 확인(설계 예측과 일치, PRD “target, record if unmet” 충족).
  5. 조회 단건 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 down

k6 산출물: 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달성 rpsp95에러판정
10099.9262ms0%여유
200199.7122ms0%여유
300298.5922ms0%knee 부근
400398.72.04s0%열화
500498.41.03s0%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 — GlobalExceptionHandlerMissingServletRequestParameterException → 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.js 3종 동시, 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_SCALE env로.
  • 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 250300 rps, F5F7 결함, 24h 무인 운용 제약
2026-07-06부록 2 — F2/F5/F6 결함 TDD 수정 후 실 운영형 24h 곡선(PEAK_SCALE=0.05, reseed 루프) 상시 기동