0.5단계 — 한정판 구매 결함(F1·F3) 재현 검증 기준선 리포트

근거 티켓: Tickets/FIX-01-한정판-예약누수-재현검증-하네스-복원.md 근거 실측: 상시 트래픽 시뮬레이터/실측-리포트.md(측정일 2026-07-06) L58-93 “결과 2 — 한정판 오버셀 방지 게이트” · “발견된 결함·후속”

측정일: 2026-07-30. 대상 커밋: origin/main 89ae6c24. 측정 환경: 로컬 docker compose 단일 인스턴스(-p sports-fix01, backend+mysql+redis+mongodb+kafka), sports-backend:fix01(해당 커밋에서 직접 빌드).

결론 (먼저)

F1(예약-주문 누수)·F3(DB커밋-클라이언트응답 불일치)가 재현되지 않았습니다. 실측-리포트.md 측정 조건(400 rps × 30s, limitedQuantity=2000)을 그대로 재현하고, 나아가 원 리포트보다 더 공격적으로 HikariCP 커넥션 풀을 실제로 고갈시킨 조건(아래 시도 4)에서도 Redis 예약 수 = DB 주문 수 = k6 202 응답 수 = 2000건으로 매 시도 정확히 일치했습니다.

FIX-02~04의 전제(“F1·F3 미해결”)가 흔들립니다. 원인은 원 리포트 측정(2026-07-06) 이후 LimitedDropDomainService.kt에 이미 보상(compensation) 로직이 반영돼 있기 때문으로 추정됩니다 — 상세는 “근본 원인 분석” 참고. FIX-02~04 착수 전에 이 로직이 실제로 F1·F3를 해소하는지, 아니면 다른 경로(동시성 레이스 등)로 여전히 재현 가능한지 재확인이 필요합니다.

시도별 요약

#조건5xx 수reservedorderk6 202누수응답불일치비고
1기본 설정(F2 활성)7615 / 12001200020002000005xx 전부 LoadSheddingFilter의 즉시 503(F2 이미 수정됨) — DB에 도달조차 못 함
2LOAD_SHEDDING_ENABLED=false0 / 4241(미측정)(미측정)2000지연만 최대 29.2s, HikariCP 타임아웃(30s) 미도달. k6 maxVUs 부족으로 dropped_iterations 7760건 발생
3시도2 + maxVUs=80000 / 10658(미측정)(미측정)2000지연 최대 31.44s로 근접했으나 5xx 0건. 로컬 1인스턴스는 CPU 경합이 없어(원 리포트는 3 replica가 각 250%+ CPU) 커넥션 회전이 빨라 실제 고갈에 못 미침
4시도3 + HIKARI_MAXIMUM_POOL_SIZE=2 + CONNECTION_TIMEOUT=3000ms146 / 863420002000200000원 리포트와 동일한 에러 시그니처 재현(HikariPool-1 - Connection is not available, request timed out after 3001ms (total=2, active=2, idle=0, waiting=399), 원 리포트는 waiting=372) — 그럼에도 최종 상태는 완전 일치
  • 시도 2·3은 verify 스크립트를 실행하지 않고 k6 요약만으로 사전 스크리닝(HikariCP 타임아웃이 로그에 없어 5xx=0으로 이미 판단 가능했기 때문). 시도 1·4만 verify-limited-drop.sh로 정식 검증했습니다.
  • 시도 1·4 raw 실행 로그: qa/load/results/goods-limited-drop-baseline-run.log(시도1), goods-limited-drop-baseline5-run.log(시도4) — results/.gitignore로 커밋 제외(레포 관례, qa/load/README.md 참고). 재현 필요 시 아래 “재현 방법”으로 다시 생성하세요.

시도 4 상세 (가장 공격적 조건 — 결정적 근거)

측정 조건

  • 한정판: limitedQuantity=2000, perUserLimit=1, product 9000001 (owner 9000000)
  • k6: QA_DROP_EXECUTOR=constant-arrival-rate, QA_DROP_RATE=400, QA_DROP_DURATION=30s, QA_DROP_PRE_ALLOCATED_VUS=2000, QA_DROP_MAX_VUS=8000
  • 백엔드: LOAD_SHEDDING_ENABLED=false(F2 우회 — 원 리포트가 F2 미해결 상태였음을 재현), SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE=2, SPRING_DATASOURCE_HIKARI_CONNECTION_TIMEOUT=3000(Spring Boot 표준 프로퍼티 — 애플리케이션 코드 무변경)

관측값 (verify-limited-drop.sh 실행 결과)

[verify-limited-drop] === FIX-01 한정판 재현 검증 시작 (dropId=8, productId=9000001, limitedQuantity=2000) ===
[verify-limited-drop] MySQL 접속 경로: compose
[verify-limited-drop] 관측값: reserved(Redis)=2000 remaining(Redis)=0 order(DB)=2000 accepted(k6 202)=2000
[verify-limited-drop] 판정 1/3 누수(F1): 0건 통과 (reserved=2000, order=2000)
[verify-limited-drop] 판정 2/3 응답 불일치(F3): 0건 통과 (order=2000, accepted=2000)
[verify-limited-drop] 판정 3/3 오버셀(회귀 보호): 없음 통과 (order=2000, limitedQuantity=2000)
[verify-limited-drop] === 검증 종료 (종료코드 0) ===
  • k6 202(LOAD_05_accepted_total) = 2000, 5xx(LOAD_05_server_error_total) = 146, 409/425/403(LOAD_05_rejected_total) = 6482, 총 요청 8628건 (goods-limited-drop-baseline5-summary.json)
  • 백엔드 로그에 실제 HikariCP 타임아웃 721건 관측(docker logs sports-fix01-backend-1 | grep -c "HikariPool-1 - Connection is not available") — 원 리포트(L77-83)와 동일한 로그 시그니처:
    HikariPool-1 - Connection is not available, request timed out after 3001ms (total=2, active=2, idle=0, waiting=399)
    

왜 leak=0인가 (근본 원인 분석)

backend/src/main/kotlin/com/sportsapp/domain/goods/service/LimitedDropDomainService.kt:251-270(persistWithThrottle)에 이미 아래 보상 로직이 존재합니다.

return try {
    val order = persistOrder(drop, command)
    if (restoreOnFailure) dropReservationStore.confirmSuccess(drop.id, command.userId, command.idempotencyKey)
    order
} catch (exception: Exception) {
    if (restoreOnFailure) dropReservationStore.cancel(drop.id, command.userId, command.quantity, command.idempotencyKey)
    throw exception
} finally {
    dropReservationStore.releaseThrottle()
}

persistOrder(DB INSERT)가 SQLTransientConnectionException(HikariCP 타임아웃 포함 모든 예외)을 던지면 catch 블록이 즉시 Redis 예약(reserved:* 마커 + remaining 카운터)을 취소하고 예외를 그대로 재전파합니다. @Transactional(UseCase)이 걸려 있어 DB 트랜잭션도 롤백되므로, “예약은 남았는데 주문은 없다”는 상태가 애초에 생기지 않습니다 — 실패한 요청은 클라이언트에게 5xx를 반환하는 동시에 Redis 슬롯을 되돌려주므로, 뒤이은 다른 요청이 그 슬롯을 다시 채워 최종적으로 reserved == order == 2000으로 수렴합니다. 이 로직은 원 리포트 권장 조치 #3(“예약→주문 원자성/보상”)과 정확히 일치하는 해결책이며, 실측-리포트.md 측정 시점(2026-07-06) 이후 반영된 것으로 추정됩니다(정확한 커밋은 git blame으로 후속 확인 필요 — 이 리포트 범위 밖).

F3(DB커밋됨 vs 클라이언트 202 미수신)도 같은 이유로 재현되지 않습니다 — @Transactional 경계 안에서 예외가 발생하면 커밋 자체가 일어나지 않고, 커밋된 주문은 예외 없이 정상적으로 202를 반환하는 흐름만 남기 때문입니다.

F2(부하 셰딩)도 이미 해결됨 (참고)

시도 1에서 관측된 7615건의 5xx는 전부 LoadSheddingFilter(backend/src/main/kotlin/com/sportsapp/infrastructure/loadshedding/LoadSheddingFilter.kt)의 즉시 503 응답이었습니다 — 원 리포트 F2(“부하 셰딩 부재”)도 이미 해결된 상태입니다. 이 필터는 load-shedding.enabled 런타임 플래그로 즉시 끌 수 있게 설계되어 있어(코드 변경 없이 시도 2~4에서 비활성화), 원 리포트 조건을 재현할 수 있었습니다.

재현 방법 (환경 재구성)

# 1) 이미지 빌드 (docker-credential-osxkeychain 깨짐 시 우회 — memory: docker-cred-helper-broken-bypass)
DOCKER_CONFIG=< config 디렉토> DOCKER_HOST="unix://$HOME/.docker/run/docker.sock" \
  docker build -t sports-backend:fix01 -f backend/Dockerfile backend
 
# 2) 최소 스택 기동 (backend+mysql+redis+mongodb+kafka, 별도 프로젝트 -p sports-fix01로 완전 격리)
#    오버레이 파일은 이 리포트 저장소 밖(git 미추적)에 별도 보관 — LIMITED_DROP_ENABLED=true,
#    APP_JWT_SECRET(고정값), kafka 이미지를 confluentinc/cp-kafka:8.2.2로 대체(bitnami/kafka:3.7
#    pull 차단, 실측-리포트.md L27과 동일 사유) 등을 담는다.
DOCKER_TAG=fix01 docker compose -p sports-fix01 -f docker-compose.yml -f <오버레>.yml \
  up -d backend mysql redis mongodb kafka
 
# 3) 시드 적용 (product 9000001 + stock) — seller_type 컬럼 NOT NULL(V61/V62) 반영 필요(본 티켓에서 발견·수정)
docker exec -i sports-fix01-mysql-1 mysql -uroot -proot sports < qa/load/seeds/goods-limited-drop-spike.sql
 
# 4) k6 실행 — dropId는 stdout의 `[LOAD-05] dropId=...`에서 파싱
QA_API_URL=http://localhost:18080 QA_JWT_SECRET=<APP_JWT_SECRET과 동일> \
  QA_DROP_EXECUTOR=constant-arrival-rate QA_DROP_RATE=400 QA_DROP_DURATION=30s \
  QA_DROP_PRE_ALLOCATED_VUS=2000 QA_DROP_MAX_VUS=8000 QA_LIMITED_DROP_QUANTITY=2000 \
  k6 run --summary-export=results/goods-limited-drop-summary.json \
    k6/goods-limited-drop-spike.js 2>&1 | tee results/goods-limited-drop-run.log
 
# 5) 검증
MYSQL_HOST= COMPOSE_PROJECT_NAME=sports-fix01 \
  DROP_ID=<run.log에서> PRODUCT_ID=9000001 LIMITED_QUANTITY=2000 \
  K6_SUMMARY_JSON=results/goods-limited-drop-summary.json \
  ORDER_SINCE_TIMESTAMP="<k6 실행 직전 UTC 시각>" \
  ./verify-limited-drop.sh

발견 사항 (하네스 결함, 이 티켓 범위에서 최소 수정)

측정 하네스를 복원하는 과정에서 시나리오 자체가 이미 깨져 있던 3가지를 발견하고, “측정 하네스와 문서만 수정”이라는 티켓 범위 안에서 최소 수정했습니다(애플리케이션 Kotlin 코드는 무변경).

  1. 인증 모델 불일치(선행 결함): SecurityConfig.kt가 AUTH-04(X-User-Id → JWT 전환)로 /limited-drops/**(GET 제외)를 authenticated()로 승격했으나, goods-limited-drop-spike.js는 여전히 headerAuth()(X-User-Id)를 사용해 모든 요청이 401이었습니다. lib/auth.jsjwtAuth(userId)(클라이언트가 QA_JWT_SECRET으로 직접 HS256 서명, /auth/login 왕복 없이 다수 synthetic 유저를 저비용 재현)를 추가해 전환했습니다.
  2. 시드 SQL 스키마 드리프트: qa/load/seeds/goods-limited-drop-spike.sqlproducts.seller_type(V61/V62, NOT NULL) 컬럼을 채우지 않아 INSERT가 실패했습니다. seller_type='B2C'를 추가했습니다.
  3. dropId 노출 경로 부재: k6 요약 JSON에는 setup() 반환값이 담기지 않아, verify-limited-drop.sh가 Redis 예약 키를 SCAN할 dropId를 알 방법이 없었습니다. setup()console.log([LOAD-05] dropId=...)를 추가했습니다.

후속 (FIX-02~04 담당자 확인 필요)

  • FIX-02~04 착수 전 재확인: F1·F3의 “이미 고쳐짐” 여부를 git log -p -- backend/src/main/kotlin/com/sportsapp/domain/goods/service/LimitedDropDomainService.kt 등으로 확인해, 정말 이 리포트의 결론(보상 로직이 F1·F3를 해소함)이 맞는지, 혹은 이 리포트가 놓친 다른 레이스(예: 동시에 두 요청이 같은 idempotencyKey로 들어와 AlreadyReserved 분기가 얽히는 경우, restoreOnFailure=false일 때의 fail-open 분기 등)에서 여전히 재현되는지 별도 조사가 필요합니다.
  • F2 상태 갱신: 실측-리포트.md의 “발견된 결함·후속” F2도 이미 해결된 것으로 보이므로, 그 문서 자체의 갱신 여부는 문서 소유자 판단이 필요합니다(이 리포트는 FIX-01 범위이므로 원본 리포트를 직접 수정하지 않았습니다).
  • results/*.json·*.log 미보관: .gitignore(qa/load/.gitignore:74-75 등 레포 관례)로 커밋 대상에서 제외됩니다. 이 리포트가 원시 산출물을 대체하는 요약이며, 재현이 필요하면 “재현 방법” 절차로 다시 생성해야 합니다.