diff --git a/cloud-services/msa-point-service/build.gradle b/cloud-services/msa-point-service/build.gradle index f41685c..0da2c57 100644 --- a/cloud-services/msa-point-service/build.gradle +++ b/cloud-services/msa-point-service/build.gradle @@ -1,6 +1,7 @@ plugins { id 'java' - id 'org.springframework.boot' version '4.0.2' + // Spring Boot 3.5.10 + id 'org.springframework.boot' version '3.5.10' id 'io.spring.dependency-management' version '1.1.7' } @@ -30,16 +31,27 @@ repositories { } ext { - set('springCloudVersion', "2025.1.0") + // Spring Cloud 2025.1.0 -> 2025.0.0 ->2025.0.1 + set('springCloudVersion', "2025.0.1") } dependencies { + // 1. 'webmvc'가 아니라 'web'이어야 Jackson 등 필수 라이브러리가 다 들어옴 implementation 'org.springframework.boot:spring-boot-starter-web' + + // 2. 버전 꼬임 방지를 위해 Jackson 명시 + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + + // 3. 나머지 의존성 implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' implementation 'org.springframework.kafka:spring-kafka' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + + // 테스트 관련 testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testImplementation 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-h2console' diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/CoreResultEvent.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/CoreResultEvent.java index 5002a9c..08a6175 100644 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/CoreResultEvent.java +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/CoreResultEvent.java @@ -8,6 +8,7 @@ @NoArgsConstructor @AllArgsConstructor public class CoreResultEvent { + private String orderId; private String userId; private String status; // "SUCCESS" 또는 "FAIL" } \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/ErrorResponse.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/ErrorResponse.java new file mode 100644 index 0000000..7bae05e --- /dev/null +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/ErrorResponse.java @@ -0,0 +1,11 @@ +package com.techsemina.msa.pointservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ErrorResponse { + private String errorCode; // 예: "SERVER_ERROR", "INVALID_INPUT" + private String errorMessage; // 예: "DB 연결에 실패했습니다." +} \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/exception/GlobalExceptionHandler.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..adac48f --- /dev/null +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/exception/GlobalExceptionHandler.java @@ -0,0 +1,33 @@ +package com.techsemina.msa.pointservice.exception; + +import com.techsemina.msa.pointservice.dto.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice // 모든 컨트롤러의 에러를 여기서 잡음 +public class GlobalExceptionHandler { + + // 1. 비즈니스 로직 에러 (예: 잘못된 입력값, 포인트 부족 등) + // 서비스에서 throw new IllegalArgumentException("포인트 부족") 했을 때 여기로 옴 + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleBadRequest(IllegalArgumentException e) { + log.warn("🚨 잘못된 요청 발생: {}", e.getMessage()); + + ErrorResponse response = new ErrorResponse("BAD_REQUEST", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + // 2. 시스템 에러 (예: DB 다운, Kafka 연결 실패, NullPointer 등) + // 위에서 안 잡힌 "나머지 모든 에러"는 여기서 잡힘 + @ExceptionHandler(Exception.class) + public ResponseEntity handleServerException(Exception e) { + log.error("🔥 서버 내부 치명적 오류 발생", e); // 스택 트레이스 로그 남기기 + + ErrorResponse response = new ErrorResponse("INTERNAL_SERVER_ERROR", "시스템 오류가 발생했습니다. 관리자에게 문의하세요."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } +} \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentConsumer.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentConsumer.java index cb69f3d..7c168ba 100644 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentConsumer.java +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentConsumer.java @@ -1,12 +1,17 @@ package com.techsemina.msa.pointservice.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.techsemina.msa.pointservice.domain.Payment; import com.techsemina.msa.pointservice.dto.CashResponseDTO; +import com.techsemina.msa.pointservice.dto.CoreResultEvent; +import com.techsemina.msa.pointservice.repository.PaymentRepository; import com.techsemina.msa.pointservice.service.PaymentService; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Component @@ -14,24 +19,47 @@ public class PaymentConsumer { private final PaymentService paymentService; + private final PaymentRepository paymentRepository; // 장부 조회용 private final ObjectMapper objectMapper; @KafkaListener(topics = "core-withdraw-result", groupId = "point-service-group") - public void consumeWithdrawResult(String message) { - try { - log.info("📨 [Kafka] 결과 수신: {}", message); - CashResponseDTO result = objectMapper.readValue(message, CashResponseDTO.class); - - if ("SUCCESS".equals(result.getStatus())) { - // 성공 처리 - paymentService.completePayment(result.getOrderId()); - } else { - // 실패 -> 롤백 - paymentService.compensatePayment(result.getOrderId()); + @Transactional // 에러 발생 시 롤백 & 카프카 재시도 + public void consumeWithdrawResult(String message) throws Exception { + + log.info("📨 [Kafka] 결과 수신: {}", message); + + // 1. DTO 변환 + CashResponseDTO result = objectMapper.readValue(message, CashResponseDTO.class); + + // 2. 성공 여부 체크 + if ("SUCCESS".equals(result.getStatus())) { + // ✅ 성공 시: 서비스의 완료 로직 호출 + paymentService.completePayment(result.getOrderId()); + } else { + // ❌ 실패 시: 롤백(환불) 로직 진행 + log.warn("🚨 결제 실패 수신 (사유: {}). 환불을 진행합니다.", result.getMessage()); + + // (1) 장부(DB)에서 주문 조회 (orderId로 찾기!) + Payment payment = paymentRepository.findByOrderId(result.getOrderId()) + .orElseThrow(() -> new RuntimeException("주문 정보를 찾을 수 없습니다.")); + + // (2) 이미 처리된 건인지 확인 (중복 방지) + if ("FAILED".equals(payment.getStatus())) { + log.info("이미 처리된 환불 건입니다."); + return; } + // (3) 실제 사용했던 포인트 조회 + Long usedPoint = payment.getPointAmount(); + + // (4) 포인트 환불 + paymentService.compensatePayment(payment.getOrderId()); - } catch (Exception e) { - log.error("❌ 메시지 처리 중 에러", e); + // (5) 장부 상태 업데이트 (FAILED) + payment.setStatus("FAILED"); + paymentRepository.save(payment); // @Transactional 있으면 자동 저장됨 (Dirty Checking) + + log.info("✅ 포인트 {}점 환불 완료.", usedPoint); } + } } \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentService.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentService.java index 1e87afc..2fc0c81 100644 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentService.java +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentService.java @@ -44,7 +44,7 @@ public void processCompositePayment(PaymentRequest request) { // [Step 2] 현금 출금 요청 (Kafka) - // 1. 변수에 먼저 담습니다. + // 1. 변수에 담음 CashRequestDTO cashMessage = new CashRequestDTO( request.getOrderId(), request.getLoginId(), @@ -54,7 +54,8 @@ public void processCompositePayment(PaymentRequest request) { log.info("-> [Kafka 전송] 토픽: core-withdraw-request, 데이터: {}", cashMessage); // 3. 전송 try { - kafkaTemplate.send("core-withdraw-request", cashMessage).get(5, TimeUnit.SECONDS); + // ❌ .get()으로 기다리지 말기! 트랜잭션 길어짐. 비동기로 보내기 + kafkaTemplate.send("core-withdraw-request", cashMessage); } catch (Exception e) { log.error("Kafka 전송 실패: {}", e.getMessage()); throw new RuntimeException("출금 요청 전송 실패", e); @@ -85,8 +86,13 @@ public void compensatePayment(String orderId) { Payment payment = paymentRepository.findByOrderId(orderId) .orElseThrow(() -> new RuntimeException("주문 없음")); - // 이미 취소된 건지 체크하는 로직 등이 여기 들어가면 안전함 - if ("FAILED".equals(payment.getStatus())) return; + // 🚨 [핵심 수정] + // 기존: if ("FAILED".equals(payment.getStatus())) return; + // 수정: "PENDING(진행중)" 상태가 아니라면(이미 성공했거나 실패했으면) 건드리지 마라! + if (!"PENDING".equals(payment.getStatus())) { + log.warn("🚫 이미 처리가 완료된 주문입니다. (상태: {}) - 환불 중단", payment.getStatus()); + return; + } // 포인트 환불 로직 pointService.refundPoint(payment.getUserId(), payment.getPointAmount());