← 공연 예매 서비스 프로젝트로 돌아가기

문제 해결

미결제 예약 자동 만료 처리와 낙관적 락을 통한 데이터 정합성 확보

문제 정의

예약 시스템은 사용자가 좌석을 선점하면 해당 좌석을 잠금 처리해 다른 사용자가 같은 좌석을 선택하지 못하게 합니다. 이 잠금은 결제가 완료되어야 해제되는 구조인데, 예약 생성 시점에는 결제 완료 여부만 상태로 관리했을 뿐 별도의 만료 시점이나 정리 로직이 없었습니다.

즉 사용자가 좌석만 선점해두고 결제를 진행하지 않아도 이를 취소할 트리거가 존재하지 않아, 좌석이 영구적으로 잠긴 상태로 남았습니다. 좌석은 유한한 자원이라 이런 미결제 예약이 쌓이면 실제로 결제할 의사가 있는 다른 사용자가 판매되지도 않은 좌석을 예약하지 못하는 상황이 발생합니다.

해결 방안 비교 및 선택 이유

미결제 예약을 정리하는 방법으로 세 가지를 검토했습니다.

후보 A: 좌석 재조회 시점에 만료 여부 확인(Lazy 체크) — 별도 배치 없이, 다른 사용자가 같은 좌석에 접근할 때 기존 예약의 만료 시점을 확인해 그 자리에서 취소하는 방식입니다. 인프라 추가가 없다는 장점이 있지만, 아무도 그 좌석을 다시 건드리지 않으면 예약은 만료됐어도 좌석은 계속 잠긴 상태로 남습니다 — 지금 겪고 있는 영구 잠금 문제가 그대로 재발하는 구조라 배제했습니다.

후보 B: Redis TTL + Keyspace Notification — 예약 생성 시 Redis에 TTL 키를 걸어두고 만료 이벤트를 구독해 즉시 취소하는 방식입니다. 좌석 잠금에 이미 Redisson 분산 락을 쓰고 있어 인프라 자체는 갖춰져 있었지만, keyspace notification은 이벤트 유실 가능성이 있어 안정성이 낮고, 상호 배제(분산 락)와 만료 스케줄링이라는 서로 다른 책임을 같은 Redis 컴포넌트에 얹게 돼 배제했습니다.

후보 C: 스케줄러 기반 주기 폴링(채택) — 스케줄러가 주기적으로 만료 대상을 조회해 일괄 취소하는 방식입니다. 예약 생성 시 만료 시간을 10분으로 설정했기 때문에, 폴링 주기를 10초로 잡아도 실제 좌석이 풀리는 시점은 만료 예정 시각 대비 최대 10초입니다. 이는 이미 확정된 10분 잠금 시간 위에 1.6% 남짓 더해지는 수준에 불과합니다. 후보 B의 즉시 해제가 줄이는 지연도 이 정도에 그치는 반면 keyspace notification의 안정성 문제는 그대로 떠안아야 해서, 폴링 지연을 감수하고 기존 스택만으로 구현 가능한 이 방식을 택했습니다.

문제 해결 과정

1) 예약 도메인에 만료·상태 모델 추가. 예약 엔티티에 만료 시각과 예약 상태(생성/확정/취소)를 추가하고, 상태 전이를 확정·취소 처리 메서드로 캡슐화했습니다. 별도의 만료 상태는 두지 않고 만료로 인한 취소도 취소 상태로 귀결시켜, 상태는 결제 성공/실패라는 결과 기준으로만 유지했습니다.

2) 만료 예약 자동 취소 스케줄러 구현. 스케줄러가 10초마다 취소 처리 로직을 호출해 결제 대기 상태이고 만료 시각이 지난 예약을 조회하고, 각 건마다 예약을 취소하며 연결된 좌석의 잠금을 해제했습니다. 조회 트랜잭션과 건별 취소 트랜잭션을 분리해, 한 건 처리 중 예외가 나도 다른 건 처리에 영향을 주지 않게 했습니다.

3) 티켓 발급 시 확정 상태로 전환. 결제가 승인되면 티켓 발급 로직이 예약을 확정 처리해 상태를 확정으로 바꾸고 티켓을 발급하도록 했습니다. 이 시점 이후 예약은 스케줄러의 조회 조건(결제 대기 상태)에서 자연히 제외됩니다.

4) 스케줄러·결제 동시성 충돌 예측과 낙관적 락 도입. 설계 리뷰 과정에서, 예약의 만료 시각 직전에 결제가 승인되면 스케줄러의 조회와 결제 확정 처리가 같은 예약 row를 동시에 건드릴 수 있다는 시나리오가 나왔습니다. 스케줄러가 조회 시점엔 결제 대기 상태였던 예약을, 그사이 결제가 확정돼 확정 상태로 바뀐 뒤에도 그대로 취소해버리면 이미 발급된 티켓과 예약 상태가 어긋납니다.

다만 이런 충돌은 예약 만료 직전 짧은 구간에 결제가 하필 그 순간 완료될 때만 발생합니다. 대부분의 예약은 둘 중 하나만 겪고 끝나기 때문에 충돌 자체가 드뭅니다. 이런 경로에 비관적 락을 걸면 스케줄러가 여러 건을 순회하는 배치 트랜잭션과 결제 확정 트랜잭션이 매번 같은 락을 두고 경합할 여지가 생깁니다. 그러면 결제를 진행 중인 사용자가 스케줄러 처리를 기다리는 지연으로 이어질 수 있습니다. 반대로 낙관적 락은 충돌이 없는 대다수 경우엔 잠금 비용이 전혀 없습니다. 충돌이 나더라도 스케줄러 쪽에서 예외를 잡아 해당 건만 건너뛰면 되므로 결제 확정 트랜잭션의 흐름에는 영향을 주지 않습니다.

이 특성에 맞춰 예약 엔티티에 낙관적 락(버전 관리)을 추가하고, 스케줄러의 취소 트랜잭션에서 예외를 잡아 해당 건만 건너뛰도록 했습니다. 재시도는 두지 않았습니다 — 버전 충돌이 났다는 건 그사이 결제가 확정됐다는 뜻이라, 스케줄러가 다시 취소를 시도할 이유가 없기 때문입니다.

결과

미결제 예약은 10분 뒤 스케줄러에 의해 자동으로 취소되고 좌석 잠금도 함께 해제되어, 별도 조치 없이 다른 사용자가 같은 좌석을 다시 예약할 수 있게 됐습니다. 다만 이 효과를 실측 트래픽으로 검증한 지표는 아직 없고, 아래는 테스트로 검증된 구조적 근거입니다.

이를 검증하는 테스트는 스케줄러 취소와 결제 확정이 겹치는 상황을 재현합니다 — 취소 트랜잭션 실행 중 예외가 발생하도록 설정한 예약은 취소 처리가 호출되지 않고 건너뛰어지는 반면, 같은 배치에 포함된 나머지 정상 예약은 그대로 취소·좌석 해제까지 처리되어 반환 카운트가 정확히 일치하는 것을 확인했습니다. 이 테스트가 통과한다는 것은, 결제와 스케줄러가 같은 예약을 동시에 건드려도 이미 결제가 확정된 예약이 실수로 취소되는 경로가 차단된다는 뜻입니다.