Skip to content

Commit 6b5e2c9

Browse files
authored
Merge pull request #165 from sylee6529/round6
[volume-6] 외부 시스템 장애 및 지연 대응
2 parents c5aad6e + 822c30a commit 6b5e2c9

82 files changed

Lines changed: 4993 additions & 194 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ docker-compose -f ./docker/monitoring-compose.yml up
2727
Root
2828
├── apps ( spring-applications )
2929
│ ├── 📦 commerce-api
30-
│ └── 📦 commerce-streamer
30+
│ ├── 📦 commerce-streamer
31+
│ └── 📦 pg-simulator
3132
├── modules ( reusable-configurations )
3233
│ ├── 📦 jpa
3334
│ ├── 📦 redis
@@ -37,3 +38,25 @@ Root
3738
├── 📦 monitoring
3839
└── 📦 logging
3940
```
41+
42+
## PG Payment System
43+
44+
완전한 결제 시스템 구현 (Resilience 패턴 적용)
45+
46+
### 빠른 시작
47+
```shell
48+
# PG Simulator 실행
49+
./gradlew :apps:pg-simulator:bootRun
50+
51+
# Commerce API 실행
52+
./gradlew :apps:commerce-api:bootRun
53+
54+
# 테스트
55+
curl -X POST http://localhost:8080/api/v1/payments/test/request \
56+
-H "X-USER-ID: 12345" -H "Content-Type: application/json" \
57+
-d '{"orderId":"ORDER-001","cardType":"SAMSUNG","cardNo":"1234-5678-9012-3456","amount":50000}'
58+
```
59+
60+
### 문서
61+
- **PAYMENT-GUIDE.md**: 전체 가이드 (구현 사항, API, 테스트)
62+
- **test-payment-flow.http**: HTTP 테스트 시나리오

apps/commerce-api/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ dependencies {
1212
implementation("org.springframework.boot:spring-boot-starter-actuator")
1313
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}")
1414

15+
// resilience4j (Spring Boot 3.x 호환 버전)
16+
implementation("io.github.resilience4j:resilience4j-spring-boot3:2.2.0")
17+
implementation("io.github.resilience4j:resilience4j-micrometer:2.2.0")
18+
implementation("org.springframework.boot:spring-boot-starter-aop")
19+
1520
// querydsl
1621
annotationProcessor("com.querydsl:querydsl-apt::jakarta")
1722
annotationProcessor("jakarta.persistence:jakarta.persistence-api")

apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.loopers.domain.common.vo.Money;
44
import com.loopers.domain.order.Order;
55
import com.loopers.domain.order.OrderItem;
6+
import com.loopers.domain.order.OrderStatus;
67
import lombok.Builder;
78
import lombok.Getter;
89

@@ -12,21 +13,25 @@
1213
@Getter
1314
@Builder
1415
public class OrderInfo {
15-
16+
1617
private final Long id;
18+
private final String orderNo;
1719
private final Long memberId;
20+
private final OrderStatus status;
1821
private final Money totalPrice;
1922
private final List<OrderItemInfo> items;
2023
private final ZonedDateTime orderedAt;
21-
24+
2225
public static OrderInfo from(Order order) {
2326
List<OrderItemInfo> itemInfos = order.getItems().stream()
2427
.map(OrderItemInfo::from)
2528
.toList();
26-
29+
2730
return OrderInfo.builder()
2831
.id(order.getId())
32+
.orderNo(order.getOrderNo())
2933
.memberId(order.getMemberId())
34+
.status(order.getStatus())
3035
.totalPrice(order.getTotalPrice())
3136
.items(itemInfos)
3237
.orderedAt(order.getCreatedAt())
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.loopers.application.payment;
2+
3+
import com.loopers.domain.payment.CardType;
4+
5+
public class PaymentCommand {
6+
7+
public record RequestPayment(
8+
String orderId,
9+
CardType cardType,
10+
String cardNo,
11+
Long amount,
12+
String callbackUrl
13+
) {
14+
public RequestPayment {
15+
if (orderId == null || orderId.isBlank()) {
16+
throw new IllegalArgumentException("주문 ID는 필수입니다.");
17+
}
18+
if (cardType == null) {
19+
throw new IllegalArgumentException("카드 타입은 필수입니다.");
20+
}
21+
if (cardNo == null || cardNo.isBlank()) {
22+
throw new IllegalArgumentException("카드 번호는 필수입니다.");
23+
}
24+
if (amount == null || amount <= 0) {
25+
throw new IllegalArgumentException("결제 금액은 0보다 커야 합니다.");
26+
}
27+
if (callbackUrl == null || callbackUrl.isBlank()) {
28+
throw new IllegalArgumentException("콜백 URL은 필수입니다.");
29+
}
30+
}
31+
}
32+
33+
public record ProcessCallback(
34+
String transactionKey,
35+
String status,
36+
String reason
37+
) {
38+
public ProcessCallback {
39+
if (transactionKey == null || transactionKey.isBlank()) {
40+
throw new IllegalArgumentException("트랜잭션 키는 필수입니다.");
41+
}
42+
if (status == null || status.isBlank()) {
43+
throw new IllegalArgumentException("상태는 필수입니다.");
44+
}
45+
}
46+
}
47+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package com.loopers.application.payment;
2+
3+
import com.loopers.domain.order.Order;
4+
import com.loopers.domain.order.repository.OrderRepository;
5+
import com.loopers.domain.product.repository.ProductRepository;
6+
import com.loopers.domain.payment.Payment;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.stereotype.Service;
10+
import org.springframework.transaction.annotation.Propagation;
11+
import org.springframework.transaction.annotation.Transactional;
12+
13+
import java.time.LocalDateTime;
14+
import java.util.List;
15+
16+
@Slf4j
17+
@Service
18+
@RequiredArgsConstructor
19+
public class PaymentFacade {
20+
21+
private final PaymentService paymentService;
22+
private final OrderRepository orderRepository;
23+
private final ProductRepository productRepository;
24+
25+
@Transactional(propagation = Propagation.NOT_SUPPORTED)
26+
public PaymentInfo requestPayment(String userId, PaymentCommand.RequestPayment command) {
27+
return paymentService.requestPayment(userId, command);
28+
}
29+
30+
@Transactional
31+
public PaymentInfo processCallback(String userId, PaymentCommand.ProcessCallback command) {
32+
PaymentInfo paymentInfo = paymentService.processCallback(userId, command);
33+
34+
if (paymentInfo.status() == com.loopers.domain.payment.PaymentStatus.SUCCESS) {
35+
Order order = orderRepository.findByOrderNo(paymentInfo.orderNo())
36+
.orElseThrow(() -> new com.loopers.support.error.CoreException(
37+
com.loopers.support.error.ErrorType.NOT_FOUND,
38+
"주문을 찾을 수 없습니다: " + paymentInfo.orderNo()));
39+
order.markAsPaid();
40+
orderRepository.save(order);
41+
log.info("[PaymentFacade] 결제 성공 및 주문 완료 - orderNo: {}", paymentInfo.orderNo());
42+
} else if (paymentInfo.status() == com.loopers.domain.payment.PaymentStatus.FAILED) {
43+
log.warn("[PaymentFacade] 결제 실패 - orderNo: {}", paymentInfo.orderNo());
44+
}
45+
46+
return paymentInfo;
47+
}
48+
49+
@Transactional
50+
public PaymentInfo syncPaymentStatus(String userId, String transactionKey) {
51+
PaymentInfo paymentInfo = paymentService.syncPaymentStatus(userId, transactionKey);
52+
53+
if (paymentInfo.status() == com.loopers.domain.payment.PaymentStatus.SUCCESS) {
54+
Order order = orderRepository.findByOrderNo(paymentInfo.orderNo())
55+
.orElseThrow(() -> new com.loopers.support.error.CoreException(
56+
com.loopers.support.error.ErrorType.NOT_FOUND,
57+
"주문을 찾을 수 없습니다: " + paymentInfo.orderNo()));
58+
if (!order.isPaid()) {
59+
order.markAsPaid();
60+
orderRepository.save(order);
61+
}
62+
log.info("[PaymentFacade] 동기화 완료 (성공) 및 주문 완료 - orderNo: {}", paymentInfo.orderNo());
63+
} else if (paymentInfo.status() == com.loopers.domain.payment.PaymentStatus.FAILED) {
64+
log.warn("[PaymentFacade] 동기화 완료 (실패) - orderNo: {}", paymentInfo.orderNo());
65+
}
66+
67+
return paymentInfo;
68+
}
69+
70+
@Transactional
71+
public PaymentInfo cancelPayment(String userId, String orderNo, String reason) {
72+
Payment payment = paymentService.findByOrderNo(orderNo);
73+
74+
if (payment.getStatus() == com.loopers.domain.payment.PaymentStatus.SUCCESS) {
75+
throw new com.loopers.support.error.CoreException(
76+
com.loopers.support.error.ErrorType.BAD_REQUEST,
77+
"이미 성공한 결제는 취소할 수 없습니다.");
78+
}
79+
80+
paymentService.cancelPayment(payment.getId(), reason);
81+
82+
Order order = payment.getOrder();
83+
if (!order.isPaid()) {
84+
order.cancel();
85+
orderRepository.save(order);
86+
87+
order.getItems().forEach(item ->
88+
productRepository.increaseStock(item.getProductId(), item.getQuantity()));
89+
}
90+
91+
log.info("[PaymentFacade] 결제 취소 완료 - orderNo: {}, reason: {}", orderNo, reason);
92+
return PaymentInfo.from(paymentService.findById(payment.getId()));
93+
}
94+
95+
@Transactional(readOnly = true)
96+
public PaymentInfo getPaymentByOrderNo(String orderNo) {
97+
return paymentService.getPaymentByOrderNo(orderNo);
98+
}
99+
100+
@Transactional(readOnly = true)
101+
public List<PaymentInfo> getPendingPaymentsOlderThan(LocalDateTime dateTime) {
102+
return paymentService.getPendingPaymentsOlderThan(dateTime);
103+
}
104+
105+
@Transactional(readOnly = true)
106+
public List<PaymentInfo> getPaymentsRequiringRetry() {
107+
return paymentService.getPaymentsRequiringRetry();
108+
}
109+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.loopers.application.payment;
2+
3+
import com.loopers.domain.payment.CardType;
4+
import com.loopers.domain.payment.Payment;
5+
import com.loopers.domain.payment.PaymentStatus;
6+
7+
import java.time.ZonedDateTime;
8+
9+
public record PaymentInfo(
10+
Long id,
11+
String orderNo,
12+
String transactionKey,
13+
PaymentStatus status,
14+
CardType cardType,
15+
String cardNo,
16+
Long amount,
17+
String reason,
18+
boolean requiresRetry,
19+
ZonedDateTime createdAt
20+
) {
21+
public static PaymentInfo from(Payment payment) {
22+
return new PaymentInfo(
23+
payment.getId(),
24+
payment.getOrder().getOrderNo(),
25+
payment.getTransactionKey(),
26+
payment.getStatus(),
27+
payment.getCardType(),
28+
payment.getCardNo(),
29+
payment.getAmount(),
30+
payment.getReason(),
31+
payment.isRequiresRetry(),
32+
payment.getCreatedAt()
33+
);
34+
}
35+
}

0 commit comments

Comments
 (0)