From bc1a1cde2f82f35d7adc969646aee1387880955f Mon Sep 17 00:00:00 2001 From: kimjb Date: Mon, 1 Jun 2026 22:59:18 +0900 Subject: [PATCH] =?UTF-8?q?Fix:=20=EC=98=88=EC=95=BD=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EC=8B=9C=20=EA=B2=B0=EC=A0=9C=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/GlobalExceptionHandler.java | 6 ++++- .../service/ReservationService.java | 23 +++++++++++++++++-- .../store/service/StoreService.java | 4 +++- 3 files changed, 29 insertions(+), 4 deletions(-) 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()