Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.catchtable.global.exception;

import com.catchtable.global.common.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
Expand All @@ -11,6 +12,7 @@
// 엔드포인트의 예외까지 이 advice가 가로채면, ApiResponse(JSON)를 openmetrics 응답으로
// 직렬화하려다 HttpMessageNotWritableException → 스크랩 500 → 모니터링 DOWN 오표시가 발생.
// 본인 컨트롤러(com.catchtable.*)로만 적용 범위를 한정해 액추에이터를 건드리지 않게 한다.
@Slf4j
@RestControllerAdvice(basePackages = "com.catchtable")
public class GlobalExceptionHandler {

Expand Down Expand Up @@ -55,9 +57,11 @@ public ResponseEntity<ApiResponse<Void>> handleSpringOptimisticLock(org.springfr
.body(ApiResponse.error(ErrorCode.OPTIMISTIC_LOCK_CONFLICT));
}

// 500 - 서버 내부 오류
// 500 - 서버 내부 오류 (Unhandled)
// log.error 로 클래스/메시지/스택트레이스를 남겨야 원인 추적 가능.
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
log.error("Unhandled exception → INTERNAL_ERROR: {}", e.getMessage(), e);
return ResponseEntity
.status(ErrorCode.INTERNAL_ERROR.getHttpStatus())
.body(ApiResponse.error(ErrorCode.INTERNAL_ERROR));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,17 @@ public void cancelReservation(Long reservationId, Long userId) {
// 결제 미완료: PAYMENT_FAILED로 기록 (사용자 예약 취소 내역과 구분)
handlePendingFailure(reservation);
} else {
// 결제 완료(CONFIRMED): PortOne 환불 후 CANCELED로 변경
paymentService.refundPayment(reservation);
// 결제 완료(CONFIRMED): PortOne 환불 후 CANCELED로 변경.
// 결제 정보가 없는 비정상 상태(과거 cleanup 흔적 등)에서도 취소가 가능하도록
// PAYMENT_NOT_FOUND 는 graceful 처리. PortOne API 자체 실패는 그대로 전파한다.
try {
paymentService.refundPayment(reservation);
} catch (CustomException e) {
if (e.getErrorCode() != ErrorCode.PAYMENT_NOT_FOUND) {
throw e;
}
log.warn("환불 대상 결제 없음 — 취소 진행: reservationId={}", reservationId);
}
Comment on lines +284 to +291

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

Spring의 @Transactional 동작 방식 때문에 발생하는 심각한 버그가 있습니다.

cancelReservation 메서드와 paymentService.refundPayment 메서드 모두 @Transactional이 적용되어 있어 동일한 트랜잭션 범위를 공유합니다. refundPayment 내에서 CustomException (RuntimeException)이 발생하면, 호출부인 cancelReservation에서 이를 try-catch로 잡아내더라도 이미 해당 트랜잭션은 rollback-only 상태로 마킹됩니다.

결과적으로 cancelReservation 메서드가 정상적으로 종료되는 시점에 UnexpectedRollbackException이 발생하며 전체 트랜잭션이 롤백되어, 예약 취소 및 재고 복구 로직이 모두 실패하게 됩니다.

이를 해결하기 위해 예외를 발생시키고 잡는 대신, ReservationService에서 paymentRepository를 통해 결제 정보 존재 여부를 사전에 체크한 후 refundPayment를 호출하도록 변경하는 것이 안전합니다.

            if (paymentRepository.findByReservation_Id(reservation.getId()).isPresent()) {
                paymentService.refundPayment(reservation);
            } else {
                log.warn("환불 대상 결제 없음 — 취소 진행: reservationId={}", reservationId);
            }

restoreInventory(reservation);
reservation.changeStatus(ReservationStatus.CANCELED);
eventPublisher.publishEvent(new ReservationCanceledEvent(
Expand Down Expand Up @@ -307,6 +316,16 @@ public ReservationUpdateResponseDto updateReservation(Long reservationId, Long u
Reservation newReservation = createReservationCore(userId, request.newRemainId(), request.newMember(), request.couponId());
newReservation.changeStatus(ReservationStatus.CONFIRMED);

// payment.reservation_id 에 UNIQUE 제약 — 새 reservation 에 이미 잔존 payment 가 있으면
// transferToReservation 시점 flush 에서 UNIQUE 위반. 사전 정리해 idempotent 보장.
paymentRepository.findByReservation_Id(newReservation.getId()).ifPresent(stale -> {
if (stale.getStatus() == PaymentStatus.PAID) {
throw new CustomException(ErrorCode.PAYMENT_ALREADY_PAID);
}
paymentRepository.delete(stale);
paymentRepository.flush();
});
Comment on lines +321 to +327

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

newReservation은 바로 직전 라인(316번째 줄)에서 createReservationCore를 통해 새롭게 생성되어 저장된 예약 엔티티입니다.

따라서 데이터베이스에 이 새로운 예약 ID(newReservation.getId())와 매핑된 기존 결제(Payment) 정보가 존재할 가능성은 전혀 없습니다. 결과적으로 paymentRepository.findByReservation_Id(newReservation.getId()) 조회는 항상 빈 Optional을 반환하게 되므로, 이 블록은 실행되지 않는 불필요한 데드 코드(Dead Code)이자 불필요한 추가 쿼리를 발생시킵니다.

만약 중복 예약이나 중복 결제 시도를 방지하려는 목적이라면, 새로운 예약을 생성하기 전에 사용자 ID와 시간대(remainId) 등을 기준으로 기존 예약이 존재하는지 먼저 검증하는 로직을 추가하는 것이 올바른 방향입니다. 현재의 중복 제거 로직은 불필요하므로 제거하는 것을 권장합니다.


if (oldPayment != null) {
oldPayment.transferToReservation(newReservation);
}
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/com/catchtable/store/service/StoreService.java
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ public List<StoreListResponse> getStores(String name, Category category, Distric
}

// 인기 매장 — Redis 캐시(TTL 5분) 적용, Cache Stampede 방지
@Cacheable(value = "popularStores", key = "#limit", sync = true)
// RedisCacheManager는 sync = true 를 일관되게 지원하지 않음 (UnsupportedOperationException 가능).
// Cache stampede 방지가 필요하면 Redisson 분산락으로 별도 구현해야 함.
@Cacheable(value = "popularStores", key = "#limit")
@Transactional(readOnly = true)
public List<StoreListResponse> getPopularStores(int limit) {
return storeRepository.findPopular(PageRequest.of(0, limit)).stream()
Expand Down
Loading