From 3ced6657b7f10239a42d1139c660309314394aef Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 7 May 2026 14:26:36 +0900 Subject: [PATCH 01/12] =?UTF-8?q?fix:=20=EC=98=88=EC=95=BD/=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=B7=A8=EC=95=BD?= =?UTF-8?q?=EC=A0=90=20=EC=88=98=EC=A0=95=20(=EB=A0=88=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=20=EC=BB=A8=EB=94=94=EC=85=98=20=EB=B0=8F=20=EC=98=A4=EB=B2=84?= =?UTF-8?q?=EB=B6=80=ED=82=B9)=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BookingScheduler: jakarta → Spring @Transactional 교체, 취소 전 PENDING 상태 재확인 추가 - Booking: @Version 추가 (낙관적 락으로 동시 상태 변경 감지) - BookingTable: booking_date/time 컬럼 추가 및 (store_table_id, booking_date, booking_time) 유니크 제약 추가 - BookingRepository: findByIdWithLock() 메서드 추가 (PESSIMISTIC_WRITE) - BookingCommandServiceImpl: confirmPayment/cancelBooking/cancelBookingByOwner에 비관적 락 적용 - PaymentService: booking.confirm() 시 락 재조회 및 CONFIRMED 중복 방지 처리 Co-Authored-By: Claude Sonnet 4.6 --- .../eatsfine/domain/booking/entity/Booking.java | 5 +++++ .../booking/entity/mapping/BookingTable.java | 17 +++++++++++++++++ .../booking/repository/BookingRepository.java | 7 +++++++ .../service/BookingCommandServiceImpl.java | 6 +++--- .../booking/service/BookingScheduler.java | 7 +++++-- .../domain/payment/service/PaymentService.java | 13 +++++++++---- 6 files changed, 46 insertions(+), 9 deletions(-) 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..05883ad9 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); } 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..d786cc13 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"} + ) +) public class BookingTable { @Id @@ -23,4 +33,11 @@ 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; } 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..b57a8414 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); /** 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..acb0838b 100644 --- a/src/main/java/com/eatsfine/domain/booking/service/BookingCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/domain/booking/service/BookingCommandServiceImpl.java @@ -153,7 +153,7 @@ 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)); //이미 예약이 확정됐는지 최종 확인 @@ -182,7 +182,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 +220,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..3e5b2d03 100644 --- a/src/main/java/com/eatsfine/domain/booking/service/BookingScheduler.java +++ b/src/main/java/com/eatsfine/domain/booking/service/BookingScheduler.java @@ -3,11 +3,11 @@ 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; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; @@ -42,7 +42,10 @@ public void cleanupExpiredPendingBookings() { // 2. 상태 변경 및 로그 기록 expiredBookings.forEach(booking -> { - booking.cancel("결제 시간 초과로 인한 자동 취소"); + // 조회 후 상태가 변경되었을 수 있으므로 PENDING 여부를 재확인 + if (booking.getStatus() == BookingStatus.PENDING) { + booking.cancel("결제 시간 초과로 인한 자동 취소"); + } }); } 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..655e124a 100644 --- a/src/main/java/com/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/domain/payment/service/PaymentService.java @@ -1,6 +1,7 @@ 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.repository.BookingRepository; import com.eatsfine.domain.payment.dto.request.PaymentWebhookDTO; import com.eatsfine.domain.payment.dto.request.PaymentConfirmDTO; @@ -115,11 +116,15 @@ 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()) + .orElse(null); + if (lockedBooking != null && lockedBooking.getStatus() != BookingStatus.CONFIRMED) { + lockedBooking.confirm(); + log.info("Booking confirmed for OrderID: {}", dto.orderId()); + } } From 6e3c14a4c033b78ca69fe9c81b9dd2af5726dac5 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 7 May 2026 14:54:40 +0900 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20=EC=98=88=EC=95=BD=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EC=8B=9C=20BookingTable=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=9C=A0=EB=8B=88=ED=81=AC=20=EC=A0=9C=EC=95=BD=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 취소된 예약이 점유하던 BookingTable 레코드를 cancel() 시 제거하여 동일 시간대 재예약이 가능하도록 수정. orphanRemoval=true 활용. Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/eatsfine/domain/booking/entity/Booking.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 05883ad9..d9aa372d 100644 --- a/src/main/java/com/eatsfine/domain/booking/entity/Booking.java +++ b/src/main/java/com/eatsfine/domain/booking/entity/Booking.java @@ -104,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; + // 유니크 제약 해제: 취소된 예약의 테이블 점유를 풀어 동일 시간대 재예약 허용 + // orphanRemoval = true이므로 리스트 비우면 DB에서 자동 삭제됨 + this.bookingTables.clear(); } //예약과 관련된 결제 중 결제 완료된 결제키 조회 From d89fd226f431231567fa6a9c2354fc4894f2e916 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 7 May 2026 15:01:18 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20=EA=B2=B0=EC=A0=9C=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20Booking=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=84=EC=9D=B4=20=EC=A1=B0=EA=B1=B4=EC=9D=84=20PENDING?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=95=9C=EC=A0=95=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONFIRMED/COMPLETED/CANCELED 등 이미 확정된 예약에 다시 CONFIRMED를 덮어쓰는 논리 오류 방지. Co-Authored-By: Claude Sonnet 4.6 --- .../com/eatsfine/domain/payment/service/PaymentService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 655e124a..f101db93 100644 --- a/src/main/java/com/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/domain/payment/service/PaymentService.java @@ -121,7 +121,7 @@ public PaymentResponseDTO.PaymentSuccessResultDTO confirmPayment(PaymentConfirmD // 비관적 락으로 재조회하여 스케줄러 / 다른 스레드와의 동시 수정 방지 Booking lockedBooking = bookingRepository.findByIdWithLock(booking.getId()) .orElse(null); - if (lockedBooking != null && lockedBooking.getStatus() != BookingStatus.CONFIRMED) { + if (lockedBooking != null && lockedBooking.getStatus() == BookingStatus.PENDING) { lockedBooking.confirm(); log.info("Booking confirmed for OrderID: {}", dto.orderId()); } From bab83c34f76f0ab3c7014c3c0216156bbde76f7d Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 7 May 2026 15:08:49 +0900 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EB=B0=B0=EC=B9=98=20=EC=B7=A8=EC=86=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=EB=B3=84=20=EB=8F=85=EB=A6=BD=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=EC=9C=BC=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BookingCancelExecutor 추가 (REQUIRES_NEW) — 낙관적 락 충돌 발생 시 해당 예약만 실패하고 나머지 배치는 정상 처리됨. 스케줄러는 트랜잭션 없이 조율만 담당하고 실패는 warn 로그로 기록. Co-Authored-By: Claude Sonnet 4.6 --- .../service/BookingCancelExecutor.java | 39 +++++++++++++++++++ .../booking/service/BookingScheduler.java | 35 ++++++++--------- 2 files changed, 55 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/eatsfine/domain/booking/service/BookingCancelExecutor.java 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..351a0568 --- /dev/null +++ b/src/main/java/com/eatsfine/domain/booking/service/BookingCancelExecutor.java @@ -0,0 +1,39 @@ +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.findAllByStatusAndCreatedAtBefore(BookingStatus.PENDING, threshold) + .stream() + .map(booking -> booking.getId()) + .toList(); + } + + // REQUIRES_NEW: 호출마다 독립 트랜잭션 — 하나 실패해도 다른 예약에 영향 없음 + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void cancelIfPending(Long bookingId) { + bookingRepository.findByIdWithLock(bookingId).ifPresent(booking -> { + if (booking.getStatus() == BookingStatus.PENDING) { + booking.cancel("결제 시간 초과로 인한 자동 취소"); + log.info("예약 ID {} 자동 취소 완료", bookingId); + } + }); + } +} 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 3e5b2d03..ec78624a 100644 --- a/src/main/java/com/eatsfine/domain/booking/service/BookingScheduler.java +++ b/src/main/java/com/eatsfine/domain/booking/service/BookingScheduler.java @@ -1,13 +1,9 @@ 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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; @@ -17,36 +13,37 @@ @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()); + log.info("스케줄러 실행: 만료된 PENDING 예약 {}건 처리 시작", expiredIds.size()); - // 2. 상태 변경 및 로그 기록 - expiredBookings.forEach(booking -> { - // 조회 후 상태가 변경되었을 수 있으므로 PENDING 여부를 재확인 - if (booking.getStatus() == BookingStatus.PENDING) { - booking.cancel("결제 시간 초과로 인한 자동 취소"); + int successCount = 0; + for (Long id : expiredIds) { + try { + bookingCancelExecutor.cancelIfPending(id); + successCount++; + } catch (Exception e) { + log.warn("예약 ID {} 자동 취소 실패 — 다음 실행에서 재시도: {}", id, e.getMessage()); } - }); + } + log.info("스케줄러 완료: {}건 성공 / {}건 시도", successCount, expiredIds.size()); } } From 8ab24a936c0664f6ec41ddae53e0ad4ba83a0f42 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 7 May 2026 15:46:22 +0900 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20=EC=98=88=EC=95=BD=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EC=8B=9C=20BookingTable=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20is=5Factive=3Dnull=20=EC=86=8C=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EB=94=9C=EB=A6=AC=ED=8A=B8=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BookingTable에 is_active(nullable Boolean) 컬럼 추가 - 유니크 제약을 (store_table_id, booking_date, booking_time, is_active)로 변경 - cancel() 시 bookingTables.clear() 제거 → deactivate()로 is_active=null 처리 - MySQL NULL 유니크 특성으로 취소 슬롯 재예약 허용, 테이블 이력 데이터 보존 Co-Authored-By: Claude Sonnet 4.6 --- .../com/eatsfine/domain/booking/entity/Booking.java | 6 +++--- .../domain/booking/entity/mapping/BookingTable.java | 11 ++++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) 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 d9aa372d..02c8cfbb 100644 --- a/src/main/java/com/eatsfine/domain/booking/entity/Booking.java +++ b/src/main/java/com/eatsfine/domain/booking/entity/Booking.java @@ -107,9 +107,9 @@ public void confirm() { public void cancel(String cancelReason) { this.status = BookingStatus.CANCELED; this.cancelReason = cancelReason; - // 유니크 제약 해제: 취소된 예약의 테이블 점유를 풀어 동일 시간대 재예약 허용 - // orphanRemoval = true이므로 리스트 비우면 DB에서 자동 삭제됨 - this.bookingTables.clear(); + // 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 d786cc13..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 @@ -17,7 +17,7 @@ name = "booking_table", uniqueConstraints = @UniqueConstraint( name = "uq_booking_table_slot", - columnNames = {"store_table_id", "booking_date", "booking_time"} + columnNames = {"store_table_id", "booking_date", "booking_time", "is_active"} ) ) public class BookingTable { @@ -40,4 +40,13 @@ public class BookingTable { @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; + } } From 9117e44c31fadb2b39cea92b3f87ecdb0c9b2fc7 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 7 May 2026 17:20:57 +0900 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20DB=20=EC=9C=A0=EB=8B=88=ED=81=AC?= =?UTF-8?q?=20=EC=A0=9C=EC=95=BD=20=EC=9C=84=EB=B0=98=EC=9D=84=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=98=88=EC=99=B8=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 동시 예약 생성 시 uq_booking_table_slot 제약 위반으로 발생하는 DataIntegrityViolationException을 BookingException(_ALREADY_RESERVED_TABLE)로 변환. 500 대신 409 Conflict 응답을 반환함. Co-Authored-By: Claude Sonnet 4.6 --- .../booking/service/BookingCommandServiceImpl.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 acb0838b..a5916d4f 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,14 @@ 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 유니크 제약 위반 — 동시 요청으로 동일 슬롯이 선점된 경우 + throw new BookingException(BookingErrorStatus._ALREADY_RESERVED_TABLE); + } // 결제 대기 데이터 생성 (내부 서비스 호출) PaymentRequestDTO.RequestPaymentDTO paymentRequest = new PaymentRequestDTO.RequestPaymentDTO(savedBooking.getId()); From ebaed6bee2ed60a793bf4c60b822be1e45b816e8 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 7 May 2026 17:34:58 +0900 Subject: [PATCH 07/12] =?UTF-8?q?chore:=20Flyway=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?=EB=B0=8F=20booking=5Ftable=20=EC=8A=AC=EB=A1=AF=20=EC=A0=9C?= =?UTF-8?q?=EC=95=BD=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flyway-mysql 의존성 추가 - V1 마이그레이션: booking_date/time 백필 → NOT NULL → 유니크 제약 추가 - Booking.version 컬럼 추가 (낙관적 락) - application-local: ddl-auto update→validate, flyway baseline-on-migrate 설정 - application-test: flyway disabled (H2 create-drop 유지) [prod 적용 가이드] application-prod.yml에 아래 설정 추가 필요: spring.jpa.hibernate.ddl-auto: validate spring.flyway.enabled: true spring.flyway.baseline-on-migrate: true spring.flyway.baseline-version: 0 Co-Authored-By: Claude Sonnet 4.6 --- build.gradle | 3 ++ src/main/resources/application-local.yml | 6 ++- ...V1__add_booking_table_slot_constraints.sql | 40 +++++++++++++++++++ src/test/resources/application-test.yml | 2 + 4 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V1__add_booking_table_slot_constraints.sql 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/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 From 8a28e52bde8fe49081d6d31ee068de2e98ed234a Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 7 May 2026 17:39:51 +0900 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20DataIntegrityViolationException?= =?UTF-8?q?=EC=9D=84=20=EC=A0=9C=EC=95=BD=20=EC=9D=B4=EB=A6=84=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=84=A0=EB=B3=84=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GeneralException/BookingException에 cause 생성자 추가 (원본 스택 트레이스 보존) - uq_booking_table_slot 위반만 _ALREADY_RESERVED_TABLE(409)로 변환 - FK/NOT NULL 등 다른 제약 위반은 원본 예외 그대로 전파 (500 처리 유지) Co-Authored-By: Claude Sonnet 4.6 --- .../domain/booking/exception/BookingException.java | 4 ++++ .../domain/booking/service/BookingCommandServiceImpl.java | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) 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/service/BookingCommandServiceImpl.java b/src/main/java/com/eatsfine/domain/booking/service/BookingCommandServiceImpl.java index a5916d4f..5d0149df 100644 --- a/src/main/java/com/eatsfine/domain/booking/service/BookingCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/domain/booking/service/BookingCommandServiceImpl.java @@ -132,8 +132,12 @@ public BookingResponseDTO.CreateBookingResultDTO createBooking(Long userId, Long savedBooking = bookingRepository.save(booking); bookingRepository.flush(); } catch (DataIntegrityViolationException e) { - // uq_booking_table_slot 유니크 제약 위반 — 동시 요청으로 동일 슬롯이 선점된 경우 - throw new BookingException(BookingErrorStatus._ALREADY_RESERVED_TABLE); + // 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; } // 결제 대기 데이터 생성 (내부 서비스 호출) From 34424308d04e6652cf8a71d9e3b66c9b23ed4919 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 7 May 2026 17:44:26 +0900 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20GeneralException=20cause=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9E=90=20=EB=88=84=EB=9D=BD=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(CI=20=EB=B9=8C=EB=93=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0)=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS 대소문자 무시 파일시스템으로 인해 이전 커밋에서 GeneralException.java 변경사항이 누락됨. Co-Authored-By: Claude Sonnet 4.6 --- .../global/apipayload/exception/GeneralException.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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; + } } From aa1cbedd90e26339f85fc6b76fe138b31f1e5335 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 7 May 2026 18:24:48 +0900 Subject: [PATCH 10/12] =?UTF-8?q?fix:=20confirmPayment=EC=97=90=EC=84=9C?= =?UTF-8?q?=20CANCELED=20=EC=98=88=EC=95=BD=EC=97=90=20=EB=8C=80=ED=95=9C?= =?UTF-8?q?=20=EB=B3=B4=EC=83=81=20=ED=99=98=EB=B6=88=20=EB=B0=8F=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=A8=B8=EC=8B=A0=20=EC=9C=84=EB=B0=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PaymentService.confirmPayment: orElse(null) → orElseThrow로 변경하여 예약 누락 방지 - CANCELED 상태 감지 시 Toss 자동 환불 후 payment.cancelPayment() 처리 (noRollbackFor = GeneralException.class로 환불 상태 커밋 보장) - CONFIRMED 상태는 멱등성 처리 (중복 확정 방지) - BookingCommandServiceImpl.confirmPayment: CANCELED 체크 추가하여 취소된 예약이 CONFIRMED로 잘못 전이되는 상태 머신 위반 방지 Co-Authored-By: Claude Sonnet 4.6 --- .../service/BookingCommandServiceImpl.java | 7 ++++-- .../payment/service/PaymentService.java | 25 +++++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) 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 5d0149df..870c2e44 100644 --- a/src/main/java/com/eatsfine/domain/booking/service/BookingCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/domain/booking/service/BookingCommandServiceImpl.java @@ -167,8 +167,11 @@ public BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(Long 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); } 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 f101db93..ea004d16 100644 --- a/src/main/java/com/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/domain/payment/service/PaymentService.java @@ -2,7 +2,9 @@ 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; @@ -120,8 +122,27 @@ public PaymentResponseDTO.PaymentSuccessResultDTO confirmPayment(PaymentConfirmD if (booking != null) { // 비관적 락으로 재조회하여 스케줄러 / 다른 스레드와의 동시 수정 방지 Booking lockedBooking = bookingRepository.findByIdWithLock(booking.getId()) - .orElse(null); - if (lockedBooking != null && lockedBooking.getStatus() == BookingStatus.PENDING) { + .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()); } From d01060b3fa31245107733e6656f35b5068f63b99 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 7 May 2026 18:31:46 +0900 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EC=A7=80=ED=91=9C=20=EC=A0=95=ED=99=95=EB=8F=84=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=E2=80=94=20cancelIfPending=20boolean=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실제 취소 여부와 무관하게 successCount가 증가하던 문제 수정. 취소 완료/스킵/시도 건수를 분리하여 로그 지표 명확화. Co-Authored-By: Claude Sonnet 4.6 --- .../service/BookingCancelExecutor.java | 19 ++++++++++++------- .../booking/service/BookingScheduler.java | 10 ++++++---- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/eatsfine/domain/booking/service/BookingCancelExecutor.java b/src/main/java/com/eatsfine/domain/booking/service/BookingCancelExecutor.java index 351a0568..1e913632 100644 --- a/src/main/java/com/eatsfine/domain/booking/service/BookingCancelExecutor.java +++ b/src/main/java/com/eatsfine/domain/booking/service/BookingCancelExecutor.java @@ -27,13 +27,18 @@ public List findExpiredPendingIds(LocalDateTime threshold) { } // REQUIRES_NEW: 호출마다 독립 트랜잭션 — 하나 실패해도 다른 예약에 영향 없음 + // 반환값: 실제로 취소가 수행된 경우 true, 이미 상태 변경된 경우 false @Transactional(propagation = Propagation.REQUIRES_NEW) - public void cancelIfPending(Long bookingId) { - bookingRepository.findByIdWithLock(bookingId).ifPresent(booking -> { - if (booking.getStatus() == BookingStatus.PENDING) { - booking.cancel("결제 시간 초과로 인한 자동 취소"); - log.info("예약 ID {} 자동 취소 완료", bookingId); - } - }); + 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/BookingScheduler.java b/src/main/java/com/eatsfine/domain/booking/service/BookingScheduler.java index ec78624a..dfed0c04 100644 --- a/src/main/java/com/eatsfine/domain/booking/service/BookingScheduler.java +++ b/src/main/java/com/eatsfine/domain/booking/service/BookingScheduler.java @@ -34,16 +34,18 @@ public void cleanupExpiredPendingBookings() { log.info("스케줄러 실행: 만료된 PENDING 예약 {}건 처리 시작", expiredIds.size()); - int successCount = 0; + int canceledCount = 0; + int skippedCount = 0; for (Long id : expiredIds) { try { - bookingCancelExecutor.cancelIfPending(id); - successCount++; + if (bookingCancelExecutor.cancelIfPending(id)) canceledCount++; + else skippedCount++; } catch (Exception e) { log.warn("예약 ID {} 자동 취소 실패 — 다음 실행에서 재시도: {}", id, e.getMessage()); } } - log.info("스케줄러 완료: {}건 성공 / {}건 시도", successCount, expiredIds.size()); + log.info("스케줄러 완료: 취소 {}건 / 스킵 {}건 / 시도 {}건", + canceledCount, skippedCount, expiredIds.size()); } } From 568d29e9d2b2ed7d7ce7d2347b8e543d3f35ffaa Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Fri, 8 May 2026 21:43:33 +0900 Subject: [PATCH 12/12] =?UTF-8?q?perf:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EB=A7=8C=EB=A3=8C=20=EC=98=88=EC=95=BD=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20ID=EB=A7=8C=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20#157?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전체 Booking 엔티티 로드 후 ID 매핑 → SELECT id만 조회하는 findIdsByStatusAndCreatedAtBefore() 추가로 불필요한 메모리/GC 부하 제거. Co-Authored-By: Claude Sonnet 4.6 --- .../domain/booking/repository/BookingRepository.java | 4 ++++ .../domain/booking/service/BookingCancelExecutor.java | 5 +---- 2 files changed, 5 insertions(+), 4 deletions(-) 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 b57a8414..34024ccd 100644 --- a/src/main/java/com/eatsfine/domain/booking/repository/BookingRepository.java +++ b/src/main/java/com/eatsfine/domain/booking/repository/BookingRepository.java @@ -87,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 index 1e913632..89e2f649 100644 --- a/src/main/java/com/eatsfine/domain/booking/service/BookingCancelExecutor.java +++ b/src/main/java/com/eatsfine/domain/booking/service/BookingCancelExecutor.java @@ -20,10 +20,7 @@ public class BookingCancelExecutor { @Transactional(readOnly = true) public List findExpiredPendingIds(LocalDateTime threshold) { - return bookingRepository.findAllByStatusAndCreatedAtBefore(BookingStatus.PENDING, threshold) - .stream() - .map(booking -> booking.getId()) - .toList(); + return bookingRepository.findIdsByStatusAndCreatedAtBefore(BookingStatus.PENDING, threshold); } // REQUIRES_NEW: 호출마다 독립 트랜잭션 — 하나 실패해도 다른 예약에 영향 없음