중앙 잠금장치가 여러 재고 슬롯과 연결되어 결제 중 임시 홀드된 예약 자원을 보호하는 개념도

예약 시스템 설계: 동시성·임시 홀드·결제·중복 예약 방지

10시 10분 00초, 임시 홀드를 만료시키는 쓰기와 결제 성공을 예약으로 바꾸는 쓰기가 겹쳤다. 예약 시스템 설계에서 먼저 답할 질문은 어느 서버가 빠른지가 아니라, 이 순간 재고 소유권을 어느 쓰기에 줄 것인가다. 화면 타이머도 마지막으로 도착한 결제 알림도 혼자서는 답이 될 수 없다.

이 글은 설명용 사용자 U-17이 슬롯 S-1930을 보고 홀드 H-42를 만든 뒤, 결제 화면이 PROCESSING에서 멈추고 결제 알림이 만료 뒤 중복 도착한 합성 사례를 추적한다. 실제 장애 기록이나 특정 서비스의 내부 구조가 아니다. 독자는 이 사건을 따라 웹·모바일 사용자의 검색, 확보, 결제, 재조회와 복구 규칙을 설계할 수 있다.

20초 핵심 요약

  • 무엇: 수량이 제한된 좌석·객실·시간을 잠시 확보하고 결제로 확정하는 예약 흐름이다.
  • 왜: 만료와 결제가 엇갈리면 같은 재고를 두 번 팔거나, 끝난 홀드가 재고를 계속 막을 수 있다.
  • 어떻게: 조건부 쓰기, 서버 시각의 논리 만료, 처리 경계별 중복 방지 키, 결제 조정과 정합성 감사를 연결한다.

사용자가 남긴 세 번의 행동을 시간표로 복원한다

H-42의 사용자는 최신 웹이나 모바일 앱에서 다음 세 행동을 했다. 검색은 빠르기를 기대하지만 화면의 가능 여부가 잠시 뒤 바뀔 수 있다는 안내도 필요하다. 홀드와 확정은 조금 더 기다리더라도 정확해야 하며, 결론이 나지 않았을 때는 거짓 성공·실패 대신 처리 중 상태와 재조회 방법을 보여줘야 한다.

설명용 시각 사용자 행동 서버가 돌려준 사실 사용자가 기대하는 결과
10:00:00 날짜·인원으로 S-1930 검색 기준 시각과 남은 수량 힌트 빠른 후보 확인
10:00:01 H-42 생성 ACTIVE, 만료 시각 10:10:00 결제 중 재고 확보
10:09:59 이후 결제 시작, 상태 재조회 PAYMENT_PENDING 새 결제 없이 최종 결과 확인

시각과 식별자는 모두 설명용 설계 가정이다. 화면 카운트다운과 기기 시계는 권위가 없고 서버가 기록한 expires_at과 상태가 판정 기준이다. 사용자는 자기 홀드와 예약만 볼 수 있어야 하며 카드 정보와 결제 비밀값은 예약 저장소나 로그에 복제하지 않는다.

API(Application Programming Interface, 앱과 서버가 요청·응답을 주고받는 규칙)는 이 세 행동을 다음 번호 흐름으로 잇는다.

  1. 사용자가 가능한 슬롯을 찾는다.
GET /v1/availability?resource_id=hall-7&date=2026-09-01&party_size=2

200 OK
{
  "as_of": "2026-08-16T10:00:00Z",
  "slots": [{"slot_id":"S-1930","remaining_hint":2,"price":50000}],
  "availability_is_guaranteed": false
}

API 접점은 인증·입력 형식·호출량을 검사한다. 검색 서비스는 캐시나 읽기 색인에서 후보를 빨리 좁히지만 판매 권한은 갖지 않는다. as_of는 정보의 기준 시각을 알리고, 실제 확보는 다음 요청에서 다시 판정한다.

  1. 사용자가 선택한 슬롯을 잠시 확보한다.
POST /v1/holds
Idempotency-Key: <무작위 요청 식별자>
{
  "slot_ids":["S-1930"],
  "quantity":2,
  "quoted_price":50000
}

201 Created
{
  "hold_id":"H-42",
  "status":"ACTIVE",
  "expires_at":"2026-08-16T10:10:00Z",
  "server_time":"2026-08-16T10:00:00Z"
}

멱등성은 같은 요청을 여러 번 받아도 최종 효과가 한 번과 같게 만드는 성질이다. 홀드 서비스는 같은 사용자·작업·요청 본문에 같은 키가 다시 오면 저장한 결과를 돌려줘 중복 홀드를 막는다. 키에는 이메일 같은 개인정보를 넣지 않는다. Stripe 멱등 요청 문서도 민감정보를 키에 넣지 말라고 안내한다.

재고 저장소는 가격과 상태를 다시 확인한다. 조건부 쓰기는 정한 조건이 참일 때만 데이터를 한 번 변경하는 연산이며, 남은 수량 >= 요청 수량과 현재 버전이 맞을 때만 재고를 홀드로 옮긴다. DynamoDB 조건식은 조건이 거짓일 때 쓰기를 거절한다. 홀드와 재고 변경은 같은 로컬 트랜잭션에 기록한다.

  1. 사용자가 결제를 시작하고 같은 예약을 재조회한다.
POST /v1/holds/H-42/payment
Idempotency-Key: <결제 시작 요청 식별자>

202 Accepted
{
  "reservation_id":"res-42",
  "status":"PAYMENT_PENDING",
  "status_url":"/v1/reservations/res-42"
}

결제 연결부는 외부 결제 제공자에도 중복 방지 키를 보내고 제공자의 결제 ID를 예약 시도에 저장한다. 웹훅은 외부 서비스가 상태 변화를 서버 주소로 보내는 알림이며, 여기서는 브라우저 성공 화면 대신 서명을 확인한 결제 결과를 받는 데 필요하다. 사용자는 결론이 없으면 status_url을 다시 조회하며 새 결제를 만들지 않는다.

검색·가격 재확인·홀드·만료·결제·확정·재조회·취소·환불·운영 감사가 필수 범위다. 추천, 가격 최적화, 대기자 자동 배정과 의도적인 초과 판매 정책은 제외한다. 여러 숙박일이나 인접 좌석은 모두 확보되거나 하나도 확보되지 않는 묶음으로 처리한다.

재고가 하나 줄었다는 말은 네 레코드에서 같아야 한다

H-42의 정답을 내려면 슬롯, 홀드, 예약, 결제 시도가 같은 사실을 말해야 한다. 데이터 모델의 최소 경계는 다음과 같다.

inventory_slot
- slot_id (PK)
- resource_id
- start_at, end_at
- capacity
- held_count
- confirmed_count
- version
- updated_at

불변 조건: held_count + confirmed_count <= capacity
인덱스: (resource_id, start_at), 필요하면 (resource_id, start_at, end_at)

inventory_slot은 좌석·객실 날짜·진료 시간처럼 판매할 수 있는 최소 단위다. 지정 좌석은 (event_id, seat_id)처럼 수량 1인 행으로 만들 수 있다. 객실 유형은 시간 구간별 행에 전체·홀드·확정 수량과 버전을 둔다. 여러 날짜를 묶을 때는 같은 키 순서로 갱신하고 하나라도 실패하면 전부 되돌린다.

hold
- hold_id (PK)
- user_id
- status: ACTIVE | CONVERTED | EXPIRED | RELEASED
- expires_at
- idempotency_key
- request_hash
- created_at, updated_at
- UNIQUE(user_id, idempotency_key)

hold_item
- hold_id, slot_id (복합 PK)
- quantity
- quoted_amount, currency

reservation
- reservation_id (PK)
- user_id
- hold_id (UNIQUE)
- provider_payment_id (UNIQUE)
- status: PAYMENT_PENDING | CONFIRMED | COMPENSATION_PENDING | CANCELED | REFUNDED | REVIEW
- amount, currency
- created_at, updated_at

payment_attempt
- payment_attempt_id (PK)
- reservation_id
- provider_payment_id
- provider_event_id
- status
- amount, currency
- created_at, updated_at
- UNIQUE(provider_payment_id)
- UNIQUE(provider_event_id)

PostgreSQL 고유 제약 문서는 여러 열을 묶은 고유 제약과 자동 고유 인덱스를 설명한다. provider_payment_id가 비어 있을 때는 데이터베이스의 null 처리와 부분 고유 인덱스 사용 여부를 정해야 한다. 중복 방지 레코드에는 사용자, 작업 종류, 요청 본문의 정규화 해시, 응답과 만료 시각을 저장한다. 보존 기간은 허용할 재시도 기간보다 길게 두되 개인정보와 저장 비용을 함께 제한한다.

다음 상태도는 왼쪽 AVAILABLE에서 시작한다. 위쪽은 HELDPAYMENT_PENDING을 거쳐 확정되는 길이고, 아래쪽은 만료·해제 뒤 보상이나 운영 검토로 갈라지는 길이다.

왼쪽 AVAILABLE에서 시작해 정상 확정, 만료·해제, 보상 처리 분기를 번호로 연결한 예약 상태도

그림의 결론은 결제 성공만으로 CONFIRMED가 되지 않는다는 점이다. 홀드가 유효하고 금액·통화·사용자·슬롯이 맞으며 같은 결제 ID로 이미 확정하지 않았을 때만 수량을 홀드에서 확정으로 옮긴다. 만료·취소·확정 상태는 다시 ACTIVE로 돌아가지 않는다.

재고와 홀드 변경을 외부 메시지로 알릴 때는 트랜잭션 아웃박스, 즉 데이터 변경과 보낼 이벤트를 같은 데이터베이스 트랜잭션에 기록하는 방식을 고려한다. 재고는 줄었는데 만료 작업은 이를 모르는 틈을 줄이기 위해서다. 상태 전이 기록의 보존 기간은 환불·감사·개인정보 정책에 맞춰 정하고, 불필요한 개인정보는 넣지 않는다.

만료 시각 10:10:00에서 두 쓰기가 경주한다

기본 선택은 버전과 남은 수량을 한 번에 검사하는 짧은 갱신이다.

UPDATE inventory_slot
SET held_count = held_count + :qty,
    version = version + 1
WHERE slot_id = :slot_id
  AND version = :expected_version
  AND held_count + confirmed_count + :qty <= capacity;

변경된 행이 1개면 성공이고 0개면 다른 요청이 먼저 바꿨거나 매진된 것이다. 같은 트랜잭션에서 홀드까지 기록하며 여러 슬롯 중 하나라도 실패하면 전부 되돌린다.

대안 장점 단점과 선택 조건
낙관적 잠금 충돌이 드물다고 보고 버전 조건이 맞을 때만 써서 기다림이 짧다. 인기 슬롯에서는 실패와 재시도가 늘어난다. 충돌률이 낮을 때 기본값으로 쓴다.
비관적 잠금 충돌을 예상해 한 작업이 행을 쓰는 동안 다른 작업을 기다리게 하므로 흐름이 직관적이다. 긴 트랜잭션과 교착 위험이 있다. 잠금 구간과 키 순서를 짧고 일정하게 통제할 때 쓴다. PostgreSQL 행 잠금
분산 잠금 여러 프로세스 사이의 진입을 조절할 수 있다. 잠금 유실·네트워크 분리·시간 만료가 생기며 데이터베이스 불변 조건을 대신하지 못한다. 권위 저장소의 한 번 쓰기로 풀 수 없을 때만 추가한다.
슬롯별 직렬 대기열 인기 슬롯의 재시도 폭풍을 줄인다. 사용자 지연과 메시지 중복 처리가 늘어난다. 슬롯별 충돌이 실제로 높을 때 도입한다.

TTL(Time To Live, 정한 시각 뒤 데이터를 만료 대상으로 표시하는 값)은 H-42가 언제 효력을 잃는지 나타내며 이탈 사용자의 재고 점유를 끝내기 위해 필요하다. 그러나 물리 삭제 시각은 유효성 판정이 아니다. DynamoDB TTL 문서는 만료 항목 삭제가 보통 만료 후 수일 안에 일어날 수 있다고 밝힌다. 이 보장은 모든 데이터베이스에 그대로 일반화할 수 없지만, 논리 만료와 저장 공간 청소를 분리해야 한다는 근거가 된다.

10:10:00의 만료 작업은 status = ACTIVE AND expires_at <= now일 때만 EXPIRED로 바꾸고 수량을 한 번 반환한다. 지연 큐와 주기 스캐너가 후보를 찾고, 중복 회수 메시지는 같은 상태 조건으로 무효화한다. 모든 홀드·결제·확정 쓰기도 서버 시각으로 만료 여부를 검사한다.

saga(사가)는 여러 시스템의 로컬 작업을 순서대로 실행하고 뒤 단계가 실패하면 보상 작업으로 앞선 효과를 되돌리는 흐름이다. 이 사례는 중앙 조정자가 결제·홀드·확정·환불 단계와 재시도를 기록하는 오케스트레이션을 선택한다. 흐름과 감사가 선명해지는 대신 조정자의 복잡도와 고가용성 비용이 생긴다. 중앙 지휘자 없이 각 서비스가 이벤트를 이어받는 코레오그래피는 단계가 적을 때 느슨하지만, 경계가 늘면 전체 상태와 순환 의존을 추적하기 어렵다. Microsoft Saga 패턴

이 글의 기본 규칙은 확정 트랜잭션을 시작할 때 H-42가 유효해야 한다는 것이다. 결제 제공자의 성공 시각만 보고 소급 확정하면 이미 다른 사용자에게 반환된 S-1930을 다시 차지할 수 있다. 결제는 성공했지만 홀드가 만료돼 재고가 없다면 COMPENSATION_PENDING으로 옮겨 환불을 중복에 안전하게 요청한다. 법이나 상품 정책상 자동 환불이 맞지 않으면 REVIEW로 보낸다. 보상도 실패할 수 있으므로 진행 기록, 제한된 재시도와 감사가 필요하다. Microsoft 보상 트랜잭션 패턴

결제 진행 중 짧은 유예를 허용할 수는 있다. 다만 시간과 진입 조건을 데이터 모델과 화면에 함께 표시해야 한다. 홀드 길이와 유예 시간은 업계 공통값이 아니며 결제 완료 시간, 추가 인증, 이탈률, 재고 희소성과 접근성 요구를 측정해 정한다.

웹훅은 순서대로 한 번씩만 도착하는 사실 목록이 아니다. Stripe 웹훅 문서는 같은 이벤트의 중복과 생성 순서대로 전달되지 않을 가능성을 밝힌다. 이벤트 ID를 고유 저장하고 객체 ID와 이벤트 종류를 확인하며, 순서가 의심스러우면 결제 API에서 최신 객체를 다시 조회한다. Stripe의 계약은 근거 예시이며 실제 결제 제공자를 정하면 해당 정책을 다시 확인해야 한다.

같은 티켓을 10배 키우면 먼저 깨지는 곳

다음 수치는 실제 운영 결과나 성능 보장이 아닌 통제된 설계 가정이다.

항목 입력 가정 계산 결과 민감도와 선택 변화
월간 사용자 1,000,000명 개인정보 보존·삭제와 계정별 접근 통제가 필요하다.
하루 검색 사용자당 월 5회, 30일 평균 약 166,667회/일 검색은 캐시나 읽기 색인으로 분리할 수 있다.
피크 검색 평균의 100배 약 193 QPS QPS(Queries/Requests Per Second, 초당 요청 수)가 10배 더 크면 캐시·콘텐츠 전송망·입장 대기열 검토가 빨라진다.
하루 홀드 50,000건 평균 약 0.58 QPS 평균보다 S-1930 같은 단일 슬롯의 충돌률이 중요하다.
활성 홀드 피크 200건/초, 10분 유지 최대 약 120,000건 10분을 절반으로 줄이면 점유량은 줄지만 느린 결제자의 실패가 늘 수 있다.
예약 저장 100,000건/일, 건당 1KB 약 100MB/일, 36.5GB/년 색인·복제·감사 기록을 더하면 저장량과 삭제 비용이 커진다.

검색 QPS가 10배가 되어도 권위 재고 쓰기 모델은 유지할 수 있다. 먼저 깨질 가능성이 큰 곳은 요청이 S-1930 한 건에 몰리는 핫키다. 핫키는 특정 데이터 한 건에 요청이 집중돼 그 지점만 병목이 되는 현상이며, 조건부 실패율·슬롯별 대기 시간·재시도 횟수로 찾는다. 임계치를 넘으면 입장 대기열, 좌석 단위 분할과 재시도 예산 제한을 적용한다.

다중 슬롯 fan-out은 한 요청이 여러 하위 작업으로 퍼지는 현상이다. 여러 숙박일이나 인접 좌석을 한 번에 잡으면 갱신할 행과 실패 지점이 늘어난다. 요청당 슬롯 수를 제한하고 같은 키 순서로 갱신해 교착 가능성을 줄인다.

정각에 홀드가 몰려 만료돼도 사용자에게 약속한 시각을 임의로 흔들어서는 안 된다. 회수 큐의 소비 속도를 조절하되 논리 만료는 즉시 판정한다. 결제 지연 중에는 데이터베이스 잠금을 잡은 채 외부 응답을 기다리지 않고, 홀드를 먼저 커밋한 뒤 짧은 타임아웃·제한된 재시도·상태 재조회로 분리한다.

검색 색인의 나이와 검색 후 홀드 409 비율을 함께 보면 오래된 화면이 실제 실패로 이어지는 정도를 알 수 있다. 원장은 상태 변화를 순서대로 남겨 수량과 결과를 다시 맞추는 기록이며, 여기서는 재고 증감과 결제 조정의 이유를 추적하기 위해 필요하다. 권위 카운터와 같은 트랜잭션에 증감 이유를 남기고 정기적으로 홀드·예약 합계와 대조하는 절충안은 처음부터 모든 상태를 이벤트만으로 재구성하는 방식보다 단순하다.

사용자가 다시 열었을 때 시스템이 답을 만드는 순서

U-17이 화면을 다시 열었을 때 시스템은 마지막 알림 하나를 답으로 내놓지 않는다. 사용자에게 보이는 현상에서 출발해 감지, 즉시 완화, 복구, 정합성 확인 순서로 증거를 모은다.

사용자에게 보이는 현상 감지 즉시 완화 복구 정합성 확인
검색에 있던 슬롯이 홀드에서 매진됨 조건부 쓰기 0행, 버전 충돌 409와 대체 시간·새 검색 제공 필요하면 대기열 또는 다른 슬롯 제안 held + confirmed <= capacity 검사
카운트다운이 남았는데 만료됨 기기·서버 시각 차이, expires_at 로그 server_time과 명확한 상태 표시 결제 미시작이면 새 홀드 시도 서버 시각만 판정에 썼는지 검사
결제했는데 처리가 오래 걸림 웹훅 지연, 결제 API 타임아웃, saga 단계 체류 새 결제를 막고 같은 예약을 조회 제공자 상태 재조회 후 확정·환불·검토 결제 ID와 예약 ID, 금액·통화 대조
버튼을 두 번 눌러 두 결과가 보임 같은 키 요청, 고유 제약 충돌, 중복 이벤트 최초 결과 재사용 중복 청구가 있으면 환불 saga 사용자 키·결제·예약 고유성 검사
만료 슬롯이 계속 안 풀림 스캐너 지연, 활성 만료 홀드, 재고 합계 차이 읽기·쓰기에서 논리 만료 제외 회수 재실행 또는 감사 수선 홀드 합과 슬롯 카운터 재계산
확정 예약이 사라지거나 중복됨 상태 전이 위반, 감사 불일치 조건 전이와 고유 제약으로 차단 상태 기록으로 재구성, 운영 검토 결제·예약·재고 대조

GET /reservations/{id}는 로그인만 확인해서는 안 된다. 객체 ID를 아는 사람이 아니라 예약 소유자나 허용된 운영자인지 매번 검사한다. OWASP API Security는 객체 식별자를 받는 API의 객체 단위 권한 누락을 주요 위험으로 다룬다.

카드 번호는 예약 저장소와 로그에 복제하지 않고 결제 제공자의 토큰이나 객체 ID만 연결한다. TLS(Transport Layer Security, 네트워크 전송 내용을 암호화하는 표준)는 결제 비밀정보의 전송 중 노출을 막기 위해 필요하다. 결제 비밀값은 URL·로그·메타데이터에 넣지 않는다. Stripe Payment Intents

웹훅은 원문 본문으로 서명을 검사하고 허용된 사건만 받는다. 복잡한 처리는 내부 큐로 넘기고 빠르게 성공 응답을 돌려준다. 인기 재고를 반복 홀드하는 공격은 계정·기기·결제 수단·IP 신호와 호출량으로 탐지하되, 개인정보 수집 목적과 보존 기간을 최소화하고 오탐 해제 절차를 둔다.

복구에는 비용도 따른다. 검색 캐시는 읽기 비용을 낮추지만 오래된 상태와 무효화 작업을 만든다. 긴 홀드는 판매 기회를 막고 짧은 홀드는 결제 실패와 지원 문의를 늘릴 수 있다. 세밀한 감사 기록은 수선을 돕지만 저장·분석·개인정보 삭제 비용을 키운다. 자동 수선은 결과가 명백하고 되돌릴 수 있을 때만 허용하고, 결제·환불·법적 확정 충돌은 상관관계 ID와 함께 운영 검토로 보낸다.

어느 측정값이 다음 확장을 허락하는가

  1. 초기에는 단일 지역의 관계형 데이터베이스에 슬롯·홀드·예약을 같은 트랜잭션 범위로 둔다. 고유 제약, 짧은 조건부 갱신이나 행 잠금, 주기 만료 스캐너와 결제 중복 방지 키로 H-42 같은 사건의 근거를 한곳에서 대조한다.
  2. 검색량이 커지고 권위 쓰기와 자원을 경쟁하면 캐시와 읽기 색인을 분리한다. as_of, 홀드 재검사, 색인 지연과 검색 후 홀드 실패율을 유지한다.
  3. 인기 슬롯의 조건부 충돌률과 대기 시간이 허용 기준을 넘으면 입장 대기열, 요청률 제한과 슬롯 분할을 도입한다. 측정 전부터 전체 시스템을 분산 잠금으로 바꾸지 않는다.
  4. PAYMENT_PENDING 체류, 웹훅 중복, 보상 실패와 만료 회수 지연이 늘면 명시적 saga 조정자, 지연 큐, 중복 이벤트 저장소와 결제·예약 정합성 작업을 분리한다.
  5. 여러 지역으로 넓힐 때 검색은 복제할 수 있지만 재고 쓰기는 슬롯별 단일 권위 지역이나 지역별 소유권이 필요하다. 여러 지역이 동시에 같은 슬롯을 쓰게 하면 충돌 해결만으로 초과 판매가 자동 방지되지 않는다. 지역 장애 때 정확성과 판매 가능성 중 무엇을 포기할지 상품별로 정한다.

검색과 예약을 같은 저장소에 두면 초기에는 단순하지만 검색이 커질 때 정확한 쓰기와 자원을 경쟁한다. 홀드 전에 결제하면 유령 점유는 줄지만 결제 성공 뒤 재고가 없어 환불할 위험이 커진다. 현재 카운터만 두면 빠르지만 불일치 이유를 찾기 어렵고, 모든 증감을 이벤트로만 재구성하면 순서·중복 처리와 저장 비용이 늘어난다. 선택한 단계는 이런 비용을 실제 관측 신호가 정당화할 때만 연다.

이제 H-42를 자동 확정, 자동 환불, 운영 검토 중 어디로 보낼지 정할 수 있다. 확정 쓰기 시작 전에 홀드가 끝났고 S-1930이 이미 반환됐다면 소급 확정하지 않는다. 다만 실제 적용 전에는 두 칸이 아직 비어 있다. 상품·지역에 맞는 환불 또는 승인 취소 규칙은 무엇인가. 결제가 진행 중임을 확인했을 때 허용할 유예 시간은 얼마인가. 이 두 값을 정하지 않으면 만료 경계의 정답도 완성되지 않는다.

참고 링크

비슷한 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다