diff --git a/src/main/java/com/catchtable/global/exception/GlobalExceptionHandler.java b/src/main/java/com/catchtable/global/exception/GlobalExceptionHandler.java index f486311..27bce67 100644 --- a/src/main/java/com/catchtable/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/catchtable/global/exception/GlobalExceptionHandler.java @@ -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; @@ -11,6 +12,7 @@ // 엔드포인트의 예외까지 이 advice가 가로채면, ApiResponse(JSON)를 openmetrics 응답으로 // 직렬화하려다 HttpMessageNotWritableException → 스크랩 500 → 모니터링 DOWN 오표시가 발생. // 본인 컨트롤러(com.catchtable.*)로만 적용 범위를 한정해 액추에이터를 건드리지 않게 한다. +@Slf4j @RestControllerAdvice(basePackages = "com.catchtable") public class GlobalExceptionHandler { @@ -55,9 +57,11 @@ public ResponseEntity> handleSpringOptimisticLock(org.springfr .body(ApiResponse.error(ErrorCode.OPTIMISTIC_LOCK_CONFLICT)); } - // 500 - 서버 내부 오류 + // 500 - 서버 내부 오류 (Unhandled) + // log.error 로 클래스/메시지/스택트레이스를 남겨야 원인 추적 가능. @ExceptionHandler(Exception.class) public ResponseEntity> 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)); diff --git a/src/main/java/com/catchtable/reservation/service/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index f32e1f2..736a6a8 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -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); + } restoreInventory(reservation); reservation.changeStatus(ReservationStatus.CANCELED); eventPublisher.publishEvent(new ReservationCanceledEvent( @@ -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(); + }); + if (oldPayment != null) { oldPayment.transferToReservation(newReservation); } diff --git a/src/main/java/com/catchtable/store/service/StoreService.java b/src/main/java/com/catchtable/store/service/StoreService.java index 30a82ae..8f13f98 100644 --- a/src/main/java/com/catchtable/store/service/StoreService.java +++ b/src/main/java/com/catchtable/store/service/StoreService.java @@ -69,7 +69,9 @@ public List 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 getPopularStores(int limit) { return storeRepository.findPopular(PageRequest.of(0, limit)).stream()