예약 요청이 몰려도 수량 한도를 넘지 않는 방법

예약이 한 자리 남았습니다. 두 사용자가 거의 동시에 화면을 열면 둘 다 available = 1을 볼 수 있습니다. 두 요청 가운데 하나는 응답을 받기 전에 타임아웃이 발생해 같은 명령을 다시 보낼 수도 있습니다. 그 사이 먼저 만든 임시 예약(hold)이 만료되고, 대기 목록의 다음 사용자에게 자리를 넘기는 작업이 시작됩니다. 마침 Redis 연결까지 끊기면 상황은 더 복잡해집니다.
현실에서 부딪히는 문제는 이런 식입니다. 동시 요청, 재시도, 만료 처리를 따로 떼어 놓으면 각각은 어렵지 않습니다. 하지만 모두 같은 예약 수량을 갱신하기 시작하면 “누가 마지막으로 결정하는가”가 분명해야 합니다.
bluetape4k-workshop의 commerce/reservation-control-plane 예제는 이 경계를 다음처럼 나눕니다.
- PostgreSQL은 예약 수량과 상태 전이, 소유권, 멱등 응답을 최종적으로 결정합니다.
- 노드 로컬 벌크헤드는 한 인스턴스가 JDBC 연결을 과도하게 사용하지 않도록 제한합니다.
- Redis 세마포어와 잠금은 요청이 PostgreSQL에 도달하기 전에 경합과 중복 작업을 줄입니다.
- Redis를 사용할 수 없어도 PostgreSQL의 검사를 건너뛰지 않습니다.
PostgreSQL과 Redis 중 어느 쪽이 더 좋은지 비교하려는 예제가 아닙니다. Redis 장애가 예약 정합성 장애로 번지지 않도록 둘의 책임을 분리합니다.
하나의 예제로 전체 흐름을 먼저 보자
섹션 제목: “하나의 예제로 전체 흐름을 먼저 보자”이 글에서는 capacity가 1인 자원을 두고 Alice와 Bob이 경쟁하는 상황을 계속 사용합니다. 설명을 쉽게 하려고 현재 자원 리비전을 42라고 두겠습니다. 42는 예제 흐름을 위한 값일 뿐, 코드에 고정된 값은 아닙니다.
- Alice와 Bob이 리비전
42를 보고 거의 동시에 임시 예약을 요청합니다. - Redis 연결이 실패해도 노드 로컬 경계를 거쳐 두 요청 모두 PostgreSQL의 검사를 받습니다.
- Alice의 조건부 갱신만 성공해
occupiedCount가 1이 되고, Bob의 갱신은 0개 행을 바꿉니다. - Alice의 트랜잭션은 커밋됐지만 응답을 받기 전에 타임아웃이 발생합니다. Alice가 같은
Idempotency-Key로 다시 보내면 저장된 응답을 재생하며 수량은 다시 증가하지 않습니다. - Bob은 FIFO 대기 목록에 들어갑니다. 이후 Alice가 임시 예약을 취소하면 자리를 비우지 않고 Bob에게
ACTIVE제안(offer)을 만듭니다. - Bob이 제안의 소유권, 상태, 리비전, 만료 검사를 통과해 수락하면 확정 예약의 소유자가 Bob으로 바뀝니다.
occupiedCount는 처음부터 끝까지 1입니다.
각 계층은 맡은 일이 다릅니다. 로컬 벌크헤드와 Redis는 PostgreSQL로 들어가는 작업량을 줄이고, PostgreSQL 트랜잭션은 수량·소유권·멱등 응답을 함께 결정합니다.

마지막 한 자리는 한 번의 조건부 갱신으로 지킨다
섹션 제목: “마지막 한 자리는 한 번의 조건부 갱신으로 지킨다”예약 생성 전에 현재 수량을 읽고 애플리케이션 코드에서 available > 0을 검사하면, 두 요청이 같은 값을 읽을 수 있습니다. 읽기와 갱신 사이에 틈이 있기 때문입니다.
예제의 CapacityResourceRepository.tryOccupy()는 상태, 리비전, 수량 조건을 한 SQL UPDATE에 넣습니다.
(table.state eq ResourceState.OPEN) and (table.revision eq expectedRevision) and (table.occupiedCount less table.capacity)조건을 만족한 한 요청만 occupiedCount와 revision을 함께 올립니다. 이미 다른 요청이 리비전을 바꿨거나 capacity에 도달했다면 갱신된 행이 0개이므로 실패합니다. 마지막 한 자리를 줄지는 애플리케이션의 이전 조회 결과가 아니라 PostgreSQL의 원자적 갱신으로 결정합니다.
수량만 올리고 임시 예약 저장에 실패하면 자리만 점유된 채 남는 문제도 생깁니다. ReservationCommandService.hold()는 예약 수량 CAS와 임시 예약 삽입을 같은 Spring 트랜잭션 안에서 실행합니다. 임시 예약을 저장하지 못하면 수량 증가도 롤백됩니다.
여기서 행 잠금이 모든 일을 하는 것은 아닙니다. 새 임시 예약의 마지막 자리 경쟁은 리비전을 포함한 조건부 갱신으로 막습니다. 반대로 취소, 만료, 대기 목록 승격처럼 이미 점유한 자리를 다른 소유자에게 넘길 때는 자원 행을 먼저 잠급니다. 두 방식은 역할이 다르지만 최종 판단 지점은 같습니다.

임시 예약을 취소해도 자리를 바로 비우지 않을 수 있다
섹션 제목: “임시 예약을 취소해도 자리를 바로 비우지 않을 수 있다”예약 제어 플레인에는 수명이 다른 세 가지 객체가 있습니다.
| 객체 | 맡은 역할 | 다음 상태 전이 때 확인할 것 |
|---|---|---|
임시 예약(hold) | 일정 시간 동안 자리를 점유 | 소유자, 상태, 리비전, 정책 버전, 만료 시각 |
| 대기 항목 | 자리가 날 때까지 FIFO로 대기 | 소유자, WAITING 상태, 순번 |
제안(offer) | 특정 대기자에게 자리를 잠시 제안 | 소유자, ACTIVE 상태, 리비전, 만료 시각 |
임시 예약을 취소하거나 만료시켰다고 해서 항상 occupiedCount를 바로 줄이면 안 됩니다. 기다리는 사용자가 있다면 그 자리는 FIFO 첫 번째 대기자에게 넘어가야 합니다. ReservationCapacityHandoffService.promoteOrRelease()는 가장 오래 기다린 대기 항목을 제안으로 승격하고, 대기자가 없을 때만 점유 수량을 반환합니다.
이때 제안을 수락하면서 occupiedCount를 다시 올리지 않습니다. 자리는 이미 이전 임시 예약이 점유하고 있었고, 소유권만 대기 목록의 다음 사용자에게 넘겼기 때문입니다. 한 자리를 반환했다가 다시 얻는 두 동작으로 나누면 그 사이에 다른 요청이 끼어들 수 있습니다.

occupiedCount를 줄입니다.WaitlistCommandService는 자리를 넘기는 트랜잭션을 실행할 때마다 자원 행을 먼저 잠근 뒤 대기 항목과 제안을 읽습니다. 그리고 X-Reservation-Owner에서 만든 다이제스트가 일치하는지, 제안이 아직 ACTIVE인지, 만료 전인지, 리비전이 같은지 확인합니다. 제안 URL을 알았다는 이유만으로 다른 사용자의 자리를 수락할 수는 없습니다.
같은 요청을 다시 받았을 때 재생과 충돌을 구분한다
섹션 제목: “같은 요청을 다시 받았을 때 재생과 충돌을 구분한다”클라이언트는 서버가 명령을 처리했는지 모른 채 응답 대신 타임아웃을 겪을 수 있습니다. 같은 Idempotency-Key로 다시 요청할 때 서버가 무조건 “이미 봤다”고 끝내는 것만으로는 부족합니다. 첫 요청이 끝났는지, 아직 처리 중인지, 같은 키를 다른 명령에 재사용했는지를 구분해야 합니다.
HttpIdempotencyRepository.acquire()는 결과를 다음처럼 나눕니다.
| 결과 | 의미 | HTTP 경계에서의 처리 |
|---|---|---|
New | 이 범위에서 처음 받은 명령 | 명령을 실행하고 응답을 저장 |
Takeover | 이전 처리 리스가 만료됨 | 이전 소유자 토큰과 기한을 CAS로 바꾼 뒤 재실행 |
Replay | 처리가 끝나 응답이 저장됨 | 원래 status와 body를 재생하고 Idempotency-Replayed: true 반환 |
FingerprintConflict | 같은 키로 다른 요청을 보냄 | 409 Conflict |
InProgress | 다른 소유자가 아직 처리 중임 | 중복 실행하지 않고 재시도 가능한 응답 |
키의 범위에는 테넌트와 작업이 들어갑니다. 요청 지문에는 작업, 정규화한 페이로드, 소유자 다이제스트가 들어갑니다. 따라서 같은 문자열 키라도 다른 테넌트나 작업이면 별개이고, 같은 범위에서 페이로드나 소유자가 바뀌면 충돌입니다.
IdempotentReservationCommandService.execute()는 명령으로 발생한 mutation과 재생할 HTTP body를 같은 PostgreSQL transaction에 저장합니다. “예약은 됐는데 재생할 응답은 없다”는 틈을 줄이기 위한 경계입니다.
Redis가 멈춰도 예약 규칙은 PostgreSQL에서 계속 검사한다
섹션 제목: “Redis가 멈춰도 예약 규칙은 PostgreSQL에서 계속 검사한다”Redis를 정합성의 최종 저장소로 사용하지 않는다고 해서 쓸모가 없는 것은 아닙니다. DB 앞에서 불필요한 작업을 줄이고 요청 폭주를 완화하는 데는 잘 맞습니다.
| 경계 | 역할 | 실패했을 때 |
|---|---|---|
| 노드 로컬 벌크헤드 | JDBC 동시 작업 수 제한 | 포그라운드 허가가 소진되면 요청 거부 |
| Redis 세마포어 | 분산 환경의 보조 진입 제어 | Redis 오류면 로컬 경계로 진행, 허가 부족이면 DB 진입 전 요청 거부 가능 |
| Redis 잠금 | 짧은 실행 중 중복 작업 억제 | Redis 오류면 PostgreSQL 멱등 기록에 판단을 맡김 |
| PostgreSQL | 수량, 상태, 소유권, 응답의 최종 권한 | 명령을 실패시키고 Redis만으로는 상태를 변경하지 않음 |
ReservationAdmissionGate는 로컬 벌크헤드를 항상 먼저 거칩니다. Redis 호출에서 예외가 나면 LOCAL_FALLBACK으로 PostgreSQL 작업을 계속합니다. Redis 세마포어가 정상적으로 “허가 없음”을 반환한 경우에는 부하를 줄이기 위해 요청을 거부할 수 있습니다. Redis 장애와 정상적인 진입 거부를 같은 결과로 취급하지 않습니다.
InFlightCommandSuppressor도 Redis 잠금을 얻지 못한 중복 실행을 짧은 시간 막습니다. Redis가 고장 나면 명령을 버리지 않고 PostgreSQL 멱등 기록 행으로 보냅니다. Redis 잠금은 중복 실행을 빠르게 줄이고, PostgreSQL 행은 영속적인 중복 판단 기준이 됩니다.
주의할 점도 있습니다. 이 예제가 사용하는 LettuceSemaphore 허가에는 리스가 없습니다. 프로세스가 허가를 얻은 뒤 finally에서 반환하기 전에 중단되면 허가가 Redis 키에 남을 수 있습니다. 전용 키를 초기화하거나 Redis를 재시작하기 전까지 진입 제어 처리량이 줄어들 수 있습니다. 그래도 PostgreSQL의 수량 조건은 그대로 검사하므로 예약 수량 한도 초과와 허가 누수를 구분해서 대응할 수 있습니다.
실패가 발생해도 어떤 값이 남아야 하는가
섹션 제목: “실패가 발생해도 어떤 값이 남아야 하는가”테스트 이름만 읽어도 의도는 알 수 있지만, 실패가 어떤 순서로 일어나고 마지막에 어떤 값이 남아야 하는지는 잘 보이지 않습니다. 아래 의사코드는 fixture와 HTTP header 같은 준비 코드를 덜어내고, 각 테스트가 고정하는 상태 전이와 검증값만 남겼습니다. 검증 구문은 workshop 예제와 마찬가지로 bluetape4k-assertions의 shouldBeEqualTo를 사용합니다.
ReservationWebIntegrationTest는 PostgreSQLServer로 실제 PostgreSQL을 띄우고 WebTestClient로 실행 중인 HTTP server를 호출합니다. 먼저 동시 요청부터 보겠습니다.
마지막 한 자리에는 한 요청만 들어간다
섹션 제목: “마지막 한 자리에는 한 요청만 들어간다”capacity가 1인 resource에 서로 다른 owner와 idempotency key를 가진 요청 10개를 동시에 보냅니다.
import io.bluetape4k.assertions.shouldBeEqualTo
resource = createResource(capacity = 1, occupiedCount = 0, revision = 0)
responses = parallel(workers = 10) { caller -> POST /api/resources/1/holds { owner = "owner-$caller" idempotencyKey = "hold-$caller" expectedResourceRevision = 0 }}
responses.count { it.status == 201 } shouldBeEqualTo 1responses.count { it.status != 201 } shouldBeEqualTo 9
snapshot = GET(/api/resources).resources.first()snapshot.occupiedCount shouldBeEqualTo 1snapshot.availableCount shouldBeEqualTo 010개 요청이 모두 같은 revision을 보더라도 성공은 하나뿐입니다. HTTP 응답 수만 세지 않고 PostgreSQL에 남은 occupiedCount까지 확인하므로, 중간 계층에서 우연히 요청 아홉 개를 거부한 경우와 데이터베이스가 실제로 수량 한도를 지킨 경우를 구분할 수 있습니다. 다만 요청 10개로 occupiedCount <= capacity 불변식을 검증한 것이지, 장시간 부하나 여러 애플리케이션 인스턴스의 처리량을 측정한 테스트는 아닙니다.
같은 명령은 재생하고, 같은 key의 다른 명령은 거부한다
섹션 제목: “같은 명령은 재생하고, 같은 key의 다른 명령은 거부한다”첫 요청의 transaction이 commit된 뒤 클라이언트가 응답을 잃었다고 가정합니다. 같은 key와 같은 payload로 다시 보내면 저장한 응답을 재생해야 합니다. payload가 달라졌다면 첫 요청의 성공 응답을 재생해서는 안 됩니다.
first = POST /api/resources/1/holds { owner = "alice" idempotencyKey = "hold-42" expectedResourceRevision = 0}
first.status shouldBeEqualTo 201first.header("Idempotency-Replayed") shouldBeEqualTo "false"
retry = POST sameRequest
retry.status shouldBeEqualTo 201retry.header("Idempotency-Replayed") shouldBeEqualTo "true"retry.body shouldBeEqualTo first.body
conflict = POST /api/resources/1/holds { owner = "alice" idempotencyKey = "hold-42" // key는 같지만 expectedResourceRevision = 1 // payload가 달라졌다}
conflict.status shouldBeEqualTo 409conflict.code shouldBeEqualTo "IDEMPOTENCY_FINGERPRINT_CONFLICT"재시도는 임시 예약을 하나 더 만들지 않습니다. 반대로 같은 키를 다른 명령에 재사용하면 409 Conflict가 나옵니다. 키 문자열만 비교하는 것이 아니라 범위와 요청 지문을 함께 저장해야 하는 이유입니다. PostgreSQL 장애 인계 도중 IN_PROGRESS 요청을 어떻게 넘길지는 이 HTTP 테스트의 범위 밖입니다.
취소된 자리는 FIFO 첫 대기자에게 바로 넘긴다
섹션 제목: “취소된 자리는 FIFO 첫 대기자에게 바로 넘긴다”Alice가 마지막 자리를 점유하고 Bob이 waitlist에 들어간 상태에서 Alice가 취소합니다. 대기자가 있으므로 occupiedCount를 0으로 내렸다가 다시 1로 올리지 않고, 점유한 자리의 소유권을 Bob에게 넘깁니다.
aliceHold = createHold(owner = "alice", resource = room)bobEntry = joinWaitlist(owner = "bob", resource = room)
cancel(aliceHold)
aliceHold.state shouldBeEqualTo CANCELLEDbobEntry.state shouldBeEqualTo OFFERED(bobEntry.offerId != null) shouldBeEqualTo trueroom.occupiedCount shouldBeEqualTo 1room.revision shouldBeEqualTo 1핵심 검증값은 occupiedCount == 1입니다. 취소 직후 자리를 풀어 버렸다면 대기 목록 밖의 새 요청이 끼어들 수 있습니다. 이 테스트는 Alice의 임시 예약이 끝난 것과 자원의 자리가 비었다는 것을 같은 사건으로 취급하지 않습니다. 하나의 PostgreSQL 권한 경계 안에서 FIFO 인계를 검증하며, 여러 리전 사이의 소유권 이전까지 증명하지는 않습니다.
Redis가 없어도 PostgreSQL API는 기동한다
섹션 제목: “Redis가 없어도 PostgreSQL API는 기동한다”RedisUnavailableBootIntegrationTest는 Redis URI를 연결할 수 없는 주소로 설정한 뒤 애플리케이션을 시작합니다.
config.redis.enabled = trueconfig.redis.uri = "redis://127.0.0.1:1" // 연결 실패
application.start()
response = GET /api/resources
response.status shouldBeEqualTo 200response.resources.first().code shouldBeEqualTo "demo-room-utc"이 테스트가 증명하는 것은 Redis 연결 실패가 PostgreSQL 기반 조회 API의 기동을 막지 않는다는 사실입니다. Redis가 없어도 같은 부하를 처리한다거나 처리량이 같다는 뜻은 아닙니다. 적어도 애플리케이션의 기동 조건이 Redis 연결 성공에 묶이지 않았음을 확인합니다.
멱등 리스가 끝나기 전에는 다른 소유자가 실행하지 못한다
섹션 제목: “멱등 리스가 끝나기 전에는 다른 소유자가 실행하지 못한다”HttpIdempotencyRepositoryTest는 실제 PostgreSQL에서 New, Replay, FingerprintConflict, InProgress, Takeover를 나눕니다.
record = acquire(scope, fingerprint, ownerA, at = t0)(record is New) shouldBeEqualTo true
beforeExpiry = acquire(scope, fingerprint, ownerB, at = t0 + 30.seconds)beforeExpiry shouldBeEqualTo InProgress(remainingLease = 60.seconds)
afterExpiry = acquire(scope, fingerprint, ownerB, at = t0 + 91.seconds)(afterExpiry is Takeover) shouldBeEqualTo trueafterExpiry.ownerToken shouldBeEqualTo ownerB
finalize(record.id, ownerA, body = "stale") shouldBeEqualTo falsefinalize(record.id, ownerB, body = "fresh") shouldBeEqualTo true
replay = acquire(scope, fingerprint, ownerC, at = t0 + 92.seconds)replay shouldBeEqualTo Replay(status = 201, body = "fresh")lease가 남아 있으면 두 번째 owner는 실행을 시작하지 못합니다. lease가 끝난 뒤에는 takeover할 수 있지만, 이전 owner가 늦게 돌아와 결과를 덮어쓰는 것은 CAS가 막습니다. 처리 권한을 인계하는 것과 과거 실행 결과를 신뢰하는 것은 별개의 문제입니다.
만료된 자리는 다음 대기자에게 넘기거나 실제로 반환한다
섹션 제목: “만료된 자리는 다음 대기자에게 넘기거나 실제로 반환한다”ReservationResourceTransactionServiceTest는 만료된 임시 예약과 제안을 자원 단위 트랜잭션으로 정리합니다. 먼저 대기자가 있는 경우입니다.
room = occupiedResource(capacity = 1)expiredHold = createHold(room, owner = "alice", expiresAt = past)bob = joinWaitlist(room, owner = "bob")
summary = finalizeExpiredResource(room.id, now)
summary.expiredHolds shouldBeEqualTo 1summary.promotedEntries shouldBeEqualTo 1expiredHold.state shouldBeEqualTo EXPIREDbob.state shouldBeEqualTo OFFEREDroom.occupiedCount shouldBeEqualTo 1notification("offer-created").status shouldBeEqualTo PENDING다음 대기자가 있으면 자리를 넘기고 notification intent를 같은 transaction에 남깁니다. 같은 resource를 다시 sweep해도 이미 처리한 hold를 다시 만료시키거나 notification을 하나 더 만들지 않습니다.
반대로 offer가 만료됐고 다음 대기자가 없다면 그때 capacity를 반환합니다.
room = occupiedResource(capacity = 1)onlyWaiter = joinWaitlist(room, owner = "bob")expiredOffer = promote(onlyWaiter, expiresAt = past)
summary = finalizeExpiredResource(room.id, now)
expiredOffer.state shouldBeEqualTo EXPIREDonlyWaiter.state shouldBeEqualTo EXPIREDsummary.promotedEntries shouldBeEqualTo 0room.occupiedCount shouldBeEqualTo 0room.revision shouldBeEqualTo 2여기서는 넘겨줄 소유자가 없으므로 비로소 occupiedCount가 줄어듭니다. 같은 만료 처리라도 대기 목록의 존재 여부에 따라 결과가 달라집니다. 이 테스트는 한 번의 자원 트랜잭션 결과를 검증하지만, 만료 정리 작업이 오래 중단된 뒤 쌓인 적체를 얼마나 빨리 줄이는지는 측정하지 않습니다.
리더로 선출된 정리 작업자만 정해진 범위만큼 만료를 처리한다
섹션 제목: “리더로 선출된 정리 작업자만 정해진 범위만큼 만료를 처리한다”ReservationExpirySweeperTest는 리더 선출과 실제 만료 트랜잭션을 분리해서 확인합니다.
sweeper = ReservationExpirySweeper( instanceId = "node-a", maxResources = 32, tickBudget = 5.seconds)
when (leaderGate.run(slot = "reservation-expiry-sweeper")) { ELECTED -> sweepWork(maxResources = 32, budget = 5.seconds).ranOnce shouldBeEqualTo true SKIPPED -> sweepWork.executions shouldBeEqualTo 0}
insideFirstTick { nested = sweeper.sweepOnce() nested shouldBeEqualTo LocalBusy}리더가 아닌 인스턴스는 PostgreSQL 작업을 시작하지 않습니다. 리더가 된 인스턴스도 한 번에 최대 32개를 5초의 시간 예산 안에서만 처리합니다. 같은 프로세스에서 실행 주기가 겹치면 로컬 단일 실행 제어가 두 번째 실행을 건너뜁니다. 이 테스트는 리더 게이트와 작업량 제한 사이의 계약을 고정하지만, 리더 백엔드 자체의 고가용성을 증명하지는 않습니다.
알림 작업자가 중단돼 재시도해도 알림은 중복 발송되지 않는다
섹션 제목: “알림 작업자가 중단돼 재시도해도 알림은 중복 발송되지 않는다”NotificationOutboxTest는 전송 ID, 선점 리스, 재시도와 공급자 중복 제거를 함께 검증합니다.
deliveryId = "waitlist-30-offered"enqueue(deliveryId)enqueue(deliveryId)outbox.size shouldBeEqualTo 1
first = claim(deliveryId, workerA, lease = 30.seconds)provider.send(first) shouldBeEqualTo RetryableFailuremarkFailed(deliveryId, workerA)
second = claim(deliveryId, workerA, at = nextAttemptAt)provider.send(second) shouldBeEqualTo AcceptedworkerA.crashBeforeMarkDelivered() // outbox에는 완료 기록을 못 남김
recovered = claim(deliveryId, workerB, at = second.time + 31.seconds)provider.send(recovered) shouldBeEqualTo DuplicatemarkDelivered(deliveryId, workerB) shouldBeEqualTo APPLIED
provider.effectCount(deliveryId) shouldBeEqualTo 1outbox[deliveryId].status shouldBeEqualTo DELIVERED공급자가 요청을 받아들인 직후 작업자가 중단되면 아웃박스에는 성공 여부를 기록하지 못합니다. 리스가 끝난 뒤 다른 작업자가 같은 전송 ID로 다시 보내고, 공급자는 이를 중복으로 판단합니다. 아웃박스와 공급자가 같은 트랜잭션을 쓸 수 없으므로 양쪽 모두 변하지 않는 전송 ID를 기준으로 복구해야 합니다. 여기서는 중복 제거가 가능한 가짜 공급자를 사용하므로, 실제 공급자도 같은 전송 ID 계약을 지원하는지는 별도로 확인해야 합니다.
계속 실패하는 delivery는 무한히 재시도하지 않습니다.
policy = RetryPolicy( maxAttempts = 5, initialDelay = 2.seconds, maxDelay = 10.seconds)
repeat(5) { claim(deliveryId) markFailed(deliveryId, policy)}
outbox[deliveryId].status shouldBeEqualTo EXHAUSTEDoutbox[deliveryId].attemptCount shouldBeEqualTo 5outbox[deliveryId].nextAttemptAt shouldBeEqualTo null이 의사코드들이 실패 자체를 없앤다는 뜻은 아닙니다. 동시 요청, Redis 연결 실패, 타임아웃, 만료 경쟁, 리더 경합, 작업자 중단이 발생해도 PostgreSQL의 수량과 소유권, 멱등 응답, 알림 처리 상태가 모순 없이 남는지를 확인합니다.
이 예제가 아직 증명하지 않은 것
섹션 제목: “이 예제가 아직 증명하지 않은 것”이 예제는 단일 애플리케이션 안에서 권한 경계를 구체적으로 보여 주지만, 다음 항목까지 증명하지는 않습니다.
- 여러 리전과 network partition에서 같은 예약 수량의 정합성을 유지하는 방식
- 장시간 부하에서의 처리량과 tail latency
- 누수된 Redis 세마포어 허가를 자동으로 탐지하고 복구하는 정책
- 리더 정리 작업자가 오래 멈춘 뒤 적체를 안전하게 줄이는 운영 절차
- PostgreSQL 장애 인계 중
IN_PROGRESS멱등 요청을 어떻게 인계할지에 대한 운영 정책 - SLO, alert threshold, dashboard 구성
X-Reservation-Owner를 실제 로그인 사용자나 서비스 인증서에 연결하는 인증·인가 경계- 실제 알림 공급자를 운영하는 전용 작업자
이 목록은 구현이 부족하다는 평가가 아닙니다. 테스트로 확인한 범위와 운영 환경에서 다시 증명해야 할 범위를 나누는 체크리스트입니다. 예제 코드를 실제 서비스에 옮길 때는 남은 항목을 시스템 환경에 맞게 채워야 합니다.
예약 확정은 PostgreSQL 커밋 이후에만 일어난다
섹션 제목: “예약 확정은 PostgreSQL 커밋 이후에만 일어난다”예약 수량 한도를 지키는 규칙은 단순해야 합니다. Redis admission을 통과했다고 예약이 확정되는 것이 아닙니다. PostgreSQL의 revision과 수량 조건을 통과하고, 상태·소유권 검사를 거쳐 transaction이 commit돼야 확정됩니다. Redis는 그 앞에서 중복 작업과 경합을 줄일 수 있지만 최종 결정을 대신하지 않습니다.
같은 원리는 재고, 좌석, 쿠폰처럼 수량이 제한된 자원에도 적용할 수 있습니다. 먼저 장애 후에도 남는 저장소를 최종 권한으로 정한 다음, 캐시, 세마포어, 리더 선출을 보조 장치로 붙여야 합니다. 순서를 뒤집으면 보조 장치의 장애가 곧 정합성 장애가 됩니다.
함께 읽을 글:
- 한 건의 예약 가능 시간 조회와 전체 일정 최적화는 다르다
- Transactional Outbox와 Idempotency: Spring, Ktor 예제로 보는 실패 경계
- Transactional Outbox Part 2: Kafka 우선 fallback으로 hot transaction 줄이기
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.