Skip to content

Commit 20f265a

Browse files
committed
Feature/pg client (#27)
* test: PG 호출 테스트 코드 추가 * feat: PG 호출 로직 구현 * test: PG CircuitBreaker 테스트 코드 추가 * feat: CircuitBreaker 로직 구현
1 parent 8557d64 commit 20f265a

40 files changed

Lines changed: 5056 additions & 80 deletions

apps/commerce-api/build.gradle.kts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ dependencies {
1010
implementation("org.springframework.boot:spring-boot-starter-web")
1111
implementation("org.springframework.boot:spring-boot-starter-actuator")
1212
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}")
13+
14+
// feign client
15+
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
16+
17+
// resilience4j
18+
implementation("io.github.resilience4j:resilience4j-spring-boot3")
19+
implementation("io.github.resilience4j:resilience4j-core") // IntervalFunction을 위한 core 모듈
20+
implementation("io.github.resilience4j:resilience4j-circuitbreaker")
21+
implementation("io.github.resilience4j:resilience4j-retry")
22+
implementation("io.github.resilience4j:resilience4j-timelimiter")
23+
implementation("io.github.resilience4j:resilience4j-bulkhead") // Bulkheads 패턴 구현
24+
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")
1325

1426
// batch
1527
implementation("org.springframework.boot:spring-boot-starter-batch")

apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
import org.springframework.boot.SpringApplication;
55
import org.springframework.boot.autoconfigure.SpringBootApplication;
66
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
7+
import org.springframework.cloud.openfeign.EnableFeignClients;
78
import org.springframework.scheduling.annotation.EnableScheduling;
89
import java.util.TimeZone;
910

1011
@ConfigurationPropertiesScan
1112
@SpringBootApplication
1213
@EnableScheduling
14+
@EnableFeignClients
1315
public class CommerceApiApplication {
1416

1517
@PostConstruct
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package com.loopers.application.purchasing;
2+
3+
import com.loopers.domain.order.Order;
4+
import com.loopers.domain.order.OrderRepository;
5+
import com.loopers.domain.order.OrderStatus;
6+
import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient;
7+
import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
8+
import com.loopers.infrastructure.user.UserJpaRepository;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.scheduling.annotation.Scheduled;
12+
import org.springframework.stereotype.Component;
13+
import org.springframework.transaction.annotation.Transactional;
14+
15+
import java.util.List;
16+
17+
/**
18+
* 결제 상태 복구 스케줄러.
19+
* <p>
20+
* 콜백이 오지 않은 PENDING 상태의 주문들을 주기적으로 조회하여
21+
* PG 시스템의 결제 상태 확인 API를 통해 상태를 복구합니다.
22+
* </p>
23+
* <p>
24+
* <b>동작 원리:</b>
25+
* <ol>
26+
* <li>주기적으로 실행 (기본: 1분마다)</li>
27+
* <li>PENDING 상태인 주문들을 조회</li>
28+
* <li>각 주문에 대해 PG 결제 상태 확인 API 호출</li>
29+
* <li>결제 상태에 따라 주문 상태 업데이트</li>
30+
* </ol>
31+
* </p>
32+
* <p>
33+
* <b>설계 근거:</b>
34+
* <ul>
35+
* <li><b>주기적 복구:</b> 콜백이 오지 않아도 자동으로 상태 복구</li>
36+
* <li><b>Eventually Consistent:</b> 약간의 지연 허용 가능</li>
37+
* <li><b>안전한 처리:</b> 각 주문별로 독립적으로 처리하여 실패 시에도 다른 주문에 영향 없음</li>
38+
* <li><b>성능 고려:</b> 배치로 처리하여 PG 시스템 부하 최소화</li>
39+
* </ul>
40+
* </p>
41+
*
42+
* @author Loopers
43+
* @version 1.0
44+
*/
45+
@Slf4j
46+
@RequiredArgsConstructor
47+
@Component
48+
public class PaymentRecoveryScheduler {
49+
50+
private final OrderRepository orderRepository;
51+
private final UserJpaRepository userJpaRepository;
52+
private final PurchasingFacade purchasingFacade;
53+
54+
/**
55+
* PENDING 상태인 주문들의 결제 상태를 복구합니다.
56+
* <p>
57+
* 1분마다 실행되어 PENDING 상태인 주문들을 조회하고,
58+
* 각 주문에 대해 PG 결제 상태 확인 API를 호출하여 상태를 복구합니다.
59+
* </p>
60+
* <p>
61+
* <b>처리 전략:</b>
62+
* <ul>
63+
* <li><b>배치 처리:</b> 한 번에 여러 주문 처리</li>
64+
* <li><b>독립적 처리:</b> 각 주문별로 독립적으로 처리하여 실패 시에도 다른 주문에 영향 없음</li>
65+
* <li><b>안전한 예외 처리:</b> 개별 주문 처리 실패 시에도 계속 진행</li>
66+
* </ul>
67+
* </p>
68+
*/
69+
@Scheduled(fixedDelay = 60000) // 1분마다 실행
70+
public void recoverPendingOrders() {
71+
try {
72+
log.debug("결제 상태 복구 스케줄러 시작");
73+
74+
// PENDING 상태인 주문들 조회
75+
List<Order> pendingOrders = orderRepository.findAllByStatus(OrderStatus.PENDING);
76+
77+
if (pendingOrders.isEmpty()) {
78+
log.debug("복구할 PENDING 상태 주문이 없습니다.");
79+
return;
80+
}
81+
82+
log.info("PENDING 상태 주문 {}건에 대한 결제 상태 복구 시작", pendingOrders.size());
83+
84+
int successCount = 0;
85+
int failureCount = 0;
86+
87+
// 각 주문에 대해 결제 상태 확인 및 복구
88+
for (Order order : pendingOrders) {
89+
try {
90+
// Order의 userId는 User의 id (Long)이므로 User를 조회하여 userId (String)를 가져옴
91+
var userOptional = userJpaRepository.findById(order.getUserId());
92+
if (userOptional.isEmpty()) {
93+
log.warn("주문의 사용자를 찾을 수 없습니다. 복구를 건너뜁니다. (orderId: {}, userId: {})",
94+
order.getId(), order.getUserId());
95+
failureCount++;
96+
continue;
97+
}
98+
99+
String userId = userOptional.get().getUserId();
100+
101+
// 결제 상태 확인 및 복구
102+
purchasingFacade.recoverOrderStatusByPaymentCheck(userId, order.getId());
103+
successCount++;
104+
} catch (Exception e) {
105+
// 개별 주문 처리 실패 시에도 계속 진행
106+
log.error("주문 상태 복구 중 오류 발생. (orderId: {})", order.getId(), e);
107+
failureCount++;
108+
}
109+
}
110+
111+
log.info("결제 상태 복구 완료. 성공: {}건, 실패: {}건", successCount, failureCount);
112+
113+
} catch (Exception e) {
114+
log.error("결제 상태 복구 스케줄러 실행 중 오류 발생", e);
115+
}
116+
}
117+
}
118+
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.loopers.application.purchasing;
2+
3+
import com.loopers.domain.order.OrderStatusUpdater;
4+
import com.loopers.infrastructure.paymentgateway.DelayProvider;
5+
import com.loopers.infrastructure.paymentgateway.PaymentGatewayAdapter;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.stereotype.Component;
9+
10+
import java.time.Duration;
11+
12+
/**
13+
* 결제 복구 서비스.
14+
* <p>
15+
* 타임아웃 발생 후 결제 상태를 확인하여 주문 상태를 복구합니다.
16+
* </p>
17+
*
18+
* @author Loopers
19+
* @version 1.0
20+
*/
21+
@Slf4j
22+
@Component
23+
@RequiredArgsConstructor
24+
public class PaymentRecoveryService {
25+
26+
private final PaymentGatewayAdapter paymentGatewayAdapter;
27+
private final OrderStatusUpdater orderStatusUpdater;
28+
private final DelayProvider delayProvider;
29+
30+
/**
31+
* 타임아웃 발생 후 결제 상태를 확인하여 주문 상태를 복구합니다.
32+
* <p>
33+
* 타임아웃은 요청이 전송되었을 수 있으므로, 실제 결제 상태를 확인하여
34+
* 결제가 성공했다면 주문을 완료하고, 실패했다면 주문을 취소합니다.
35+
* </p>
36+
*
37+
* @param userId 사용자 ID
38+
* @param orderId 주문 ID
39+
*/
40+
public void recoverAfterTimeout(String userId, Long orderId) {
41+
try {
42+
// 잠시 대기 후 상태 확인 (PG 처리 시간 고려)
43+
// 타임아웃이 발생했지만 요청은 전송되었을 수 있으므로,
44+
// PG 시스템이 처리할 시간을 주기 위해 짧은 대기
45+
delayProvider.delay(Duration.ofSeconds(1));
46+
47+
// PG에서 주문별 결제 정보 조회
48+
var status = paymentGatewayAdapter.getPaymentStatus(userId, String.valueOf(orderId));
49+
50+
// 별도 트랜잭션으로 상태 업데이트
51+
orderStatusUpdater.updateByPaymentStatus(orderId, status, null, null);
52+
53+
} catch (InterruptedException e) {
54+
Thread.currentThread().interrupt();
55+
log.warn("타임아웃 후 상태 확인 중 인터럽트 발생. (orderId: {})", orderId);
56+
} catch (Exception e) {
57+
// 기타 오류: 나중에 스케줄러로 복구 가능
58+
log.error("타임아웃 후 상태 확인 중 오류 발생. 나중에 스케줄러로 복구됩니다. (orderId: {})", orderId, e);
59+
}
60+
}
61+
}
62+
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.loopers.application.purchasing;
2+
3+
import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
4+
5+
/**
6+
* 결제 요청 도메인 모델.
7+
*
8+
* @author Loopers
9+
* @version 1.0
10+
*/
11+
public record PaymentRequest(
12+
String userId,
13+
String orderId,
14+
PaymentGatewayDto.CardType cardType,
15+
String cardNo,
16+
Long amount,
17+
String callbackUrl
18+
) {
19+
}
20+
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.loopers.application.purchasing;
2+
3+
import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
4+
import com.loopers.support.error.CoreException;
5+
import com.loopers.support.error.ErrorType;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.stereotype.Component;
9+
10+
/**
11+
* 결제 요청 빌더.
12+
* <p>
13+
* 결제 요청 도메인 모델을 생성합니다.
14+
* </p>
15+
*
16+
* @author Loopers
17+
* @version 1.0
18+
*/
19+
@Component
20+
@RequiredArgsConstructor
21+
public class PaymentRequestBuilder {
22+
23+
@Value("${server.port:8080}")
24+
private int serverPort;
25+
26+
/**
27+
* 결제 요청을 생성합니다.
28+
*
29+
* @param userId 사용자 ID
30+
* @param orderId 주문 ID
31+
* @param cardType 카드 타입 문자열
32+
* @param cardNo 카드 번호
33+
* @param amount 결제 금액
34+
* @return 결제 요청 도메인 모델
35+
* @throws CoreException 잘못된 카드 타입인 경우
36+
*/
37+
public PaymentRequest build(String userId, Long orderId, String cardType, String cardNo, Integer amount) {
38+
// 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항)
39+
String orderIdString = formatOrderId(orderId);
40+
return new PaymentRequest(
41+
userId,
42+
orderIdString,
43+
parseCardType(cardType),
44+
cardNo,
45+
amount.longValue(),
46+
generateCallbackUrl(orderId)
47+
);
48+
}
49+
50+
/**
51+
* 카드 타입 문자열을 CardType enum으로 변환합니다.
52+
*
53+
* @param cardType 카드 타입 문자열
54+
* @return CardType enum
55+
* @throws CoreException 잘못된 카드 타입인 경우
56+
*/
57+
private PaymentGatewayDto.CardType parseCardType(String cardType) {
58+
try {
59+
return PaymentGatewayDto.CardType.valueOf(cardType.toUpperCase());
60+
} catch (IllegalArgumentException e) {
61+
throw new CoreException(ErrorType.BAD_REQUEST,
62+
String.format("잘못된 카드 타입입니다. (cardType: %s)", cardType));
63+
}
64+
}
65+
66+
/**
67+
* 콜백 URL을 생성합니다.
68+
*
69+
* @param orderId 주문 ID
70+
* @return 콜백 URL
71+
*/
72+
private String generateCallbackUrl(Long orderId) {
73+
return String.format("http://localhost:%d/api/v1/orders/%d/callback", serverPort, orderId);
74+
}
75+
76+
/**
77+
* 주문 ID를 6자리 이상 문자열로 변환합니다.
78+
* <p>
79+
* pg-simulator의 검증 요구사항에 맞추기 위해 최소 6자리로 패딩합니다.
80+
* </p>
81+
*
82+
* @param orderId 주문 ID (Long)
83+
* @return 6자리 이상의 주문 ID 문자열
84+
*/
85+
public String formatOrderId(Long orderId) {
86+
return String.format("%06d", orderId);
87+
}
88+
}
89+

0 commit comments

Comments
 (0)