상시 트래픽 시뮬레이터 TDD

Background

근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/상시 트래픽 시뮬레이터/PRD.md (검수 PASS)

선행 설계:

  • ../배포 파이프라인·환경 분리/TDD.md(⑧) — base compose + dev/prod override, prod 대상, compose 파일 소유 경계표(⑦ = docker-compose.lb.yml).
  • ../옵저버빌리티 스택 도입/TDD.md(⑤) — docker-compose.infra.yml·docker-compose.observability.yml, Prometheus scrape·Grafana, env 태그.
  • ../마케팅 이벤트 고부하 대응/TDD.md(③) — 20000TPS 스파이크 대상 엔드포인트 POST /limited-drops/{dropId}/orders.
  • ../B2B 파트너 연동/TDD.md(②) — Authorization: Bearer partner_<keyId>_<random>/api/goods-seller/products·/api/event-host/events 경유.
  • ../도메인 경계 재설계/TDD.md(①) — 도메인 컨텍스트 맵.

qa/load/k6/에는 단발 회귀 시나리오 4종(facility-search·booking-create-throughput·ticket-seat-select-spike·cart-add-item-concurrency)과 공통 헬퍼(lib/auth.js·lib/metrics.js), 멱등 시드 SQL(seeds/cart-add-item-concurrency.sql)이 있다. 이들은 CI·수동 트리거로 단발 실행되는 회귀 도구다(qa/load/README.md — “측정값은 회귀 추세 추적용, 절대치 비교 금지”). 상시(24시간)·일주기·온오프·상태 조회가 가능한 시스템은 없다. docker-compose.yml에는 backend·LB가 없다(⑧가 편입).

Overview

항목내용
무엇을prod 대상 24시간 상시 일주기 트래픽 발생기(k6 ramping-arrival-rate 곡선) + 10분 주기 소진성 자원 재충전(reseed) 배치 + backend 다중 인스턴스(3) + nginx LB + 상태 조회(⑤ Grafana) + 온오프 제어
”일회성 부하 테스트”가 아니라 “실제 운영처럼 24시간 흐르는 상시 트래픽”이 없다. 소진성 자원(슬롯·재고·좌석) 고갈로 상시 트래픽이 붕괴되고, 다중 인스턴스+LB 인프라가 없어 고부하 흡수 전제가 없다
어떻게애플리케이션 도메인 코드·DB 스키마 변경 0. 전부 qa/load/ 부하 자산 + ⑦ 전용 compose 오버레이(docker-compose.lb.yml·docker-compose.sim.yml) + nginx LB 설정 + reseed 컨테이너(멱등 synthetic SQL/Redis 복원). base·dev/prod override(⑧)와 observability(⑤)는 미수정, 추가 -f로만 얹는다
도메인 영향없음 — QA/부하·인프라 계층 작업. Kotlin 코드·Flyway 마이그레이션·Kafka 토픽·Redis 앱 키 신규 0

Terminology

용어정의
일주기 곡선(diurnal curve)24시간을 시간대별 목표 TPS 배율(평시 저부하→점심·저녁 피크)로 표현한 트래픽 강도 곡선
ramping-arrival-ratek6 executor — 구간별 목표 도착률(iterations/sec)을 지정해 서버 부하를 웜업·피크·감쇠시키는 open model executor
arrival rate초당 요청(iteration) 발생률. TPS와 등가로 취급
reseed(재충전)소진된 synthetic 자원(슬롯·재고·좌석)을 baseline으로 복원하는 10분 주기 배치
synthetic 자원부하 전용으로 격리된 데이터(더미 owner_id·상품·시설·이벤트·유저 풀). 실사용자 데이터와 ID 네임스페이스로 구분
유저 풀(user pool)사전 준비된 synthetic 유저·토큰 집합. 대량 동시 인증이 병목이 되지 않게 재사용
추종 오차목표 TPS 대비 실제 도달 TPS의 편차. 10분 버킷 단위로 집계(±10% 이내 버킷 90%)
격차 리포트(gap report)목표 미달 시 달성률(%)과 병목 추정(클라이언트 k6 한계 vs 서버 한계)을 담은 종료 리포트
시간 축약(time-scale)24시간 곡선을 검증용으로 비율 압축(기존 스크립트 1/5 관례) — 곡선 형상 보존

Define Problem

AS-IS

실제 코드(/Users/biuea/sports-application)를 읽어 확인.

요소현재 상태근거
부하 시나리오단발 실행 4종. 고정 부하·단순 스파이크만 표현qa/load/k6/*.js
곡선 표현stages로 선형 ramp만. 24시간 일주기 곡선 없음booking-create-throughput.js:22-27
공통 헬퍼assertSafeTarget(로컬 외 차단), issueToken(setup 1회 발급·전 VU 공유), headerAuth(userId)(X-User-Id)lib/auth.js:13,31,59
임계 헬퍼scenarioMetrics·thresholdsFor (Trend/Rate + p95/p99/errorRate)lib/metrics.js
멱등 시드synthetic 복원 SQL 관례 존재 — cart soft-delete 후 products 1~10 ACTIVE·stock 9999 upsert, owner_id=1 더미seeds/cart-add-item-concurrency.sql
CI 부하 워크플로load-test.ymlworkflow_dispatch 전용, target_url 기본 https://staging.example.com. 자동·상시 실행 없음.github/workflows/load-test.yml
backend·LBcompose에 backend 없음(⑧가 편입 예정), LB 없음. infra/nginx/mcp.conf:22 upstream sports_backend { server 127.0.0.1:8080; } (단일·MCP 전용·TLS placeholder)docker-compose.yml, infra/nginx/mcp.conf
서버 설정포트 8080, context-path·전역 prefix 없음 → @RequestMapping 경로가 곧 실제 경로application.yml:2

소진성 자원 실측 (reseed 대상):

자원소비(쓰기)복원 수단(기존)조회인증
Slot capacity (가용 = capacity − active 예약수)POST /bookings취소 POST /bookings/{id}/cancel(자동 복원), capacity 증설 PATCH /facilities/{fid}/slots/{sid}, 신규 POST /facilities/{fid}/slotsGET /facilities/{fid}/slots소비/취소=X-User-Id, 조회=permitAll
Stock.quantity (@Version 낙관 락)POST /goods-ordersStock.restore(가산), POST /api/goods-seller/products/{id}/stock/restore(가산·GOODS_SELLER)GET /products, /products/{id}소비=X-User-Id, restore=Bearer+GOODS_SELLER, 조회=permitAll
Seat 락 (Redis seat:lock:{eventId}:{seatId})POST /events/{id}/seats/select, POST /ticket-orders명시 POST /events/{id}/seats/release, 주문취소 자동 unlock, Redis 300s TTL 자동 만료GET /events, /events/{id}(가용성 포함)대부분 X-User-Id

근거: GoodsDomainService.kt#restoreStock(:58)·#restoreProductStock(:238), Stock.kt#restore(:32), BookingDomainService.kt#cancel(:122), SlotDomainService.kt#updateSlot(:31), TicketingDomainService.kt(:41 SEAT_LOCK_TTL=300s, :108 releaseSeats), SecurityConfig.kt:56-81.

문제점:

  1. 상시 구동·온오프·상태 조회 시스템 부재 — 단발 스크립트만 존재.
  2. 일주기 곡선 미정의 — 모든 스크립트가 고정/단순 ramp.
  3. 마케팅 스파이크(20000TPS) 예약 시각 자동 트리거 메커니즘 부재.
  4. backend 단일·LB 부재 — 다중 인스턴스 전제 미충족.
  5. B2C 3000TPS 상시 쓰기 시 소진성 자원 고갈 → 이후 쓰기가 409로 수렴 → 곡선 붕괴. reseed·조회 위주 구성 부재.
  6. 전용 admin reseed API 없음 — 복원은 도메인별 개별 경로뿐.

TO-BE

  • qa/load/k6/lib/에 일주기 곡선 빌더·유저 풀·격차 리포트 헬퍼 신설. 기존 4종 회귀 스크립트는 무변경 유지.
  • B2C 조회(70%)·쓰기(30%)·B2B(100TPS)·마케팅 스파이크(20000TPS) 상시 스크립트 신설.
  • reseed 컨테이너(10분 cron, 멱등 synthetic SQL/Redis 복원, 윈도 내 분산).
  • docker-compose.lb.yml(⑦) — backend replicas: 3 + nginx LB(infra/nginx/lb.conf 신규). docker-compose.sim.yml(⑦) — k6 러너·reseed·제어 컨테이너.
  • 상태 조회 = ⑤ Grafana 대시보드(k6 Prometheus remote-write) + k6 구조화 로그. 온오프 = compose 컨테이너 lifecycle + graceful ramp-to-0.

Architecture Benchmarking (의무)

제품/사례해결 방식참고할 패턴미참고 사유
k6 ramping-arrival-rate executor (grafana.com/docs/k6 — ramping-arrival-rate)구간별 목표 도착률(iters/s)을 stages로 지정하는 open model. VU 수가 아니라 도착률을 목표로 유지(서버 지연과 무관하게 부하 강도 고정)채택 — 일주기 곡선·스파이크를 도착률 stage 시퀀스로 표현. 목표 TPS 추종(±10%) 측정이 open model에서 자연스러움ramping-vus(closed model)는 VU 수만 제어해 서버가 느려지면 TPS가 함께 떨어져 “목표 TPS 추종” 측정 불가 → 미채택
k6 Prometheus remote write output (grafana.com/docs/k6 — prometheus remote write)k6가 실시간 메트릭을 Prometheus remote_write로 push → Grafana에서 라이브 조회채택 — FR-6 상태 조회(실제 도달 TPS·활성 시나리오)를 ⑤ Grafana로 실현. 커스텀 상태 API(앱 코드) 불필요k6 Cloud·전용 대시보드 서버는 로컬 개인 규모에 과함, ⑤ 스택 재사용이 단순
배달앱 피크타임 일주기 패턴 (PRD 벤치 — 월급쟁이부자들 k6 도입기)점심(11:3013:00)·저녁(18:0020:00) 급증, 그 외 평시. 일주기 배율 곡선채택 — B2C 24시간 곡선의 형상(심야 저부하 + 2회 피크) 참조 모델. 시간대별 배율표로 구현지역별 분포·요일 편차는 PRD Non-Goals → 단일 일주기 배율만
Grafana k6-operator / distributed k6 (github.com/grafana/k6-operator)여러 러너로 부하를 수평 분산해 단일 노드 한계를 넘김개념(20000TPS 미달 시 러너 수평 확장)만 참고 — Open Question 대비Kubernetes 전제. 개인 단일 호스트에는 과함 → 단일 러너 + 리소스 상한 + 격차 리포트로 대체, 확장은 재검토

Possible Solutions

방안 비교 — 일주기 곡선 표현 (ADR-001)

방안설명왜 채택 / 미채택
A. ramping-arrival-rate + 배율표 → stage 확장기시간대별 목표 TPS 배율표(JSON)를 lib/diurnal.jsstages[{target, duration}]로 확장. read 0.7×/write 0.3×, B2B 0.033× 스케일. TIME_SCALE로 24h↔압축채택 — open model로 목표 TPS 추종 측정이 정확. 곡선이 데이터(배율표)로 선언돼 테스트·검토 용이. 기존 thresholdsFor 재사용
B. constant-arrival-rate 다단 시나리오 나열시간대마다 고정 도착률 시나리오를 startTime으로 이어붙임미채택 — 계단식이라 피크 진입/감쇠의 완만한 곡선 표현 불가, 시나리오 수 폭증
C. ramping-vus(closed model)VU 수를 곡선대로 증감미채택 — 서버가 느려지면 TPS가 동반 하락해 목표 TPS 추종·달성률(FR-8) 측정 불가

방안 비교 — 소진성 자원 reseed (ADR-002)

방안설명왜 채택 / 미채택
A. 멱등 synthetic SQL/Redis 복원 (윈도 내 분산)reseed 컨테이너가 synthetic ID 범위에만 절대값 복원 — stocks.quantity reset, synthetic 예약 정리(슬롯 해방), synthetic ticket_order 정리. 좌석 락은 300s TTL 자연 만료. 10분 윈도를 청크·jitter로 분산채택 — 기존 seeds/*.sql 관례와 정합. 멱등(절대 복원 → 드리프트 없음), synthetic 격리, 앱 요청 부하 0(NFR “재충전이 관측 대상에 스파이크 유발 금지”에 정확히 부합). 앱 코드·마이그레이션 0
B. 기존 도메인 복원 엔드포인트 호출/api/goods-seller/products/{id}/stock/restore(가산)·/bookings/{id}/cancel·/events/{id}/seats/release를 reseed가 호출미채택(부분 보조) — ① 가산 restore는 반복 호출 시 재고 무한 증가(드리프트) ② GOODS_SELLER 토큰 필요·개별 booking ID 추적 필요 ③ 관측 대상 앱에 reseed 요청 부하가 실려 측정 왜곡(NFR 위반). 단 SQL이 부적합한 극단(도메인 불변식 필요)에서만 폴백
C. 앱에 admin reseed API 신설POST /admin/load/reseed 등 신규 컨트롤러미채택 — 애플리케이션 코드·엔드포인트 신설(무변경 원칙 위반), prod 앱에 부하 전용 경로 상주. 지금 규모에 과함

reseed는 도메인의 복원 의미(restore semantics) 를 그대로 활용하되(재고 quantity·슬롯 가용·좌석 TTL), 실행은 synthetic 시드행에 대한 멱등 SQL로 한다 — “기존 도메인 메서드 활용”과 “관측 왜곡 금지” NFR을 동시에 만족.

방안 비교 — backend 다중 인스턴스 + LB (ADR-003)

방안설명왜 채택 / 미채택
A. docker-compose.lb.yml 오버레이 — backend replicas:3 + nginx LB(신규 lb.conf)⑦ 전용 파일에서 backend를 deploy.replicas:3(내부 8080만)으로 override, nginx LB가 80/443 노출. upstream sports_backend를 Docker DNS backend:8080로 재정의(resolver + 변수 proxy_pass로 replica 재해석)채택 — ⑧ 경계표가 ⑦에 부여한 파일. base·override 미수정, 추가 -f로 병합. sports_backend upstream 이름 재사용(mcp.conf 무수정, 신규 lb.conf)
B. mcp.confsports_backend를 in-place로 다중 서버로 수정기존 파일에 server backend-1..3 추가미채택 — mcp.conf는 MCP 전용·TLS placeholder·127.0.0.1:8080(컨테이너 내 미해석). in-place 수정은 MCP 동작 변경 + Single Writer 충돌. 신규 lb.conf가 안전
C. compose --scale backend=3 CLI만파일 없이 실행 시 스케일미채택 — 선언적이지 않아 재현성 낮음, nginx upstream이 replica를 못 봄(정적 서버 목록)

방안 비교 — 상태 조회·제어 수단 (FR-6/FR-7, ADR-004)

방안설명왜 채택 / 미채택
A. ⑤ Grafana(k6 remote-write) + 구조화 로그 + compose lifecycle 제어실제 도달 TPS·활성 시나리오·거부율을 k6→Prometheus→Grafana로. 목표 곡선은 참조선. 제어는 make sim-up/down/pause(graceful ramp-to-0)채택 — 앱 코드 0, ⑤ 스택 재사용. FR-6 “최소 보장(상태 조회)” 충족, 구현 수단은 TDD 재량(PRD 위임)
B. 커스텀 상태 API 서버(BE)시뮬레이터 상태 전용 HTTP 엔드포인트미채택 — 앱/신규 서버 코드 유발. FR-6은 조회 수단만 요구, Grafana로 충분(단순함 우선)

Detail Design

도메인 바운디드 컨텍스트 경계 판단 (의무)

  • 결정: 신규 도메인 분리 없음, 기존 도메인 합류도 없음. 이 과제는 부하·QA·인프라 계층 작업으로 애플리케이션 도메인 코드(Kotlin)·DB 스키마·Kafka 토픽·Redis 앱 키를 만들거나 바꾸지 않는다.
  • 산출물은 전부 부하/인프라 자산: qa/load/k6/**(스크립트·라이브러리), qa/load/reseed/**(멱등 SQL·스크립트), qa/load/provision/**(synthetic 시드), docker-compose.lb.yml·docker-compose.sim.yml, infra/nginx/lb.conf, Grafana 대시보드 JSON, 제어 스크립트.
  • 미채택(신규 도메인 생성) 사유: 트래픽 시뮬레이터는 라이프사이클·데이터 소유가 없는 외부 부하 발생기다. 도메인 경계 대상이 아니며, 대상 도메인(booking/goods/ticketing/②partner/③limited-drop)은 그 엔드포인트를 외부에서 호출당할 뿐 이 과제가 그 코드를 소유·변경하지 않는다.
  • 도메인 간 참조: 시뮬레이터는 대상 앱과 HTTP(k6) 및 synthetic DB/Redis(reseed)로만 접점. 앱 내부 코드 결합 0.

서버 토폴로지 설계 (의무)

과제 특성 = “외부 부하 발생(비동기 지속) + 주기 배치 + 대상 앱 수평 확장”. 요청-응답 처리 서버를 새로 만드는 게 아니라, 부하를 생성·격리·분산한다.

서버/컨테이너 형태후보 검토채택 여부
부하 발생기(k6 러너) 컨테이너상시 트래픽·스파이크 생성. 관측 대상과 리소스 분리(NFR)채택docker-compose.sim.ymlk6-runner. CPU·메모리 상한 명시(측정 왜곡 방지)
reseed 배치 컨테이너10분 주기 synthetic 자원 복원. 주기 실행채택sim.ymlreseed(cron loop 또는 sleep-loop, 윈도 내 분산)
backend API 서버(대상)요청-응답. 고부하 흡수 위해 수평 확장채택 — replicas:3(⑧ 단일 정의 위에 ⑦가 override). ⑤가 3개 타깃 scrape
nginx LBreplica 앞단 분산 + 헬스체크채택docker-compose.lb.yml + infra/nginx/lb.conf. Docker DNS round-robin
상태 조회 전용 서버FR-6 상태 노출미채택 — ⑤ Grafana + k6 remote-write로 대체(앱/신규 서버 코드 0)
스케줄러 전용 서버마케팅 스파이크 예약 트리거미채택(전용 서버) — k6 시나리오 startTime(연속 곡선 내 오프셋) 또는 reseed 컨테이너의 cron이 지정 시각에 스파이크 스크립트 기동. 별도 서버 불필요

결론: 부하 발생기·reseed는 관측 대상과 격리된 별도 컨테이너(NFR 리소스 분리), 대상 backend는 3 replica + nginx LB. 상태·제어는 ⑤ Grafana + compose lifecycle. 모두 ⑦ 오버레이 파일 + 별도 컨테이너 → prod 앱 코드 무영향.

compose 파일 소유 경계 (⑤⑧과 확정, Single Writer)

실행 시 여러 -f로 병합. ⑦는 자기 파일만 추가하고 base·override·observability는 건드리지 않는다.

파일소유책임
docker-compose.yml(base)backend·MySQL·Mongo·Redis·Kafka·mock 정의(내부 포트)
docker-compose.{dev,prod}.yml환경별 포트·APP_ENV·볼륨·태그
docker-compose.infra.yml·docker-compose.observability.yml데이터 인프라·관측 스택(Prometheus·Grafana·Tempo·Loki·exporter)
docker-compose.lb.ymlbackend deploy.replicas:3(내부 8080만 노출) + nginx-lb 서비스(80/443, infra/nginx/lb.conf 마운트)
docker-compose.sim.ymlk6-runner(부하 발생, 리소스 상한, Prometheus remote-write) + reseed(10분 배치) + 제어. mock/앱 미정의
infra/nginx/lb.confLB용 nginx server + upstream sports_backend { server backend:8080; }(Docker DNS resolver). mcp.conf 무수정
  • 병합 예(prod + LB + 관측 + 시뮬): docker compose -p sports-prod -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.observability.yml -f docker-compose.lb.yml -f docker-compose.sim.yml up -d.
  • docker-compose.lb.yml이 backend host 포트 노출을 제거(replica 포트 충돌 방지)하고 nginx-lb가 유일 진입점. prod override의 backend 직접 노출은 LB 오버레이가 덮는다(⑧ 경계표 명시).

nginx LB 설계 (FR-1)

  • replicas:3이면 compose가 backend를 3개 인스턴스로 띄우고 정적 host 포트를 못 준다 → backend는 내부 8080만. nginx-lb가 80/443 노출.
  • Docker 임베디드 DNS(127.0.0.11)가 backend에 3개 IP를 반환. nginx는 정적 upstream이면 첫 IP만 캐시하므로, resolver 127.0.0.11 valid=10s + 변수 proxy_pass(set $pool backend; proxy_pass http://$pool:8080)로 매 요청 재해석 → replica 라운드로빈.
  • 인스턴스별 헬스체크: docker-compose.lb.yml backend healthcheck(/actuator/health), nginx proxy_next_upstream error timeout http_502 http_503로 비정상 replica 우회.
  • upstream sports_backend 이름 재사용(mcp.conf 관례 계승), 실체는 lb.conf에서 backend:8080으로 재정의. mcp.conf는 무수정(Single Writer).

인터페이스·계약 (구현자 간 해석 차이 제거 — 병렬 authoring SSOT)

곡선 배율표 계약 (lib/diurnal.js가 소비, 스크립트가 인용):

시간대(시)배율(peak 대비)시간대(시)배율
00–050.0513–170.55
06–080.10 → 0.3018–200.95 → 1.00(저녁 피크)
09–110.45 → 0.70210.50
11:30–13:001.00(점심 피크)22–230.20 → 0.08
  • 목표TPS(t) = round(peak × 배율(t)). B2C peak=3000, B2B peak=100. read=0.7×B2C, write=0.3×B2C.
  • 곡선 빌더 시그니처: buildStages(peakRate, curve, {timeScale}) -> [{target, duration}]. timeScale=1 → 실시간 24h, timeScale=0.2 → 압축(기존 1/5 관례).

공통 헬퍼 시그니처 (lib/ 확장, 기존 auth/metrics 재사용):

// lib/diurnal.js
export function b2cReadStages(timeScale)   // peak 2100 (=3000*0.7)
export function b2cWriteStages(timeScale)  // peak 900  (=3000*0.3)
export function b2bStages(timeScale)       // peak 100
export function spikeStages()              // 0 → 20000 (30s 내) → steady → 감쇠

// lib/pool.js  (FR-9 유저 풀·토큰 재사용)
export function syntheticUserId(vu)        // synthetic ID 범위 매핑 (X-User-Id)
export function partnerAuthHeaders()       // Bearer partner_<keyId>_<random> (env 주입, 전 VU 공유)

// lib/gapreport.js  (FR-8 격차 리포트)
export function handleSummary(data, {targetPeak, scenarioId})
  // 달성률(%) = 실제 http_reqs rate / targetPeak
  // 병목 추정: dropped_iterations>0 || vus==maxVUs → "client(k6)", 5xx율↑ || p95↑ → "server"
  // qa/load/results/<scenarioId>-gap.json + stdout 요약

synthetic 격리 계약 (provision·reseed·스크립트 공유):

자원synthetic 범위격리 근거
유저X-User-Id = 900000+ 범위(부하 전용), email synthetic+<n>@loadtest.local실사용자 ID·이메일과 겹치지 않음
상품products.owner_id = 1(더미), id 부하 풀(기존 시드 관례 계승)seeds/*.sql 관례
시설·슬롯부하 전용 facility·slot id 풀실데이터 미접촉
B2B 파트너synthetic Partner + API Key(②의 POST /api/admin/partners로 사전 발급)실파트너와 구분
마케팅 drop③의 POST /limited-drops로 대량 수량 synthetic drop 사전 개설실판매 회차와 구분

시스템 역할 경계 (의무)

단위역할소유/책임노출 인터페이스의존
lib/diurnal.js곡선 → stage 확장배율표·ramping-arrival-rate stage 생성b2cRead/Write/b2b/spikeStages()(없음)
lib/pool.jssynthetic 신원·토큰 재사용X-User-Id 매핑·partner 헤더syntheticUserId·partnerAuthHeadersenv 주입
lib/gapreport.jsFR-8 격차 리포트달성률·병목 추정handleSummaryk6 summary data
b2c-diurnal-read.jsB2C 조회 70% 곡선facility/goods/event 조회 부하k6 시나리오lib·provision
b2c-diurnal-write.jsB2C 쓰기 30% 곡선booking/order/ticket 쓰기 부하k6 시나리오lib·provision·reseed
b2b-diurnal.jsB2B 100TPS 곡선·1000건/일goods-seller/event-host 등록k6 시나리오lib·provision(파트너 키)
marketing-spike.js20000TPS 스파이크③ limited-drop ordersk6 시나리오lib·provision(drop)
qa/load/reseed/*10분 자원 복원synthetic SQL/Redis 멱등 복원(윈도 분산)reseed.shMySQL·Redis(synthetic 범위)
qa/load/provision/*synthetic 시드·발급유저 풀·파트너 키·drop 사전 준비provision.shadmin API·SQL
docker-compose.lb.yml다중 인스턴스+LBbackend replicas·nginx-lbcompose 병합base(⑧)·lb.conf
docker-compose.sim.yml발생기·reseed·제어k6-runner·reseed 컨테이너·리소스 상한compose 병합스크립트·reseed·⑤ Prometheus
infra/nginx/lb.confLB 라우팅upstream·resolver·헬스 페일오버nginxbackend 서비스
Grafana 대시보드 JSONFR-6 상태 조회도달 TPS·활성 시나리오·거부율·목표 참조선·reseed 이력⑤ Grafana 프로비저닝⑤ Prometheus

실패 경로·동시성·멱등 (의무)

시나리오처리
소진성 자원 고갈 → 쓰기 409 연속정상 비즈니스 응답(5xx 아님). read 70%/write 30% 배분 + 10분 reseed로 다음 주기 복원. 409율 대시보드 노출, 특정 시간대 50% 초과 구간 없음 확인(Success Metric)
목표 TPS 미달(20000 등)실패로 중단 금지. handleSummary가 달성률·병목(client vs server) 기록(FR-8). NFR: 스파이크 ±30초 내 20000 시도, 미달 시 리포트 대체
reseed 실행 중 스파이크reseed는 청크·jitter로 10분 윈도 분산 → 순간 부하 스파이크 미유발(NFR). synthetic 범위만 write
reseed 부분 실패(일부 SQL 오류)멱등 절대 복원이라 다음 주기 재실행이 자동 수렴. 실패는 로그 + Grafana reseed 이력에 표기, 트래픽 중단 없음
k6 러너 자원 포화컨테이너 CPU/메모리 상한(NFR 측정 왜곡 방지). dropped_iterations → “client 한계”로 병목 태깅
replica 1개 다운nginx proxy_next_upstream로 우회, 남은 replica로 계속. ⑤ up{job="backend"} 감지
시뮬레이터 중지k6 gracefulStop으로 진행 중 iteration 완료 후 도착률 0 수렴(User Scenario 5). 컨테이너 SIGTERM
synthetic 데이터 누적reseed가 synthetic 쓰기 결과(예약·주문·잠금)를 주기 정리 → 무한 성장 방지. 실데이터 미접촉
동시성시뮬레이터 자체는 상태 없음(stateless 부하). 대상 앱의 동시성(재고 @Version·좌석 락·slot 비관락)은 각 도메인 소유 — 시뮬레이터는 부하만 인가
멱등reseed = 절대값 복원(반복 안전). provision = INSERT ... ON DUPLICATE/seedIfAbsent(재실행 안전). k6 쓰기는 Idempotency-Key(goods-order·ticket-order) 규약 준수

상태 전이 표 — 시뮬레이터 lifecycle (FR-7)

현재 상태 × 이벤트다음 상태비고
정지 × sim-up구동중k6-runner·reseed 컨테이너 up, 곡선 시작
구동중 × 곡선 진행구동중시간대별 목표 TPS 추종
구동중 × 예약 스파이크 시각 도달스파이크중marketing-spike 시나리오 startTime 트리거(±30초)
스파이크중 × 감쇠 완료구동중일주기 곡선 복귀
구동중 × sim-pause일시정지k6-runner stop(graceful ramp-0), reseed·LB 유지
일시정지 × sim-up구동중러너 재기동
구동중/일시정지 × sim-down정지graceful 0 수렴 후 컨테이너 down
임의 × 목표 50% 미만 지속(상태 유지)⑥ 알림 source=simulator(대상 앱 장애와 구분), 중단 없음

Component Diagram (Mermaid flowchart LR)

flowchart LR
    subgraph Sim["시뮬레이터 컨테이너 (격리)"]
        K6["k6-runner (곡선/스파이크)"]
        RS["reseed (10분 배치)"]
    end
    subgraph LB["nginx-lb"]
        NG["upstream sports_backend"]
    end
    subgraph App["backend replicas x3"]
        B1["backend-1"]
        B2["backend-2"]
        B3["backend-3"]
    end
    subgraph Data["synthetic 자원"]
        DB["MySQL/Redis"]
    end
    subgraph Obs["⑤ 관측"]
        PROM["Prometheus"]
        GRAF["Grafana 상태 대시보드"]
    end
    K6 -->|HTTP 부하| NG
    NG --> B1
    NG --> B2
    NG --> B3
    B1 --> DB
    RS -->|멱등 복원| DB
    K6 -->|remote-write| PROM
    PROM --> GRAF

Sequence Diagram — 일주기 곡선 + reseed + 스파이크

sequenceDiagram
    participant Op as 운영자
    participant K6 as k6-runner
    participant LB as nginx-lb
    participant BE as backend replicas
    participant RS as reseed
    participant GR as Grafana(⑤)
    Op->>K6: sim-up (곡선 시작)
    loop 시간대별 목표 TPS
        K6->>LB: arrival-rate 부하
        LB->>BE: 라운드로빈 분산
        BE-->>K6: 200/409(자원 소진 시)
        K6->>GR: 도달 TPS remote-write
    end
    loop 10분 주기
        RS->>BE: (synthetic DB 직접 복원)
    end
    Op->>K6: 예약 스파이크 시각
    K6->>LB: 0→20000 TPS (±30초)
    K6->>GR: 달성률·병목 gap report

ERD

해당 없음 — 이 과제는 DB 스키마·컬렉션을 신규·변경하지 않는다(Flyway 마이그레이션 0건). reseed는 기존 테이블의 synthetic 시드행을 멱등 복원할 뿐 스키마를 만들지 않는다. 선행 인프라 DB 작업 없음.

Testing Plan

부하/인프라 자산이므로 Kotest 앱 테스트 대신 정적 검증 + 구동 검증 + 부하 실측이 테스트다.

레벨대상검증 방법
정적k6 스크립트k6 inspect <script>로 시나리오·stage 파싱 유효(exit 0). 배율표→stage 확장 단위 검증(합계·피크 시점)
정적compose·nginxdocker compose -f ... config 병합 유효(exit 0), nginx -t(lb.conf), hadolint(reseed 이미지)
단위(곡선)lib/diurnal.js배율표 입력 → stage 배열: 피크 시각 target=peak, 심야 target≈0.05×peak, timeScale 압축 비례. read+write 합 = B2C 목표
단위(리포트)lib/gapreport.jsmock summary → 달성률·병목 분류(dropped_iterations→client, 5xx↑→server) 정확
통합(LB)nginx-lb + replicas3 replica 기동, 반복 요청이 3개 인스턴스에 분산(access log·env/host 태그), 1개 down 시 페일오버
통합(reseed)reseed 배치synthetic stock 소진 상태 → reseed 후 quantity=baseline, 실데이터 미접촉, 윈도 내 분산(순간 write 스파이크 없음)
부하(추종)B2C 곡선압축 실행 → 10분 버킷 추종 오차 ±10% 이내 버킷 90%↑, read:write ≈ 70:30
부하(스파이크)marketing-spike20000TPS 도전, ±30초 도달 시도, 도달 TPS·병목 gap report 생성, 5xx 분리 측정
부하(B2B)b2b-diurnal100TPS 피크 곡선, 하루 누적 1000건↑ 환산(압축 시 비례)
시나리오(E2E)상시 구동sim-up→곡선 구동→예약 스파이크→감쇠→sim-down graceful 0 수렴. Grafana 상태 노출

핵심 실패 경로 시나리오:

  • write 곡선을 reseed 없이 구동 → 특정 시간대 409율 50% 초과 재현(reseed 필요성 실증), reseed 켜면 50% 미초과 수렴.
  • 러너 리소스 상한에서 20000TPS 미달 → gap report가 “client(k6) 한계”로 병목 태깅(중단 없음).
  • reseed가 실사용자 데이터를 건드리지 않음(synthetic 범위 밖 UPDATE 0건) 검증.

Release Scenario — 무중단 배포 (의무)

이 과제는 전부 additive 오버레이(신규 부하 자산 + -f 오버레이 compose + 신규 nginx conf + 별도 컨테이너)다. 애플리케이션·base·override(⑧)·observability(⑤)를 수정하지 않으므로 기존 서비스는 무영향. LB 도입만 트래픽 경로 전환을 수반한다.

배포 순서 (오버레이 = 피처 플래그 등가)

  1. Expand(부하 자산 추가)qa/load/k6/lib/**·스크립트·reseed/**·provision/**를 dev에 머지. 기존 4종 회귀 스크립트·seeds/*.sql 무변경. -f로 얹기 전엔 아무 컨테이너도 안 뜸 → 기존 흐름 무영향.
  2. provision(synthetic 준비)provision.sh로 synthetic 유저 풀·B2B 파트너 키·마케팅 drop 사전 발급(멱등). 실데이터 미접촉.
  3. LB 오버레이 전환(무중단)docker-compose.lb.yml-f로 추가해 replicas:3 + nginx-lb 기동. 전환 전 backend 직접 노출과 병존 가능(expand) → nginx 헬스 확인 후 진입점을 nginx로 전환 → 구 직접 노출 포트 제거(contract).
  4. 시뮬레이터 기동docker-compose.sim.yml-f로 추가, make sim-up. 곡선·reseed 시작. ⑤ Grafana에서 도달 TPS 확인.
  5. 관측 연동(⑤) — k6 remote-write 대상 = ⑤ Prometheus(remote-write receiver 활성 필요, ⑤와 조율). k6 대시보드 JSON을 ⑤ Grafana에 프로비저닝.

단계별 전환 조건

  • 1→2: k6 inspect·compose config·nginx -t 통과.
  • 2→3: provision 멱등 재실행 안전 + synthetic 자원 baseline 확인.
  • 3→4: 3 replica healthy + nginx 라운드로빈·페일오버 확인.
  • 4→5: 압축 곡선 추종 오차 ±10% 90%↑ + reseed 409 억제 확인.

롤백 방법 (단계마다, 전부 플래그/오버레이 OFF)

단계롤백
부하 자산 머지PR revert(파일 삭제). 기존 회귀 스크립트 무영향
provisionsynthetic 시드 정리 SQL(범위 한정 delete). 실데이터 무접촉
LB 오버레이-f docker-compose.lb.yml 미적용(오버레이 OFF) → backend 직접 노출 복귀. nginx-lb down
시뮬레이터make sim-down(graceful 0 수렴) → -f docker-compose.sim.yml down. 대상 앱 무영향
reseedreseed 컨테이너만 stop. 곡선은 계속(read 위주라 즉시 붕괴 아님)
관측 연동k6 output 옵션 제거(로컬 summary만). ⑤ 스택 무영향

Observability (조건부 — 신규 부하 시스템·외부 연동)

관측 지표원천노출
실제 도달 TPS·활성 시나리오k6 http_reqs rate + scenario 라벨 → remote-write⑤ Grafana(FR-6 상태)
목표 대비 격차·달성률·병목handleSummary gap reportGrafana annotation + results/*-gap.json(FR-8)
시간대별 추종 오차(10분 버킷)도달 TPS vs 목표 곡선 참조선Grafana(Success Metric ±10%/90%)
소진성 409율(자원별)k6 status 태그별 RateGrafana(50% 초과 구간 감시)
다음 마케팅 스파이크 예정 시각reseed/제어 cron 설정Grafana text 패널(Operations)
reseed 배치 실행 이력reseed 컨테이너 로그 → Loki(⑤)Grafana(Operations)
replica 분산·헬스up{job="backend"} + nginx access logGrafana
  • 알람: 목표 TPS 50% 미만 지속 → ⑥ 지능형 장애 알림 채널, source=simulator 태깅(관측 대상 prod 앱 장애와 구분 — PRD Operations). 시뮬레이터 자체 크래시(24시간 0건 목표) 감지.
  • 알람 규칙·채널 자체는 ⑥ 소관. 본 과제는 지표·태깅 소스만 제공.

형제 과제 접점 (②③⑤⑧⑥)

과제접점경계·의존
⑧ 배포·환경 분리⑦ lb.yml/sim.yml이 ⑧ base+prod override에 -f로 얹힘. 대상=prod⑧가 backend 서비스·prod 스택 선행 제공. ⑦는 base/override 무수정(⑧ 경계표 준수)
⑤ 옵저버빌리티k6 remote-write → ⑤ Prometheus, 상태 대시보드 = ⑤ Grafana⑤ 의존: Prometheus --web.enable-remote-write-receiver 활성 + k6 대시보드 프로비저닝. ⑦는 ⑤ 파일 무수정(대시보드 JSON을 산출물로 전달, 조율)
③ 마케팅 고부하marketing-spike.js → ③ POST /limited-drops/{dropId}/orders③ 의존: limited-drop 엔드포인트·대량 수량 synthetic drop. 20000TPS·오버셀0은 ③ 소관, ⑦는 부하 인가·도달 TPS 기록
② B2B 파트너b2b-diurnal.js → ② Bearer partner_../api/goods-seller/products·/api/event-host/events② 의존: partner 인증·POST /api/admin/partners(synthetic 파트너 키 발급). ⑦는 그 키로 100TPS·1000건/일 부하
⑥ 지능형 알림목표 50% 미만 지속 통지, source=simulator 태깅⑥ 소관(알림 규칙·채널). ⑦는 지표·태깅 소스 제공
① 도메인 경계대상 도메인 엔드포인트 확인직접 의존 없음(HTTP 외부 호출)

Open Questions

  • 20000TPS 물리 도달성(PRD Open Q): 개인 머신 단일 러너로 20000TPS는 미달 가능. 목표는 유지하되(Success Metric) handleSummary가 도달 TPS·병목(client vs server)을 기록(FR-8). 확장은 distributed k6/클라우드 러너로 M4 중 재검토(벤치 k6-operator).
  • synthetic 데이터 정리 주기(PRD Open Q): reseed가 10분마다 synthetic 쓰기 결과(예약·주문·좌석)를 정리하되, 장기 누적분은 일 1회 심야(배율 최저 구간) 전체 정리 배치를 reseed 컨테이너 cron에 추가. ID 네임스페이스(900000+·owner_id=1·loadtest.local)로 실데이터와 구분.
  • prod 스택 물리 분리(PRD Open Q): ⑧ 결정에 따름(-p sports-prod 네임스페이스). ⑦는 prod 스택 위에 오버레이.
  • ⑤ remote-write receiver 활성: ⑤ Prometheus 기동 플래그 조율 필요(⑦가 ⑤ 파일 미수정). 미활성 시 임시로 k6 로컬 summary + 별도 경량 output로 폴백.

Document History

날짜변경 내용
2026-07-03최초 작성 — AS-IS 실측(k6 4종·seeds 관례·소진성 자원 복원 메서드·nginx upstream), 일주기 곡선(ramping-arrival-rate 배율표), reseed(멱등 synthetic SQL·앱 부하 0), backend replicas+nginx LB(lb.yml·lb.conf), 상태=⑤ Grafana, compose 소유 경계(⑤⑧), 무중단 오버레이 배포, ②③⑤⑧⑥ 접점, ADR 4건·티켓 9건