예약 진행 플로우
대기열 통과 → 좌석 선택(5분) → 결제창 이동 → 좌석 임시 점유(5분) → 결제 성공 → 예약 확정
└─ 취소/만료 → 좌석 반납 → 홈 화면
예약은 좌석 선택 단계(최대 5분)와 결제 단계(최대 5분)로 나뉩니다. 사용자가 좌석을 선택한 뒤 결제 화면으로 이동하면, 최대 5분 - 좌석 선택 소요 시간 + 결제 시간(최대 5분) 동안 선택한 좌석을 보호해야 합니다.
만약 이 기간 동안 DB 락을 유지한다면, 트랜잭션과 커넥션을 오랫동안 점유하게 되어 커넥션 풀이 쉽게 고갈되고 전체 처리량이 감소할 수 있습니다.
따라서 이 시스템은 조건부 UPDATE를 수행하는 순간에만 잠시 행 락을 사용해 원자성을 보장하고, 이후에는 HELD 상태와 heldUntil을 이용해 논리적으로 좌석을 점유하는 방식을 적용했습니다.
이를 통해 장시간 트랜잭션을 유지하지 않으면서도 결제 중인 좌석을 안전하게 보호할 수 있습니다.
[ 상세 과정 ]

좌석 예약은 Hold(임시 점유) → Confirm(예약 확정)의 2단계로 이루어집니다.
중요한 점은 결제 중 좌석을 보호하는 것은 DB 락이 아니라 좌석 상태 데이터라는 것입니다.
DB 락은 UPDATE가 실행되는 아주 짧은 순간에만 존재하며, 커밋과 동시에 해제됩니다. 이후 결제가 완료될 때까지는 HELD 상태와 heldUntil 값이 좌석을 보호합니다.
Hold ( 임시 점유 )
사용자가 좌석을 선택하면 조건부 UPDATE를 통해 좌석을 HELD 상태로 변경합니다.
이 과정에서 DB는 잠시 행 락을 획득하지만, 커밋과 동시에 즉시 해제됩니다.
Seat
...
- status = HELD
- heldBy = UserA
- heldUntil = 현재 + 5분
...
결제 진행
결제 중에는 트랜잭션도 없고 DB 락도 존재하지 않으며, 대신 좌석에는 아래와 같은 정보가 존재합니다.
- status = HELD
- heldBy = UserA
- heldUntil = 12:05
다른 사용자가 같은 좌석을 선택하면 다음 조건으로 다시 조건부 UPDATE를 수행합니다.
WHERE
status = FREE
OR (status = HELD AND heldUntil < NOW())
OR (status = HELD AND heldBy = :me)
예를 들어, UserB가 접근하면 status = FREE ❌ , heldUntil < NOW() ❌ , heldBy = UserB ❌
세 조건 모두 만족하지 않으므로 0행 업데이트가 발생하며 좌석 선점에 실패합니다.
즉, 결제 중 좌석을 보호하는 것은 DB 락이 아니라 HELD 상태와 heldUntil 데이터입니다.
Confirm ( 예약 확정 )
결제가 완료되면 다시 조건부 UPDATE를 수행하여 좌석 상태를 HELD → RESERVED로 변경합니다.
이때 자신의 홀드인지(heldBy) 아직 홀드가 만료되지 않았는지(heldUntil)를 함께 검증한 뒤 예약을 확정합니다.
Release ( 홀드 해제 )
사용자가 결제를 취소하거나 결제창을 이탈하면 좌석 상태를 HELD → FREE로 변경합니다.
이후 다른 사용자는 해당 좌석을 즉시 다시 예약할 수 있습니다.
조건부 UPDATE 방식
조건부 UPDATE 방식은 하나의 SQL 안에서 조회, 검증, 선점을 모두 수행하는 방식입니다.
UPDATE seat
SET status = 'HELD'
WHERE status = 'FREE'
위와 같은 조건을 만족하는 경우에만 UPDATE가 수행됩니다.
UPDATE 실행 시 DB는 해당 행에 대한 배타적 락을 획득하므로 여러 요청이 동시에 들어와도 하나의 트랜잭션만 UPDATE에 성공합니다.
나머지는 영향받은 행 수가 0이 되어 자연스럽게 실패하므로 별도의 조회 후 검증 과정 없이 Race Condition을 방지할 수 있습니다.
여러 좌석 예약도 안전한 이유
UPDATE seat
SET ...
WHERE seat_number IN (...)
AND ...
UPDATE 이후 영향받은 행 수를 확인하여 요청한 좌석 수와 같으면 성공, 하나라도 부족하면 실패로 판단합니다.
따라서, 3석 요청했는데 2석만 예약되는 부분 성공이 발생하지 않으며, 하나의 트랜잭션에서 모두 성공하거나 모두 실패하도록 처리됩니다.
조건부 UPDATE를 선택한 이유
재시도가 필요하지 않습니다.
@Version 기반 낙관적 락은 충돌 시 OptimisticLockException이 발생하여 애플리케이션이 재시도를 수행합니다.
반면, 조건부 UPDATE는 영향받은 행 수만으로 성공 여부를 판단하므로 예외와 재시도가 필요하지 않습니다.
좌석 예약은 먼저 선점한 사용자가 성공하고 이후 요청은 실패하는 것이 정상적인 비즈니스 규칙이므로 즉시 409 CONFLICT를 반환하는 것이 더 적절하다고 판단했습니다.
락 보유 시간이 매우 짧습니다.
행 락은 UPDATE 실행부터 커밋까지의 매우 짧은 시간만 유지됩니다.
조회와 검증, 변경을 하나의 SQL에서 수행하기 때문에 락 유지 시간이 짧고 경합도 줄일 수 있습니다.
여러 좌석도 효율적으로 처리합니다.
여러 좌석을 하나의 UPDATE 문으로 처리하므로 DB 왕복이 증가하지 않습니다.
좌석 수가 늘어나도 영향받은 행 수만 확인하여 전체 성공 여부를 판단할 수 있습니다.
고려해야 할 점
데드락 가능성
여러 좌석을 동시에 예약할 경우 요청마다 락 획득 순서가 달라지면 데드락이 발생할 수 있습니다.
이를 방지하기 위해 좌석 번호를 정렬한 뒤 UPDATE를 수행하여 모든 요청이 동일한 순서로 락을 획득하도록 하였습니다.
DB의 동시성 보장에 의존합니다.
이 방식은 동일한 행에 대한 UPDATE가 직렬화된다는 DB의 동시성 보장에 기반합니다.
MySQL의 REPEATABLE READ와 PostgreSQL의 READ COMMITTED에서는 UPDATE ... WHERE가 잠금 획득 후 최신 커밋 값을 기준으로 조건을 다시 평가하므로 안전하게 동작합니다.
핫 로우 문제
특정 인기 좌석에 요청이 집중되면 어떤 락 방식을 사용하더라도 해당 행은 직렬 처리됩니다.
( 이는 조건부 UPDATE의 한계가 아니라 모든 행 기반 동시성 제어 방식이 갖는 특성 )
본 프로젝트에서는 DB 앞단에 대기열을 두어 동시에 DB에 진입하는 요청 수를 제한함으로써 이러한 병목을 완화했습니다.
멱등성
예약 확정 API에는 멱등성을 적용하여 동일한 요청이 여러 번 전달되더라도 예약은 한 번만 처리되도록 구현했습니다.
클라이언트는 Idempotency-Key를 함께 전달하며, 서버는 먼저 해당 키의 처리 이력을 조회합니다.
idempotency_key에 설정된 UNIQUE 제약을 이용해 동일한 키를 하나의 요청만 선점할 수 있도록 합니다. 이후 같은 트랜잭션 안에서 예약 확정 로직을 수행하고, 성공하면 멱등성 정보를 완료 상태로 변경한 뒤 함께 커밋합니다.동일한 Idempotency-Key로 여러 요청이 동시에 들어오더라도 하나의 요청만 예약을 수행하며, 후행 요청은 선행 요청의 처리 결과를 조회하여 동일한 응답을 반환합니다.
또한, 예약 과정에서 예외가 발생하면 예약 정보와 멱등성 정보가 함께 롤백되므로, 동일한 키로 다시 요청해도 정상적으로 재시도할 수 있습니다.