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
16 changes: 14 additions & 2 deletions cloud-services/msa-point-service/build.gradle
Original file line number Diff line number Diff line change
@@ -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'
}

Expand Down Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
@NoArgsConstructor
@AllArgsConstructor
public class CoreResultEvent {
private String orderId;
private String userId;
private String status; // "SUCCESS" 또는 "FAIL"
}
Original file line number Diff line number Diff line change
@@ -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 연결에 실패했습니다."
}
Original file line number Diff line number Diff line change
@@ -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<ErrorResponse> 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<ErrorResponse> handleServerException(Exception e) {
log.error("🔥 서버 내부 치명적 오류 발생", e); // 스택 트레이스 로그 남기기

ErrorResponse response = new ErrorResponse("INTERNAL_SERVER_ERROR", "시스템 오류가 발생했습니다. 관리자에게 문의하세요.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
Original file line number Diff line number Diff line change
@@ -1,37 +1,65 @@
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
@RequiredArgsConstructor
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;
}
Comment on lines +47 to 50
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

중복 방지 조건이 compensatePayment()와 일치하지 않습니다.

  • 컨슈머: FAILED 상태만 스킵
  • compensatePayment(): PENDING이 아니면 모두 스킵 (COMPLETED, FAILED 포함)

만약 이미 COMPLETED된 주문에 대해 실패 메시지가 늦게 도착하면:

  1. 컨슈머는 COMPLETED를 FAILED가 아니므로 통과
  2. compensatePayment()는 PENDING 아니므로 스킵
  3. 하지만 아래 코드에서 상태를 FAILED로 변경 (의도치 않은 상태 변경)

가드 조건을 통일하세요:

🔧 가드 조건 통일
             // (2) 이미 처리된 건인지 확인 (중복 방지)
-            if ("FAILED".equals(payment.getStatus())) {
-                log.info("이미 처리된 환불 건입니다.");
+            if (!"PENDING".equals(payment.getStatus())) {
+                log.info("이미 처리된 건입니다. (상태: {})", payment.getStatus());
                 return;
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ("FAILED".equals(payment.getStatus())) {
log.info("이미 처리된 환불 건입니다.");
return;
}
if (!"PENDING".equals(payment.getStatus())) {
log.info("이미 처리된 건입니다. (상태: {})", payment.getStatus());
return;
}
🤖 Prompt for AI Agents
In
`@cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentConsumer.java`
around lines 47 - 50, The consumer's duplicate-prevention check in
PaymentConsumer is inconsistent with compensatePayment(): change the guard in
the consumer (the block that currently checks
"FAILED".equals(payment.getStatus())) to the same logic used by
compensatePayment() — skip processing unless payment.getStatus() == PENDING
(i.e., if status is not PENDING, log "이미 처리된 환불 건입니다." and return) so messages
for COMPLETED or FAILED don't trigger unintended state changes; update the check
in PaymentConsumer to explicitly mirror compensatePayment()'s condition.

// (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);
Comment on lines +42 to +61
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

중복 상태 업데이트 및 트랜잭션 범위 문제가 있습니다.

compensatePayment() 메서드와 이 컨슈머에서 동일한 Payment 레코드에 대해 상태를 중복 설정하고 있습니다:

  1. Line 55: compensatePayment()가 내부에서 payment.setStatus("FAILED") 수행
  2. Line 58-59: 컨슈머에서 다시 payment.setStatus("FAILED")save() 호출

두 메서드가 각각 findByOrderId()를 호출하므로 서로 다른 엔티티 인스턴스를 수정하게 되어, Dirty Checking이 제대로 동작하지 않거나 optimistic locking 예외가 발생할 수 있습니다.

🔧 수정 방안: 역할 분리 명확화
         // (4) 포인트 환불
         paymentService.compensatePayment(payment.getOrderId());

-        // (5) 장부 상태 업데이트 (FAILED)
-        payment.setStatus("FAILED");
-        paymentRepository.save(payment); // `@Transactional` 있으면 자동 저장됨 (Dirty Checking)
+        // compensatePayment 내부에서 이미 FAILED 상태로 변경 & 저장됨
+        // 중복 업데이트 제거

         log.info("✅ 포인트 {}점 환불 완료.", usedPoint);

또는 compensatePayment()에서 상태 변경을 제거하고 컨슈머에서만 처리하는 방식도 가능합니다.

🤖 Prompt for AI Agents
In
`@cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentConsumer.java`
around lines 42 - 61, The consumer duplicates state changes on the same Payment
entity causing conflicting entity instances and potential optimistic locking
issues: remove the redundant status change either by having
paymentService.compensatePayment(...) perform the full update (including setting
status to "FAILED" and saving) and stop setting status/save in the consumer, or
conversely remove status changes from compensatePayment(...) and let the
consumer alone call paymentRepository.findByOrderId(...), call
paymentService.compensatePayment(...) (which only handles refund logic), then
set payment.setStatus("FAILED") and save once; ensure you use the same entity
instance (avoid calling findByOrderId twice for the same orderId) and keep
transaction boundaries consistent (annotate the service or consumer with
`@Transactional` as appropriate).

}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public void processCompositePayment(PaymentRequest request) {


// [Step 2] 현금 출금 요청 (Kafka)
// 1. 변수에 먼저 담습니다.
// 1. 변수에 담음
CashRequestDTO cashMessage = new CashRequestDTO(
request.getOrderId(),
request.getLoginId(),
Expand All @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Kafka 토픽명이 여전히 하드코딩되어 있습니다.

PR 목적 중 하나가 Kafka 하드코딩 수정인데, "core-withdraw-request" 토픽명이 아직 문자열 리터럴로 남아있습니다. 설정 파일이나 상수로 관리하면 환경별 배포와 유지보수가 편해집니다.

🔧 토픽명 외부화 예시

application.yml:

kafka:
  topic:
    withdraw-request: core-withdraw-request

서비스 클래스:

+    `@Value`("${kafka.topic.withdraw-request}")
+    private String withdrawRequestTopic;

     // ...
-    kafkaTemplate.send("core-withdraw-request", cashMessage);
+    kafkaTemplate.send(withdrawRequestTopic, cashMessage);
🤖 Prompt for AI Agents
In
`@cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentService.java`
at line 58, The kafka topic name is hardcoded in PaymentService at the
kafkaTemplate.send("core-withdraw-request", cashMessage) call; change this to
use a configurable property or constant instead: add a configuration property
(e.g., kafka.topic.withdraw-request in application.yml) and inject it into
PaymentService (via `@Value` or a `@ConfigurationProperties-backed` bean) then
replace the literal in the kafkaTemplate.send(...) invocation with the injected
property (e.g., withdrawRequestTopic) so the topic can be managed per
environment and is no longer a string literal.

} catch (Exception e) {
log.error("Kafka 전송 실패: {}", e.getMessage());
throw new RuntimeException("출금 요청 전송 실패", e);
Expand Down Expand Up @@ -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());
Expand Down