[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을 복원한다