[BE-04] DropReservationStoreImpl — Redis Lua 게이트 + 세마포어 완충

작업 내용 (설계 의도)

변경 사항

DropReservationStore의 infrastructure 구현(근거 TDD: ../TDD.md “실패 경로·동시성·멱등”, ADR-001·ADR-003). SeatLockStoreImpl(StringRedisTemplate + Lua) 선례를 따른다. Redis 키/TTL/Lua 스크립트 최종 계약은 private-redis-implementer가 선행 산출 — 이 티켓은 그 계약을 받아 어댑터를 구현한다. 롤백: limited-drop.redis-gate.enabled OFF → 폴백(BE-03 fail-open).

  • 원자 판정 Lua(decr-if-positive + 멱등 마커 + 1인 한도)를 단일 스크립트로 실행. 반환 코드 → ReservationResult 매핑(1=Admitted, 2=AlreadyReserved, 0=SoldOut, 3=PerUserLimitExceeded).
  • 순서(ADR-003): Lua 소진 판정(FR-8) 먼저 → Admitted면 인프로세스 Semaphore.tryAcquire(timeout)(FR-7). permit 실패 시 Redis 복원 후 Throttled.
  • confirmSuccess: permit 반납(Redis 유지). cancel: Lua로 DECR·1인 카운트 복원 + permit 반납.
  • seedIfAbsent: SET remaining NX = limitedQuantity + 1인 카운트 초기화, TTL 부여.
  • Redis 장애는 DataAccessException을 그대로/RedisLockException으로 전파(BE-03가 fail-open 처리).
  • 세마포어 permit 수·acquire timeout은 설정값(BE-10 config)에서 주입.
reserve Lua 스케치 (redis-implementer 확정 대상)
-- KEYS[1]=remaining KEYS[2]=buyer:{userId} KEYS[3]=reserved:{key}
-- ARGV[1]=quantity ARGV[2]=perUserLimit ARGV[3]=markerTtl
if redis.call('EXISTS', KEYS[3]) == 1 then return 2 end
local buyer = tonumber(redis.call('GET', KEYS[2]) or '0')
if buyer + tonumber(ARGV[1]) > tonumber(ARGV[2]) then return 3 end
local rem = tonumber(redis.call('GET', KEYS[1]) or '-1')
if rem < tonumber(ARGV[1]) then return 0 end
redis.call('DECRBY', KEYS[1], ARGV[1])
redis.call('INCRBY', KEYS[2], ARGV[1])
redis.call('SET', KEYS[3], '1', 'EX', ARGV[3])
return 1

의존

  • BE-02 (DropReservationStore·ReservationResult), INFRA(Redis 키·Lua 계약 — private-redis-implementer 선행)

다이어그램

클래스 의존

flowchart LR
    DropReservationStoreImpl -.->|implements| DropReservationStore
    DropReservationStoreImpl --> StringRedisTemplate
    DropReservationStoreImpl --> Semaphore

테스트 케이스

  • 재고 100에 동시 500 reserve → 정확히 100건 Admitted·400건 SoldOut (Testcontainers Redis, 원자성)
  • 동일 idempotencyKey 2회 reserve → 두 번째는 AlreadyReserved이고 remaining이 1회만 감소한다
  • perUserLimit=1인데 같은 userId가 2건 요청하면 두 번째는 PerUserLimitExceeded다
  • cancel 호출 시 remaining이 정확히 복원된다
  • seedIfAbsent를 2회 호출해도 remaining이 limitedQuantity로 유지된다(재실행 안전)
  • 세마포어 permit 소진 시 Admitted 대신 Throttled를 반환하고 Redis remaining을 복원한다