Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
11 changes: 9 additions & 2 deletions src/main/java/com/eatsfine/domain/booking/entity/Booking.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}

//예약과 관련된 결제 중 결제 완료된 결제키 조회
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)
Comment on lines +18 to +21
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

uq_booking_table_slot 유니크 제약 조건은 오버부킹을 방지하는 강력한 수단이지만, 현재 구현상 심각한 부작용이 있습니다. 예약이 취소(CANCELED)되어도 BookingTable 레코드가 삭제되지 않고 유지되기 때문에, 취소된 예약이 점유했던 동일한 시간대에 다른 사용자가 예약을 시도하면 DB 제약 조건 위반으로 인해 재예약이 불가능해집니다. 이를 해결하려면 예약 취소 시 연관된 BookingTable 데이터를 삭제(또는 Soft Delete 처리 후 유니크 인덱스에서 제외)하는 로직이 반드시 추가되어야 합니다.

)
Comment on lines +16 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

기존 테이블에 NOT NULL 컬럼 추가 시 데이터 백필 마이그레이션 필요

booking_date/booking_time 컬럼은 nullable = false로 추가되지만, 기존 booking_table 행에는 해당 값이 없습니다. 마이그레이션 스크립트 없이 적용하면 NOT NULL 제약 위반으로 마이그레이션이 실패합니다.

권장 마이그레이션 순서:

  1. 컬럼을 NULLABLE로 추가
  2. 기존 행에 연관 bookingbooking_date/booking_time으로 백필
  3. 취소된 예약의 BookingTable 행을 삭제(유니크 제약 충돌 방지, Booking.cancel() 수정과 함께)
  4. NOT NULL 제약 추가
  5. 유니크 제약 추가

또한 BookingCommandServiceImpl.createBookingbookingRepository.flush()(Line 130)에서 유니크 제약 위반 시 DataIntegrityViolationException이 발생하므로, 글로벌 예외 핸들러에서 이를 명시적으로 "이미 예약된 슬롯" 오류로 처리해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/eatsfine/domain/booking/entity/mapping/BookingTable.java`
around lines 16 - 22, 테이블 매이그레이션과 예외처리를 분리해 해결하세요: BookingTable 엔티티의
booking_date/booking_time을 즉시 NOT NULL로 변경하지 말고 마이그레이션에서(1) 컬럼을 NULL 허용으로
추가하고(2) 기존 Booking 레코드의 값으로 backfill(참조: Booking → booking_date/booking_time
매핑)하며(3) 취소된 예약의 잔여 BookingTable 행은 삭제하도록 Booking.cancel() 로직을 보강해 유니크 충돌을 방지한
뒤(4) 컬럼을 NOT NULL로 변경하고(5) 유니크 제약(uq_booking_table_slot) 추가하세요; 또한
BookingCommandServiceImpl.createBooking 내 bookingRepository.flush() 호출로 발생하는
DataIntegrityViolationException을 글로벌 예외 핸들러에서 잡아 “이미 예약된 슬롯” 오류로 변환 처리하도록 예외 매핑을
추가하세요.

public class BookingTable {

@Id
Expand All @@ -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)
Comment on lines +38 to +41
private LocalTime bookingTime;

// true = 활성 예약 슬롯 (유니크 제약 적용)
// null = 취소된 슬롯 — MySQL은 NULL을 유니크 인덱스에서 중복으로 보지 않으므로 동일 시간대 재예약 허용
@Column(name = "is_active")
private Boolean isActive = true;
Comment on lines +46 to +47
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP --type=java -C3 'BookingTable\s*\.\s*builder\s*\(\s*\)'

Repository: Eatsfine/BE

Length of output: 751


🏁 Script executed:

cat -n src/main/java/com/eatsfine/domain/booking/entity/mapping/BookingTable.java | head -70

Repository: Eatsfine/BE

Length of output: 1803


🏁 Script executed:

cat -n src/main/java/com/eatsfine/domain/booking/entity/Booking.java | sed -n '85,100p'

Repository: Eatsfine/BE

Length of output: 629


🏁 Script executed:

rg -nP --type=java "uq_booking_table_slot|unique.*booking.*table" -i

Repository: Eatsfine/BE

Length of output: 173


🏁 Script executed:

# Check for `@Builder` and `@AllArgsConstructor` annotations in BookingTable
rg -nP --type=java "@Builder|@AllArgsConstructor" src/main/java/com/eatsfine/domain/booking/entity/mapping/BookingTable.java

Repository: Eatsfine/BE

Length of output: 90


🏁 Script executed:

# Search for all BookingTable.builder() usages to see the full scope
rg -nP --type=java 'BookingTable\.builder\(\)'

Repository: Eatsfine/BE

Length of output: 179


@Builder.Default 누락 — 신규 슬롯이 isActive=null로 생성되어 유니크 제약이 무력화됨

@Builder + @AllArgsConstructor 조합에서는 필드 초기값(= true)이 빌더에 반영되지 않습니다. @Builder.Default가 없으므로 BookingTable.builder()...build()isActivetrue가 아닌 null로 채워집니다.

Booking.addBookingTable(StoreTable) (Booking.java Line 89~97)는 isActive를 명시적으로 설정하지 않으므로, 신규 예약 슬롯도 is_active=NULL로 저장됩니다. MySQL 유니크 인덱스는 NULL을 중복으로 취급하지 않기 때문에 uq_booking_table_slot 제약이 이중 예약을 전혀 차단하지 못합니다 — 이번 PR의 핵심 방어선이 무효화됩니다.

🛡️ 제안 수정 — `@Builder.Default` 추가
     // true = 활성 예약 슬롯 (유니크 제약 적용)
     // null = 취소된 슬롯 — MySQL은 NULL을 유니크 인덱스에서 중복으로 보지 않으므로 동일 시간대 재예약 허용
+    `@Builder.Default`
     `@Column`(name = "is_active")
     private Boolean isActive = true;

또한 운영 DB에 이미 is_active=NULL로 잘못 적재된 활성 슬롯이 존재할 수 있으니 마이그레이션으로 보정 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/eatsfine/domain/booking/entity/mapping/BookingTable.java`
around lines 46 - 47, The BookingTable.isActive field is initialized to true but
lacks Lombok's `@Builder.Default`, so using BookingTable.builder() yields isActive
== null and breaks the unique slot constraint; add the `@Builder.Default`
annotation to the isActive field declaration in BookingTable (keeping the = true
initializer) so builders default to true, and verify call sites like
Booking.addBookingTable(StoreTable) rely on the builder-provided default; also
plan a DB migration to fix any existing is_active=NULL rows.


public void deactivate() {
this.isActive = null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ public class BookingException extends GeneralException {
public BookingException(BaseErrorCode code) {
super(code);
}

public BookingException(BaseErrorCode code, Throwable cause) {
super(code, cause);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -70,6 +73,10 @@ List<Booking> findActiveBookingsByTableAndDate(
@Param("tableId") Long tableId,
@Param("date") LocalDate date);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT b FROM Booking b WHERE b.id = :id")
Optional<Booking> findByIdWithLock(@Param("id") Long id);
Comment on lines +76 to +78
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

findByIdWithLock에 락 타임아웃이 설정되지 않아 무한 블로킹 위험

@Lock(PESSIMISTIC_WRITE)SELECT ... FOR UPDATE를 발행하지만, 타임아웃 힌트가 없으면 락을 보유한 트랜잭션이 느릴 때(예: cancelBooking/cancelBookingByOwner에서 Toss 외부 API 호출 중) 대기 트랜잭션이 무한정 블로킹되어 DB 커넥션 풀이 고갈될 수 있습니다.

🛡️ 제안 수정
+import org.springframework.data.jpa.repository.QueryHints;
+import jakarta.persistence.QueryHint;
...
 `@Lock`(LockModeType.PESSIMISTIC_WRITE)
+@QueryHints(`@QueryHint`(name = "jakarta.persistence.lock.timeout", value = "3000"))
 `@Query`("SELECT b FROM Booking b WHERE b.id = :id")
 Optional<Booking> findByIdWithLock(`@Param`("id") Long id);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/eatsfine/domain/booking/repository/BookingRepository.java`
around lines 76 - 78, findByIdWithLock currently uses `@Lock`(PESSIMISTIC_WRITE)
without a lock timeout, which can cause indefinite blocking and exhaustion of DB
connections when callers like cancelBooking or cancelBookingByOwner perform long
external calls; add a pessimistic lock timeout hint to the repository method
(e.g., via `@QueryHints/`@QueryHint with "javax.persistence.lock.timeout" set to a
sensible millisecond value such as 5000) on the findByIdWithLock method so the
DB will fail the wait after the configured timeout instead of blocking forever.


Optional<Booking> findByIdAndStatus(Long bookingId, BookingStatus status);

/**
Expand All @@ -80,6 +87,10 @@ List<Booking> findActiveBookingsByTableAndDate(
*/
List<Booking> findAllByStatusAndCreatedAtBefore(BookingStatus status, LocalDateTime threshold);

@Query("SELECT b.id FROM Booking b WHERE b.status = :status AND b.createdAt < :threshold")
List<Long> findIdsByStatusAndCreatedAtBefore(@Param("status") BookingStatus status,
@Param("threshold") LocalDateTime threshold);


/**
* 특정 식당의 브레이크 타임과 겹치는 가장 늦은 예약 날짜를 조회합니다.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Long> 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);
}
Comment on lines +22 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

락/REQUIRES_NEW 적용은 잘 되어 있으며, 매직 문자열 상수화 및 ID 전용 프로젝션 쿼리 권장

전체 구조(락 재조회 + 독립 트랜잭션 + PENDING 재검증)는 적절합니다. 두 가지 사소한 개선 의견입니다.

  1. "결제 시간 초과로 인한 자동 취소"는 매직 문자열입니다. 향후 다른 자동 취소 사유가 추가되거나 다국어화 시 분기점이 되도록 상수로 추출하는 편이 안전합니다. (코드 품질 가이드: 매직 넘버/하드코딩 값 점검)
  2. findExpiredPendingIds가 만료 후보 Booking 엔티티 전체를 로드한 뒤 ID만 추출합니다. 만료 건수가 많아질 경우 메모리/연관 lazy 프록시 비용이 누적되므로, 리포지토리에 @Query("select b.id from Booking b where b.status = ?1 and b.createdAt < ?2") 형태의 ID 전용 쿼리를 추가하는 편이 효율적입니다. (성능 가이드: 불필요한 DB 쿼리/Stream 효율성)
♻️ 제안 수정 — 상수 추출 및 메소드 레퍼런스
+    private static final String CANCEL_REASON_PAYMENT_TIMEOUT = "결제 시간 초과로 인한 자동 취소";
+
     `@Transactional`(readOnly = true)
     public List<Long> findExpiredPendingIds(LocalDateTime threshold) {
         return bookingRepository.findAllByStatusAndCreatedAtBefore(BookingStatus.PENDING, threshold)
                 .stream()
-                .map(booking -> booking.getId())
+                .map(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("결제 시간 초과로 인한 자동 취소");
+                booking.cancel(CANCEL_REASON_PAYMENT_TIMEOUT);
                 log.info("예약 ID {} 자동 취소 완료", bookingId);
             }
         });
     }

위 코딩 가이드라인의 “3. 성능 및 효율성: 불필요한 DB 쿼리 호출…”과 “2. 코드 품질 & 가독성: 매직 넘버, 하드코딩된 값이 존재하는지 점검”에 따른 의견입니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/eatsfine/domain/booking/service/BookingCancelExecutor.java`
around lines 22 - 38, Extract the hard-coded cancel reason in cancelIfPending
("결제 시간 초과로 인한 자동 취소") into a constant (e.g., AUTO_CANCEL_REASON or
Booking.CANCEL_REASON_AUTO_TIMEOUT) and use that constant in Booking.cancel call
and any related logs; also change findExpiredPendingIds to avoid loading full
Booking entities by adding an ID-only repository query (e.g., add a method like
findIdsByStatusAndCreatedAtBefore in BookingRepository implemented with
`@Query`("select b.id from Booking b where b.status = ?1 and b.createdAt < ?2"))
and update findExpiredPendingIds to call that new repository method instead of
findAllByStatusAndCreatedAtBefore to reduce memory/ORM overhead.

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 결제 대기 데이터 생성 (내부 서비스 호출)
PaymentRequestDTO.RequestPaymentDTO paymentRequest = new PaymentRequestDTO.RequestPaymentDTO(savedBooking.getId());
Expand All @@ -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);
}

Expand All @@ -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));


Expand Down Expand Up @@ -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. 데이터 무결성 검증
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Booking> expiredBookings = bookingRepository.findAllByStatusAndCreatedAtBefore(
BookingStatus.PENDING,
threshold
);
List<Long> 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());
Comment on lines +44 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

실패 원인 추적을 위해 예외 객체와 실패 건수를 함께 기록하세요.

Line 44는 메시지만 남겨 원인 분석이 어렵고, Line 48-49 요약에도 실패 건수가 빠져 운영 지표 해석이 모호합니다.

🔧 제안 수정
         int canceledCount = 0;
         int skippedCount = 0;
+        int failedCount = 0;
         for (Long id : expiredIds) {
             try {
                 if (bookingCancelExecutor.cancelIfPending(id)) canceledCount++;
                 else skippedCount++;
             } catch (Exception e) {
-                log.warn("예약 ID {} 자동 취소 실패 — 다음 실행에서 재시도: {}", id, e.getMessage());
+                failedCount++;
+                log.warn("예약 ID {} 자동 취소 실패 — 다음 실행에서 재시도", id, e);
             }
         }
 
-        log.info("스케줄러 완료: 취소 {}건 / 스킵 {}건 / 시도 {}건",
-                canceledCount, skippedCount, expiredIds.size());
+        log.info("스케줄러 완료: 취소 {}건 / 스킵 {}건 / 실패 {}건 / 시도 {}건",
+                canceledCount, skippedCount, failedCount, expiredIds.size());

As per coding guidelines, "1. 예외 처리 - 예외가 적절히 처리되었는지 확인."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/eatsfine/domain/booking/service/BookingScheduler.java`
around lines 44 - 49, In BookingScheduler (the block where reservation
auto-cancel is attempted), update the exception logging inside the catch that
currently does log.warn("예약 ID {} 자동 취소 실패 — 다음 실행에서 재시도: {}", id,
e.getMessage()) to include the full exception object (e) so stacktrace and cause
are recorded (e.g., pass e as a parameter to the logger) and ensure you
increment or compute a failure counter (e.g., failedCount) for each caught
exception; then include that failure count alongside canceledCount, skippedCount
and expiredIds.size() in the final log.info summary so the total number of
failures is visible in the scheduler completion message.

}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Comment on lines +131 to +144
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

비관적 락 + 트랜잭션 내에서 외부 결제 취소 API를 호출하고 있습니다.

bookingRepository.findByIdWithLock(...)로 booking 행에 PESSIMISTIC_WRITE 락을 잡은 상태로 tossPaymentService.cancel(...) 외부 호출을 수행합니다. 네트워크 지연/타임아웃 동안 booking 행 락이 유지되어 다른 트랜잭션이 차단되며, Toss 측 응답 지연 시 connection pool 고갈로 이어질 수 있습니다.

권장 패턴:

  1. 락 트랜잭션에서는 booking 상태만 검증·기록하고 즉시 커밋
  2. 외부 환불 호출은 트랜잭션 외부에서 수행 (별도 컴포넌트/이벤트 발행)
  3. 환불 결과는 멱등 키 기반 후속 트랜잭션으로 반영

또한 try/catch (Exception refundEx)로 환불 실패를 단순 로깅 후 통과시키고 있습니다. 환불 실패 시 payment.cancelPayment()만 호출되어 DB는 취소 상태로 보이지만 Toss 측 자금은 보류된 채 남는 정합성 깨짐이 발생합니다. 최소한 알림/메트릭/재시도 큐 적재로 수동 개입을 보장해야 합니다.

As per coding guidelines, 트랜잭션 관리와 DB 일관성·롤백 정책을 검토해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/eatsfine/domain/payment/service/PaymentService.java` around
lines 131 - 144, The code currently holds a PESSIMISTIC_WRITE lock from
bookingRepository.findByIdWithLock(...) while calling the external
tossPaymentService.cancel(...) and then updates payment.cancelPayment(), which
blocks DB rows and risks inconsistency if the external call fails; refactor so
the locked transaction only validates and persists the booking/payment state
change (e.g., set status to CANCELED or create a CancellationRequest record) and
commit immediately, then perform tossPaymentService.cancel(...) outside the
transaction (via a separate `@Transactional` method, background worker/event
published, or retry queue) using an idempotency key; ensure failures from
tossPaymentService.cancel are not merely logged—push to retry/compensation
queue, emit metrics/alerts, and record the external refund outcome in a
follow-up transactional update method (use symbols payment.cancelPayment(),
BookingException/BookingErrorStatus._ALREADY_CANCELED as before).

} else {
lockedBooking.confirm();
Comment on lines +123 to +146
log.info("Booking confirmed for OrderID: {}", dto.orderId());
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
6 changes: 5 additions & 1 deletion src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading