Skip to content

Commit 822c30a

Browse files
committed
결제 탄력성 보완
1 parent 54f63bc commit 822c30a

15 files changed

Lines changed: 216 additions & 99 deletions

File tree

apps/commerce-api/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dependencies {
1414

1515
// resilience4j (Spring Boot 3.x 호환 버전)
1616
implementation("io.github.resilience4j:resilience4j-spring-boot3:2.2.0")
17+
implementation("io.github.resilience4j:resilience4j-micrometer:2.2.0")
1718
implementation("org.springframework.boot:spring-boot-starter-aop")
1819

1920
// querydsl

apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
import com.loopers.domain.order.Order;
44
import com.loopers.domain.order.repository.OrderRepository;
5+
import com.loopers.domain.product.repository.ProductRepository;
6+
import com.loopers.domain.payment.Payment;
57
import lombok.RequiredArgsConstructor;
68
import lombok.extern.slf4j.Slf4j;
79
import org.springframework.stereotype.Service;
10+
import org.springframework.transaction.annotation.Propagation;
811
import org.springframework.transaction.annotation.Transactional;
912

1013
import java.time.LocalDateTime;
@@ -17,8 +20,9 @@ public class PaymentFacade {
1720

1821
private final PaymentService paymentService;
1922
private final OrderRepository orderRepository;
23+
private final ProductRepository productRepository;
2024

21-
@Transactional
25+
@Transactional(propagation = Propagation.NOT_SUPPORTED)
2226
public PaymentInfo requestPayment(String userId, PaymentCommand.RequestPayment command) {
2327
return paymentService.requestPayment(userId, command);
2428
}
@@ -63,6 +67,31 @@ public PaymentInfo syncPaymentStatus(String userId, String transactionKey) {
6367
return paymentInfo;
6468
}
6569

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+
6695
@Transactional(readOnly = true)
6796
public PaymentInfo getPaymentByOrderNo(String orderNo) {
6897
return paymentService.getPaymentByOrderNo(orderNo);

apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java

Lines changed: 82 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import lombok.RequiredArgsConstructor;
1212
import lombok.extern.slf4j.Slf4j;
1313
import org.springframework.stereotype.Component;
14+
import org.springframework.transaction.annotation.Propagation;
15+
import org.springframework.transaction.annotation.Transactional;
1416

1517
import java.time.LocalDateTime;
1618
import java.util.List;
@@ -26,7 +28,8 @@ public class PaymentService {
2628
private final OrderRepository orderRepository;
2729
private final PgGateway pgGateway;
2830

29-
public PaymentInfo requestPayment(String userId, PaymentCommand.RequestPayment command) {
31+
@Transactional
32+
public Payment createPayment(String userId, PaymentCommand.RequestPayment command) {
3033
log.info("[Payment] 결제 요청 시작 - orderNo: {}, amount: {}", command.orderId(), command.amount());
3134

3235
Order order = orderRepository.findByOrderNo(command.orderId())
@@ -60,35 +63,33 @@ public PaymentInfo requestPayment(String userId, PaymentCommand.RequestPayment c
6063
throw new CoreException(ErrorType.CONFLICT, "이미 처리 중인 결제입니다: " + order.getOrderNo());
6164
}
6265
log.info("[Payment] Payment 엔티티 생성 - id: {}, orderNo: {}", payment.getId(), order.getOrderNo());
66+
return payment;
67+
}
6368

64-
try {
65-
PgGateway.PgPaymentCommand pgCommand = new PgGateway.PgPaymentCommand(
66-
order.getOrderNo(),
67-
command.cardType(),
68-
command.cardNo(),
69-
command.amount(),
70-
command.callbackUrl()
71-
);
72-
73-
CompletableFuture<PgGateway.PgPaymentResult> future = pgGateway.requestPayment(userId, pgCommand);
74-
PgGateway.PgPaymentResult pgResult = future.get(3, TimeUnit.SECONDS);
69+
public PaymentInfo requestPayment(String userId, PaymentCommand.RequestPayment command) {
70+
Payment payment = createPayment(userId, command);
7571

76-
if (pgResult.transactionKey() != null) {
77-
payment.assignTransactionKey(pgResult.transactionKey());
78-
log.info("[Payment] PG 결제 요청 성공 - transactionKey: {}", pgResult.transactionKey());
79-
} else {
80-
payment.markAsRequiresRetry();
81-
log.warn("[Payment] PG 장애로 재시도 필요 - orderNo: {}", order.getOrderNo());
82-
}
72+
PgGateway.PgPaymentCommand pgCommand = new PgGateway.PgPaymentCommand(
73+
command.orderId(),
74+
command.cardType(),
75+
command.cardNo(),
76+
command.amount(),
77+
command.callbackUrl()
78+
);
8379

80+
try {
81+
CompletableFuture<PgGateway.PgPaymentResult> future = pgGateway.requestPayment(userId, pgCommand);
82+
PgGateway.PgPaymentResult pgResult = future.get(2, TimeUnit.SECONDS);
83+
applyPgResult(payment.getId(), pgResult);
8484
} catch (Exception e) {
85-
log.error("[Payment] PG 결제 요청 실패 - orderNo: {}", order.getOrderNo(), e);
86-
payment.markAsRequiresRetry();
85+
log.error("[Payment] PG 결제 요청 실패 - orderNo: {}", command.orderId(), e);
86+
markRequiresRetry(payment.getId());
8787
}
8888

89-
return PaymentInfo.from(payment);
89+
return PaymentInfo.from(findById(payment.getId()));
9090
}
9191

92+
@Transactional
9293
public PaymentInfo processCallback(String userId, PaymentCommand.ProcessCallback command) {
9394
log.info("[Payment] 콜백 처리 시작 - transactionKey: {}, status: {}",
9495
command.transactionKey(), command.status());
@@ -116,16 +117,14 @@ public PaymentInfo syncPaymentStatus(String userId, String transactionKey) {
116117
try {
117118
PgGateway.PgPaymentDetail pgDetail = pgGateway.getPaymentStatus(userId, transactionKey);
118119
PaymentStatus pgStatus = PaymentStatus.valueOf(pgDetail.status().name());
119-
payment.updateFromPg(pgStatus, pgDetail.reason());
120-
120+
applyPgDetail(payment.getId(), pgStatus, pgDetail.reason());
121121
log.info("[Payment] 상태 동기화 완료 - orderNo: {}, status: {}",
122-
payment.getOrder().getOrderNo(), payment.getStatus());
123-
122+
payment.getOrder().getOrderNo(), pgStatus);
124123
} catch (Exception e) {
125124
log.error("[Payment] 상태 조회 실패 - transactionKey: {}", transactionKey, e);
126125
}
127126

128-
return PaymentInfo.from(payment);
127+
return PaymentInfo.from(findById(payment.getId()));
129128
}
130129

131130
public PaymentInfo getPaymentByOrderNo(String orderNo) {
@@ -151,4 +150,60 @@ public List<PaymentInfo> getPaymentsRequiringRetry() {
151150
.map(PaymentInfo::from)
152151
.toList();
153152
}
153+
154+
@Transactional(readOnly = true)
155+
public Payment findByOrderNo(String orderNo) {
156+
Order order = orderRepository.findByOrderNo(orderNo)
157+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
158+
"주문을 찾을 수 없습니다: " + orderNo));
159+
return paymentRepository.findByOrder(order)
160+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
161+
"결제 정보를 찾을 수 없습니다: " + orderNo));
162+
}
163+
164+
@Transactional(propagation = Propagation.REQUIRES_NEW)
165+
public Payment applyPgResult(Long paymentId, PgGateway.PgPaymentResult pgResult) {
166+
Payment payment = findById(paymentId);
167+
168+
if (pgResult.transactionKey() != null) {
169+
if (!payment.hasTransactionKey()) {
170+
payment.assignTransactionKey(pgResult.transactionKey());
171+
log.info("[Payment] PG 결제 요청 성공 - transactionKey: {}", pgResult.transactionKey());
172+
} else {
173+
log.info("[Payment] PG 결제 요청 성공(기존 key 유지) - transactionKey: {}", payment.getTransactionKey());
174+
}
175+
} else {
176+
payment.markAsRequiresRetry();
177+
log.warn("[Payment] PG 장애로 재시도 필요 - orderNo: {}", payment.getOrder().getOrderNo());
178+
}
179+
return payment;
180+
}
181+
182+
@Transactional(propagation = Propagation.REQUIRES_NEW)
183+
public Payment applyPgDetail(Long paymentId, PaymentStatus pgStatus, String reason) {
184+
Payment payment = findById(paymentId);
185+
payment.updateFromPg(pgStatus, reason);
186+
return payment;
187+
}
188+
189+
@Transactional(propagation = Propagation.REQUIRES_NEW)
190+
public Payment markRequiresRetry(Long paymentId) {
191+
Payment payment = findById(paymentId);
192+
payment.markAsRequiresRetry();
193+
return payment;
194+
}
195+
196+
@Transactional(readOnly = true)
197+
public Payment findById(Long paymentId) {
198+
return paymentRepository.findById(paymentId)
199+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
200+
"결제 정보를 찾을 수 없습니다: id=" + paymentId));
201+
}
202+
203+
@Transactional(propagation = Propagation.REQUIRES_NEW)
204+
public Payment cancelPayment(Long paymentId, String reason) {
205+
Payment payment = findById(paymentId);
206+
payment.markAsCancelled(reason);
207+
return payment;
208+
}
154209
}

apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentStatusSyncScheduler.java

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,15 @@ public void syncPendingPayments() {
3636
for (PaymentInfo paymentInfo : pendingPayments) {
3737
try {
3838
if (paymentInfo.transactionKey() == null || paymentInfo.transactionKey().isEmpty()) {
39-
log.warn("[PaymentSync] transactionKey 없음, 스킵 - orderNo: {}", paymentInfo.orderNo());
39+
if (paymentInfo.requiresRetry()) {
40+
log.warn("[PaymentSync] transactionKey 없음 + 재시도 필요, 취소 처리 - orderNo: {}", paymentInfo.orderNo());
41+
paymentFacade.cancelPayment(
42+
"SCHEDULER",
43+
paymentInfo.orderNo(),
44+
"PG 요청 실패로 자동 취소");
45+
} else {
46+
log.warn("[PaymentSync] transactionKey 없음, 스킵 - orderNo: {}", paymentInfo.orderNo());
47+
}
4048
continue;
4149
}
4250

@@ -80,8 +88,19 @@ public void processRetryPayments() {
8088
log.info("[PaymentRetry] 재시도 필요 결제 {}건 발견", retryPayments.size());
8189

8290
for (PaymentInfo paymentInfo : retryPayments) {
83-
log.warn("[PaymentRetry] 수동 처리 필요 - orderNo: {}, 관리자 확인 필요", paymentInfo.orderNo());
84-
// TODO: 관리자 알림, 수동 재시도 API 제공 등
91+
try {
92+
if (paymentInfo.createdAt().isBefore(java.time.ZonedDateTime.now().minusMinutes(15))) {
93+
log.warn("[PaymentRetry] 재시도 한도 초과, 자동 취소 - orderNo: {}", paymentInfo.orderNo());
94+
paymentFacade.cancelPayment(
95+
"SCHEDULER",
96+
paymentInfo.orderNo(),
97+
"PG 재요청 실패 - 타임아웃 초과");
98+
} else {
99+
log.warn("[PaymentRetry] 수동 처리 필요 - orderNo: {}, 관리자 확인 필요", paymentInfo.orderNo());
100+
}
101+
} catch (Exception e) {
102+
log.error("[PaymentRetry] 재시도 결제 처리 오류 - orderNo: {}", paymentInfo.orderNo(), e);
103+
}
85104
}
86105

87106
} catch (Exception e) {

apps/commerce-api/src/main/java/com/loopers/config/RestTemplateConfig.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ public class RestTemplateConfig {
1515
public RestTemplate pgRestTemplate(RestTemplateBuilder builder) {
1616
// PG 전용 RestTemplate - 더 짧은 타임아웃 설정
1717
return builder
18-
.setConnectTimeout(Duration.ofSeconds(2)) // PG 연결 타임아웃: 2초
19-
.setReadTimeout(Duration.ofSeconds(3)) // PG 읽기 타임아웃: 3초 (Resilience4j TimeLimiter가 2초로 제한)
18+
.setConnectTimeout(Duration.ofSeconds(1)) // PG 연결 타임아웃: 1초
19+
.setReadTimeout(Duration.ofMillis(1800)) // PG 읽기 타임아웃: TimeLimiter(2s)보다 짧게
2020
.requestFactory(SimpleClientHttpRequestFactory.class)
2121
.build();
2222
}

apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,23 @@ public void markAsRequiresRetry() {
101101
this.lastCheckedAt = LocalDateTime.now();
102102
}
103103

104+
public void markAsCancelled(String reason) {
105+
if (this.status == PaymentStatus.SUCCESS) {
106+
throw new IllegalStateException("이미 성공한 결제는 취소할 수 없습니다.");
107+
}
108+
this.status = PaymentStatus.CANCELLED;
109+
this.reason = reason;
110+
this.requiresRetry = false;
111+
this.lastCheckedAt = LocalDateTime.now();
112+
}
113+
104114
public void updateFromPg(PaymentStatus pgStatus, String reason) {
105115
this.lastCheckedAt = LocalDateTime.now();
106116

107117
switch (pgStatus) {
108118
case SUCCESS -> markAsSuccess();
109119
case FAILED -> markAsFailed(reason);
120+
case CANCELLED -> markAsCancelled(reason);
110121
case PENDING -> this.status = PaymentStatus.PENDING;
111122
}
112123
}

apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,10 @@ public enum PaymentStatus {
1414
/**
1515
* 결제 실패 (한도초과, 잘못된 카드 등)
1616
*/
17-
FAILED
17+
FAILED,
18+
19+
/**
20+
* 결제 취소 (사용자/시스템에 의해 중단)
21+
*/
22+
CANCELLED
1823
}

apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ public interface ProductRepository {
2222

2323
int decreaseStock(Long productId, int quantity);
2424

25+
int increaseStock(Long productId, int quantity);
26+
2527
int incrementLikeCount(Long productId);
2628

2729
int decrementLikeCount(Long productId);

apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,15 @@ public int decreaseStock(Long productId, int quantity) {
124124
.execute());
125125
}
126126

127+
@Override
128+
public int increaseStock(Long productId, int quantity) {
129+
return Math.toIntExact(queryFactory
130+
.update(product)
131+
.set(product.stock.quantity, product.stock.quantity.add(quantity))
132+
.where(product.id.eq(productId))
133+
.execute());
134+
}
135+
127136
@Override
128137
public int incrementLikeCount(Long productId) {
129138
return Math.toIntExact(queryFactory
@@ -249,4 +258,4 @@ private String[] decodeCursor(String cursor) {
249258
return null;
250259
}
251260
}
252-
}
261+
}

apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentController.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ public ResponseEntity<PaymentResponse> syncPaymentStatus(
6161
return ResponseEntity.ok(PaymentResponse.from(paymentInfo));
6262
}
6363

64+
@PostMapping("/orders/{orderNo}/cancel")
65+
public ResponseEntity<PaymentResponse> cancelPayment(
66+
@RequestHeader("X-USER-ID") String userId,
67+
@PathVariable String orderNo,
68+
@RequestBody CancelRequest request
69+
) {
70+
log.info("[Payment API] 결제 취소 - userId: {}, orderNo: {}", userId, orderNo);
71+
72+
PaymentInfo paymentInfo = paymentFacade.cancelPayment(userId, orderNo, request.reason());
73+
74+
return ResponseEntity.ok(PaymentResponse.from(paymentInfo));
75+
}
76+
6477
public record PaymentRequest(
6578
String orderId,
6679
CardType cardType,
@@ -69,6 +82,10 @@ public record PaymentRequest(
6982
String callbackUrl
7083
) {}
7184

85+
public record CancelRequest(
86+
String reason
87+
) {}
88+
7289
public record PaymentResponse(
7390
Long id,
7491
String orderNo,

0 commit comments

Comments
 (0)