diff --git a/build.gradle b/build.gradle index 8a0c381f..afa41e2f 100644 --- a/build.gradle +++ b/build.gradle @@ -65,6 +65,9 @@ dependencies { // WebFlux implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // Flyway (MySQL) + implementation 'org.flywaydb:flyway-mysql' } // --- QueryDSL --- def generated = 'build/generated/sources/annotationProcessor/java/main' diff --git a/src/main/java/com/eatsfine/domain/booking/entity/Booking.java b/src/main/java/com/eatsfine/domain/booking/entity/Booking.java index e6b0d941..02c8cfbb 100644 --- a/src/main/java/com/eatsfine/domain/booking/entity/Booking.java +++ b/src/main/java/com/eatsfine/domain/booking/entity/Booking.java @@ -34,6 +34,9 @@ public class Booking extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Version + private Long version; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; @@ -87,6 +90,8 @@ public void addBookingTable(StoreTable storeTable) { BookingTable bookingTable = BookingTable.builder() .booking(this) .storeTable(storeTable) + .bookingDate(this.bookingDate) + .bookingTime(this.bookingTime) .build(); this.bookingTables.add(bookingTable); } @@ -99,10 +104,12 @@ public void confirm() { this.status = BookingStatus.CONFIRMED; } - public void cancel(String cancelReason) - { + public void cancel(String cancelReason) { this.status = BookingStatus.CANCELED; this.cancelReason = cancelReason; + // BookingTable 행은 보존하되 is_active를 null로 설정하여 슬롯만 해제 + // MySQL 유니크 인덱스는 NULL을 중복으로 취급하지 않으므로 동일 시간대 재예약 허용 + this.bookingTables.forEach(BookingTable::deactivate); } //예약과 관련된 결제 중 결제 완료된 결제키 조회 diff --git a/src/main/java/com/eatsfine/domain/booking/entity/mapping/BookingTable.java b/src/main/java/com/eatsfine/domain/booking/entity/mapping/BookingTable.java index 1505257d..b4c7f437 100644 --- a/src/main/java/com/eatsfine/domain/booking/entity/mapping/BookingTable.java +++ b/src/main/java/com/eatsfine/domain/booking/entity/mapping/BookingTable.java @@ -5,11 +5,21 @@ import jakarta.persistence.*; import lombok.*; +import java.time.LocalDate; +import java.time.LocalTime; + @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder @Getter +@Table( + name = "booking_table", + uniqueConstraints = @UniqueConstraint( + name = "uq_booking_table_slot", + columnNames = {"store_table_id", "booking_date", "booking_time", "is_active"} + ) +) public class BookingTable { @Id @@ -23,4 +33,20 @@ public class BookingTable { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "booking_id") private Booking booking; + + // 유니크 제약 적용을 위해 Booking의 날짜/시간을 비정규화하여 저장 + @Column(name = "booking_date", nullable = false) + private LocalDate bookingDate; + + @Column(name = "booking_time", nullable = false) + private LocalTime bookingTime; + + // true = 활성 예약 슬롯 (유니크 제약 적용) + // null = 취소된 슬롯 — MySQL은 NULL을 유니크 인덱스에서 중복으로 보지 않으므로 동일 시간대 재예약 허용 + @Column(name = "is_active") + private Boolean isActive = true; + + public void deactivate() { + this.isActive = null; + } } diff --git a/src/main/java/com/eatsfine/domain/booking/exception/BookingException.java b/src/main/java/com/eatsfine/domain/booking/exception/BookingException.java index edc0d551..6af378c5 100644 --- a/src/main/java/com/eatsfine/domain/booking/exception/BookingException.java +++ b/src/main/java/com/eatsfine/domain/booking/exception/BookingException.java @@ -7,4 +7,8 @@ public class BookingException extends GeneralException { public BookingException(BaseErrorCode code) { super(code); } + + public BookingException(BaseErrorCode code, Throwable cause) { + super(code, cause); + } } \ No newline at end of file diff --git a/src/main/java/com/eatsfine/domain/booking/repository/BookingRepository.java b/src/main/java/com/eatsfine/domain/booking/repository/BookingRepository.java index 7f4caa6a..34024ccd 100644 --- a/src/main/java/com/eatsfine/domain/booking/repository/BookingRepository.java +++ b/src/main/java/com/eatsfine/domain/booking/repository/BookingRepository.java @@ -8,9 +8,12 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.repository.query.Param; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import jakarta.persistence.LockModeType; + import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -70,6 +73,10 @@ List findActiveBookingsByTableAndDate( @Param("tableId") Long tableId, @Param("date") LocalDate date); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT b FROM Booking b WHERE b.id = :id") + Optional findByIdWithLock(@Param("id") Long id); + Optional findByIdAndStatus(Long bookingId, BookingStatus status); /** @@ -80,6 +87,10 @@ List findActiveBookingsByTableAndDate( */ List findAllByStatusAndCreatedAtBefore(BookingStatus status, LocalDateTime threshold); + @Query("SELECT b.id FROM Booking b WHERE b.status = :status AND b.createdAt < :threshold") + List findIdsByStatusAndCreatedAtBefore(@Param("status") BookingStatus status, + @Param("threshold") LocalDateTime threshold); + /** * 특정 식당의 브레이크 타임과 겹치는 가장 늦은 예약 날짜를 조회합니다. diff --git a/src/main/java/com/eatsfine/domain/booking/service/BookingCancelExecutor.java b/src/main/java/com/eatsfine/domain/booking/service/BookingCancelExecutor.java new file mode 100644 index 00000000..89e2f649 --- /dev/null +++ b/src/main/java/com/eatsfine/domain/booking/service/BookingCancelExecutor.java @@ -0,0 +1,41 @@ +package com.eatsfine.domain.booking.service; + +import com.eatsfine.domain.booking.enums.BookingStatus; +import com.eatsfine.domain.booking.repository.BookingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class BookingCancelExecutor { + + private final BookingRepository bookingRepository; + + @Transactional(readOnly = true) + public List findExpiredPendingIds(LocalDateTime threshold) { + return bookingRepository.findIdsByStatusAndCreatedAtBefore(BookingStatus.PENDING, threshold); + } + + // REQUIRES_NEW: 호출마다 독립 트랜잭션 — 하나 실패해도 다른 예약에 영향 없음 + // 반환값: 실제로 취소가 수행된 경우 true, 이미 상태 변경된 경우 false + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean cancelIfPending(Long bookingId) { + return bookingRepository.findByIdWithLock(bookingId) + .map(booking -> { + if (booking.getStatus() == BookingStatus.PENDING) { + booking.cancel("결제 시간 초과로 인한 자동 취소"); + log.info("예약 ID {} 자동 취소 완료", bookingId); + return true; + } + return false; + }) + .orElse(false); + } +} diff --git a/src/main/java/com/eatsfine/domain/booking/service/BookingCommandServiceImpl.java b/src/main/java/com/eatsfine/domain/booking/service/BookingCommandServiceImpl.java index 1e16c8b8..870c2e44 100644 --- a/src/main/java/com/eatsfine/domain/booking/service/BookingCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/domain/booking/service/BookingCommandServiceImpl.java @@ -32,6 +32,7 @@ import com.eatsfine.domain.user.repository.UserRepository; import com.eatsfine.domain.user.status.UserErrorStatus; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -126,8 +127,18 @@ public BookingResponseDTO.CreateBookingResultDTO createBooking(Long userId, Long .divide(hundred, 0, RoundingMode.HALF_UP); booking.setDepositAmount(totalDeposit); - Booking savedBooking = bookingRepository.save(booking); - bookingRepository.flush(); + Booking savedBooking; + try { + savedBooking = bookingRepository.save(booking); + bookingRepository.flush(); + } catch (DataIntegrityViolationException e) { + // uq_booking_table_slot 위반만 도메인 예외로 변환 — FK/NOT NULL 등 다른 위반은 원본 그대로 전파 + String cause = e.getMostSpecificCause().getMessage(); + if (cause != null && cause.contains("uq_booking_table_slot")) { + throw new BookingException(BookingErrorStatus._ALREADY_RESERVED_TABLE, e); + } + throw e; + } // 결제 대기 데이터 생성 (내부 서비스 호출) PaymentRequestDTO.RequestPaymentDTO paymentRequest = new PaymentRequestDTO.RequestPaymentDTO(savedBooking.getId()); @@ -153,11 +164,14 @@ public BookingResponseDTO.CreateBookingResultDTO createBooking(Long userId, Long @Transactional public BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(Long bookingId, BookingRequestDTO.PaymentConfirmDTO dto) { - Booking booking = bookingRepository.findById(bookingId) + Booking booking = bookingRepository.findByIdWithLock(bookingId) .orElseThrow(() -> new BookingException(BookingErrorStatus._BOOKING_NOT_FOUND)); - //이미 예약이 확정됐는지 최종 확인 - if(booking.getStatus() == BookingStatus.CONFIRMED) { + if (booking.getStatus() == BookingStatus.CANCELED) { + throw new BookingException(BookingErrorStatus._ALREADY_CANCELED); + } + + if (booking.getStatus() == BookingStatus.CONFIRMED) { throw new BookingException(BookingErrorStatus._ALREADY_CONFIRMED); } @@ -182,7 +196,7 @@ public BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(Long bookingId, public BookingResponseDTO.CancelBookingResultDTO cancelBooking(Long userId, Long bookingId, BookingRequestDTO.CancelBookingDTO dto) { - Booking booking = bookingRepository.findById(bookingId) + Booking booking = bookingRepository.findByIdWithLock(bookingId) .orElseThrow(() -> new BookingException(BookingErrorStatus._BOOKING_NOT_FOUND)); @@ -220,7 +234,7 @@ public BookingResponseDTO.OwnerCancelBookingResultDTO cancelBookingByOwner(Long storeValidator.validateStoreOwner(storeId, email); // 1. 예약 존재 확인 - Booking booking = bookingRepository.findById(bookingId) + Booking booking = bookingRepository.findByIdWithLock(bookingId) .orElseThrow(() -> new BookingException(BookingErrorStatus._BOOKING_NOT_FOUND)); // 2. 데이터 무결성 검증 diff --git a/src/main/java/com/eatsfine/domain/booking/service/BookingScheduler.java b/src/main/java/com/eatsfine/domain/booking/service/BookingScheduler.java index 5d7ece75..dfed0c04 100644 --- a/src/main/java/com/eatsfine/domain/booking/service/BookingScheduler.java +++ b/src/main/java/com/eatsfine/domain/booking/service/BookingScheduler.java @@ -1,9 +1,5 @@ package com.eatsfine.domain.booking.service; -import com.eatsfine.domain.booking.entity.Booking; -import com.eatsfine.domain.booking.enums.BookingStatus; -import com.eatsfine.domain.booking.repository.BookingRepository; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -17,33 +13,39 @@ @Slf4j public class BookingScheduler { - private final BookingRepository bookingRepository; + private final BookingCancelExecutor bookingCancelExecutor; /** * 결제 미완료(PENDING) 상태로 10분이 경과한 예약을 주기적으로 취소 처리 * cron: 0분부터 10분 단위로 실행 (0, 10, 20, 30, 40, 50분) + * + * 각 예약을 독립 트랜잭션(REQUIRES_NEW)으로 처리하여 + * 낙관적 락 충돌 등 일부 실패가 전체 배치에 영향을 주지 않음 */ @Scheduled(cron = "0 0/10 * * * *") - @Transactional public void cleanupExpiredPendingBookings() { LocalDateTime threshold = LocalDateTime.now().minusMinutes(10); - // 1. 10분 전보다 이전에 생성되었고, 여전히 PENDING인 예약 조회 - List expiredBookings = bookingRepository.findAllByStatusAndCreatedAtBefore( - BookingStatus.PENDING, - threshold - ); + List expiredIds = bookingCancelExecutor.findExpiredPendingIds(threshold); - if (expiredBookings.isEmpty()) { + if (expiredIds.isEmpty()) { return; } - log.info("스케줄러 실행: 만료된 PENDING 예약 {}건을 취소 처리합니다.", expiredBookings.size()); - - // 2. 상태 변경 및 로그 기록 - expiredBookings.forEach(booking -> { - booking.cancel("결제 시간 초과로 인한 자동 취소"); - }); + log.info("스케줄러 실행: 만료된 PENDING 예약 {}건 처리 시작", expiredIds.size()); + + int canceledCount = 0; + int skippedCount = 0; + for (Long id : expiredIds) { + try { + if (bookingCancelExecutor.cancelIfPending(id)) canceledCount++; + else skippedCount++; + } catch (Exception e) { + log.warn("예약 ID {} 자동 취소 실패 — 다음 실행에서 재시도: {}", id, e.getMessage()); + } + } + log.info("스케줄러 완료: 취소 {}건 / 스킵 {}건 / 시도 {}건", + canceledCount, skippedCount, expiredIds.size()); } } diff --git a/src/main/java/com/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/domain/payment/service/PaymentService.java index 91123075..ea004d16 100644 --- a/src/main/java/com/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/domain/payment/service/PaymentService.java @@ -1,7 +1,10 @@ package com.eatsfine.domain.payment.service; import com.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.domain.booking.enums.BookingStatus; +import com.eatsfine.domain.booking.exception.BookingException; import com.eatsfine.domain.booking.repository.BookingRepository; +import com.eatsfine.domain.booking.status.BookingErrorStatus; import com.eatsfine.domain.payment.dto.request.PaymentWebhookDTO; import com.eatsfine.domain.payment.dto.request.PaymentConfirmDTO; import com.eatsfine.domain.payment.dto.request.PaymentRequestDTO; @@ -115,11 +118,34 @@ public PaymentResponseDTO.PaymentSuccessResultDTO confirmPayment(PaymentConfirmD provider, response.receipt() != null ? response.receipt().url() : null); - Booking booking = payment.getBooking(); // 결제 엔티티에 매핑된 예약 객체 가져오기 + Booking booking = payment.getBooking(); if (booking != null) { - // 예약 상태를 CONFIRMED로 변경 - booking.confirm(); - log.info("Booking confirmed for OrderID: {}", dto.orderId()); + // 비관적 락으로 재조회하여 스케줄러 / 다른 스레드와의 동시 수정 방지 + Booking lockedBooking = bookingRepository.findByIdWithLock(booking.getId()) + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._BOOKING_NOT_FOUND)); + + if (lockedBooking.getStatus() == BookingStatus.CONFIRMED) { + // 멱등성: 이미 확정된 경우 중복 처리 방지 + log.info("Booking {} already CONFIRMED (idempotent), skipping update for OrderID: {}", + lockedBooking.getId(), dto.orderId()); + } else if (lockedBooking.getStatus() == BookingStatus.CANCELED) { + // 보상 처리: 스케줄러 등에 의해 예약이 취소됐으나 결제가 완료된 경우 자동 환불 + log.warn("Booking {} is CANCELED but payment was completed. Triggering compensation refund for OrderID: {}", + lockedBooking.getId(), dto.orderId()); + try { + tossPaymentService.cancel(response.paymentKey(), + new PaymentRequestDTO.CancelPaymentDTO("예약 취소 상태에서 결제 완료 - 자동 환불")); + } catch (Exception refundEx) { + log.error("Compensation refund failed for OrderID: {}. Manual intervention required.", + dto.orderId(), refundEx); + } + payment.cancelPayment(); + // noRollbackFor = GeneralException.class 이므로 payment.cancelPayment() 변경은 커밋됨 + throw new BookingException(BookingErrorStatus._ALREADY_CANCELED); + } else { + lockedBooking.confirm(); + log.info("Booking confirmed for OrderID: {}", dto.orderId()); + } } diff --git a/src/main/java/com/eatsfine/global/apipayload/exception/GeneralException.java b/src/main/java/com/eatsfine/global/apipayload/exception/GeneralException.java index e8cc28b5..26ed8ae0 100644 --- a/src/main/java/com/eatsfine/global/apipayload/exception/GeneralException.java +++ b/src/main/java/com/eatsfine/global/apipayload/exception/GeneralException.java @@ -5,8 +5,17 @@ import lombok.Getter; @Getter -@AllArgsConstructor public class GeneralException extends RuntimeException { private final BaseErrorCode code; + + public GeneralException(BaseErrorCode code) { + super(code.getReason().getMessage()); + this.code = code; + } + + public GeneralException(BaseErrorCode code, Throwable cause) { + super(code.getReason().getMessage(), cause); + this.code = code; + } } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 0c0f8f64..2ed60cc2 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -17,11 +17,15 @@ spring: port: ${REDIS_PORT} jpa: hibernate: - ddl-auto: update + ddl-auto: validate show-sql: true properties: hibernate: format_sql: true + flyway: + enabled: true + baseline-on-migrate: true # 기존 DB(Flyway 미적용 상태)를 version 0 베이스라인으로 처리 + baseline-version: 0 security: oauth2: client: diff --git a/src/main/resources/db/migration/V1__add_booking_table_slot_constraints.sql b/src/main/resources/db/migration/V1__add_booking_table_slot_constraints.sql new file mode 100644 index 00000000..8edc84f8 --- /dev/null +++ b/src/main/resources/db/migration/V1__add_booking_table_slot_constraints.sql @@ -0,0 +1,40 @@ +-- ============================================================ +-- V1: booking_table 슬롯 유니크 제약 및 Booking 낙관적 락 추가 +-- ============================================================ +-- 목적: 동시 예약 오버부킹 방지를 위한 DB 레벨 유니크 제약 추가 +-- 영향 테이블: booking_table, booking +-- ============================================================ + +-- [Step 1] 새 컬럼 추가 — NULL 허용으로 먼저 추가 (기존 행 오류 방지) +ALTER TABLE booking_table + ADD COLUMN booking_date DATE NULL, + ADD COLUMN booking_time TIME NULL, + ADD COLUMN is_active TINYINT(1) NULL; + +-- [Step 2] 기존 booking_table 행에 booking 테이블의 날짜/시간 백필 +-- CONFIRMED / PENDING 상태: is_active = 1 (활성 슬롯) +-- 그 외 (CANCELED 등): is_active = NULL (슬롯 해제, 유니크 제약 제외) +UPDATE booking_table bt + INNER JOIN booking b ON bt.booking_id = b.id +SET bt.booking_date = b.booking_date, + bt.booking_time = b.booking_time, + bt.is_active = CASE + WHEN b.status IN ('CONFIRMED', 'PENDING') THEN 1 + ELSE NULL + END; + +-- [Step 3] 백필 완료 후 NOT NULL 적용 +ALTER TABLE booking_table + MODIFY COLUMN booking_date DATE NOT NULL, + MODIFY COLUMN booking_time TIME NOT NULL; + +-- [Step 4] 유니크 제약 추가 +-- is_active가 NULL이면 MySQL 유니크 인덱스에서 중복 체크 제외 +-- → 취소된 슬롯에 재예약 가능 +ALTER TABLE booking_table + ADD CONSTRAINT uq_booking_table_slot + UNIQUE (store_table_id, booking_date, booking_time, is_active); + +-- [Step 5] Booking 낙관적 락(@Version) 컬럼 추가 — NULL 허용 (JPA가 첫 write 시 0으로 초기화) +ALTER TABLE booking + ADD COLUMN version BIGINT NULL; diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index f653ea00..870f1adf 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -8,6 +8,8 @@ spring: driver-class-name: org.h2.Driver username: password: + flyway: + enabled: false # 테스트는 H2 ddl-auto:create-drop으로 스키마 관리 jpa: hibernate: ddl-auto: create-drop