🐛 버그 설명
예약 생성, 결제 확인, 예약 취소 로직에서 동시에 여러 요청이 들어올 때 레이스 컨디션이 발생할 수 있습니다. 이로 인해 오버부킹, 확정된 예약의 자동 취소, 중복 환불 API 호출 등의 심각한 데이터 정합성 문제가 발생할 수 있습니다.
🔍 원인 분석
1. createBooking() — 비관적 락이 BookingTable을 보호하지 않음
파일: BookingCommandServiceImpl.java:73-86
StoreTable에 PESSIMISTIC_WRITE 락을 걸지만, 중복 예약 확인 쿼리(findReservedTableIds)는 Booking/BookingTable 테이블을 조회하며 이 테이블에는 락이 없습니다.
MySQL REPEATABLE_READ 스냅샷 타이밍에 따라, 다른 스레드가 커밋한 예약이 보이지 않아 동일 테이블/시간대에 이중 예약이 발생할 수 있습니다.
2. BookingScheduler — 확정된 예약을 취소할 수 있음
파일: BookingScheduler.java:27-46
jakarta.transaction.Transactional 오용 (Spring의 @Transactional이 아님)
- 스케줄러가 PENDING 목록 조회 후, 결제 확인 스레드가 동시에 CONFIRMED로 변경하면 스케줄러가 해당 예약을 CANCELED로 덮어씌울 수 있음
3. confirmPayment() / cancelBooking() — Booking 락 없음
파일: BookingCommandServiceImpl.java:156, BookingCommandServiceImpl.java:185
파일: PaymentService.java:118-122
Booking 엔티티에 @Version 필드가 없어 두 경로(BookingCommandServiceImpl과 PaymentService)에서 동시에 booking.confirm()을 호출하면 둘 다 커밋될 수 있습니다.
cancelBooking()은 상태 확인과 Toss 환불 API 호출 사이에 갭이 있어 중복 환불 호출이 가능합니다.
✅ 작업할 내용
🔴 CRITICAL — 즉시 수정
🔴 HIGH — 단기 수정
🟡 MEDIUM — 추가 개선
🙋🏻 참고 자료
- 분석 대상 파일:
domain/booking/service/BookingCommandServiceImpl.java
domain/booking/service/BookingScheduler.java
domain/booking/repository/BookingRepository.java
domain/payment/service/PaymentService.java
domain/payment/entity/Payment.java
- MySQL InnoDB MVCC 및 REPEATABLE_READ 격리 수준 문서
- Spring Data JPA
@Lock 어노테이션 공식 문서
🐛 버그 설명
예약 생성, 결제 확인, 예약 취소 로직에서 동시에 여러 요청이 들어올 때 레이스 컨디션이 발생할 수 있습니다. 이로 인해 오버부킹, 확정된 예약의 자동 취소, 중복 환불 API 호출 등의 심각한 데이터 정합성 문제가 발생할 수 있습니다.
🔍 원인 분석
1.
createBooking()— 비관적 락이 BookingTable을 보호하지 않음파일:
BookingCommandServiceImpl.java:73-86StoreTable에PESSIMISTIC_WRITE락을 걸지만, 중복 예약 확인 쿼리(findReservedTableIds)는Booking/BookingTable테이블을 조회하며 이 테이블에는 락이 없습니다.MySQL REPEATABLE_READ 스냅샷 타이밍에 따라, 다른 스레드가 커밋한 예약이 보이지 않아 동일 테이블/시간대에 이중 예약이 발생할 수 있습니다.
2.
BookingScheduler— 확정된 예약을 취소할 수 있음파일:
BookingScheduler.java:27-46jakarta.transaction.Transactional오용 (Spring의@Transactional이 아님)3.
confirmPayment()/cancelBooking()— Booking 락 없음파일:
BookingCommandServiceImpl.java:156,BookingCommandServiceImpl.java:185파일:
PaymentService.java:118-122Booking엔티티에@Version필드가 없어 두 경로(BookingCommandServiceImpl과PaymentService)에서 동시에booking.confirm()을 호출하면 둘 다 커밋될 수 있습니다.cancelBooking()은 상태 확인과 Toss 환불 API 호출 사이에 갭이 있어 중복 환불 호출이 가능합니다.✅ 작업할 내용
🔴 CRITICAL — 즉시 수정
booking_table테이블에(store_id, booking_date, booking_time, table_id)유니크 제약 추가 — 애플리케이션 락 우회 시 최후 방어선BookingScheduler.java:27jakarta.transaction.Transactional→org.springframework.transaction.annotation.Transactional변경BookingScheduler.java:44취소 처리 전 상태 재확인 로직 추가 (PENDING 여부 검증)Booking엔티티에@Version추가 — 낙관적 락으로 동시 상태 변경 감지🔴 HIGH — 단기 수정
BookingRepository에 락 메서드 추가:findByIdWithLock(Long id)(@Lock(PESSIMISTIC_WRITE)) 구현BookingCommandServiceImpl.confirmPayment():156findById→findByIdWithLock교체BookingCommandServiceImpl.cancelBooking():185findById→findByIdWithLock교체BookingCommandServiceImpl.cancelBookingByOwner():223동일하게 락 적용🟡 MEDIUM — 추가 개선
PaymentService.confirmPayment()Toss 외부 API 호출을 트랜잭션 바깥으로 분리 (커넥션 풀 고갈 방지)PaymentService.cancelPayment()취소 전 Payment 상태 검증 및 멱등성 체크 추가🙋🏻 참고 자료
domain/booking/service/BookingCommandServiceImpl.javadomain/booking/service/BookingScheduler.javadomain/booking/repository/BookingRepository.javadomain/payment/service/PaymentService.javadomain/payment/entity/Payment.java@Lock어노테이션 공식 문서