Skip to content

Commit d928734

Browse files
authored
[volume-6] 외부 시스템 장애 및 지연 대응 (#152)
* feat: PG 모듈 추가 (#24) * Feature/pg client (#27) * test: PG 호출 테스트 코드 추가 * feat: PG 호출 로직 구현 * test: PG CircuitBreaker 테스트 코드 추가 * feat: CircuitBreaker 로직 구현
1 parent 5054a54 commit d928734

70 files changed

Lines changed: 5443 additions & 86 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.

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: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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.domain.order.OrderCancellationService;
7+
import com.loopers.domain.user.User;
8+
import com.loopers.domain.user.UserRepository;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.stereotype.Component;
12+
import org.springframework.transaction.annotation.Propagation;
13+
import org.springframework.transaction.annotation.Transactional;
14+
15+
/**
16+
* 결제 실패 처리 서비스.
17+
* <p>
18+
* 결제 실패 시 주문 취소 및 리소스 원복을 처리합니다.
19+
* </p>
20+
* <p>
21+
* <b>트랜잭션 전략:</b>
22+
* <ul>
23+
* <li>REQUIRES_NEW: 별도 트랜잭션으로 실행하여 외부 시스템 호출과 독립적으로 처리</li>
24+
* <li>결제 실패 처리 중 오류가 발생해도 기존 주문 생성 트랜잭션에 영향을 주지 않음</li>
25+
* <li>Self-invocation 문제 해결: 별도 서비스로 분리하여 Spring AOP 프록시가 정상적으로 적용되도록 함</li>
26+
* </ul>
27+
* </p>
28+
* <p>
29+
* <b>주의사항:</b>
30+
* <ul>
31+
* <li>주문이 이미 취소되었거나 존재하지 않는 경우 로그만 기록합니다.</li>
32+
* <li>결제 실패 처리 중 오류 발생 시에도 로그만 기록합니다.</li>
33+
* </ul>
34+
* </p>
35+
*
36+
* @author Loopers
37+
* @version 1.0
38+
*/
39+
@Slf4j
40+
@Component
41+
@RequiredArgsConstructor
42+
public class PaymentFailureHandler {
43+
44+
private final UserRepository userRepository;
45+
private final OrderRepository orderRepository;
46+
private final OrderCancellationService orderCancellationService;
47+
48+
/**
49+
* 결제 실패 시 주문 취소 및 리소스 원복을 처리합니다.
50+
* <p>
51+
* 결제 요청이 실패한 경우, 이미 생성된 주문을 취소하고
52+
* 차감된 포인트를 환불하며 재고를 원복합니다.
53+
* </p>
54+
* <p>
55+
* <b>처리 내용:</b>
56+
* <ul>
57+
* <li>주문 상태를 CANCELED로 변경</li>
58+
* <li>차감된 포인트 환불</li>
59+
* <li>차감된 재고 원복</li>
60+
* </ul>
61+
* </p>
62+
*
63+
* @param userId 사용자 ID (로그인 ID)
64+
* @param orderId 주문 ID
65+
* @param errorCode 오류 코드
66+
* @param errorMessage 오류 메시지
67+
*/
68+
@Transactional(propagation = Propagation.REQUIRES_NEW)
69+
public void handle(String userId, Long orderId, String errorCode, String errorMessage) {
70+
try {
71+
// 사용자 조회
72+
User user = userRepository.findByUserId(userId);
73+
74+
if (user == null) {
75+
log.warn("결제 실패 처리 시 사용자를 찾을 수 없습니다. (userId: {}, orderId: {})", userId, orderId);
76+
return;
77+
}
78+
79+
// 주문 조회
80+
Order order = orderRepository.findById(orderId)
81+
.orElse(null);
82+
83+
if (order == null) {
84+
log.warn("결제 실패 처리 시 주문을 찾을 수 없습니다. (orderId: {})", orderId);
85+
return;
86+
}
87+
88+
// 이미 취소된 주문인 경우 처리하지 않음
89+
if (order.getStatus() == OrderStatus.CANCELED) {
90+
log.info("이미 취소된 주문입니다. 결제 실패 처리를 건너뜁니다. (orderId: {})", orderId);
91+
return;
92+
}
93+
94+
// 주문 취소 및 리소스 원복
95+
orderCancellationService.cancel(order, user);
96+
97+
log.info("결제 실패로 인한 주문 취소 완료. (orderId: {}, errorCode: {}, errorMessage: {})",
98+
orderId, errorCode, errorMessage);
99+
} catch (Exception e) {
100+
// 결제 실패 처리 중 오류 발생 시에도 로그만 기록
101+
// 이미 주문은 생성되어 있으므로, 나중에 수동으로 처리할 수 있도록 로그 기록
102+
log.error("결제 실패 처리 중 오류 발생. (orderId: {}, errorCode: {})",
103+
orderId, errorCode, e);
104+
}
105+
}
106+
}
107+
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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.user.UserJpaRepository;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.scheduling.annotation.Scheduled;
10+
import org.springframework.stereotype.Component;
11+
12+
import java.util.List;
13+
14+
/**
15+
* 결제 상태 복구 스케줄러.
16+
* <p>
17+
* 콜백이 오지 않은 PENDING 상태의 주문들을 주기적으로 조회하여
18+
* PG 시스템의 결제 상태 확인 API를 통해 상태를 복구합니다.
19+
* </p>
20+
* <p>
21+
* <b>동작 원리:</b>
22+
* <ol>
23+
* <li>주기적으로 실행 (기본: 1분마다)</li>
24+
* <li>PENDING 상태인 주문들을 조회</li>
25+
* <li>각 주문에 대해 PG 결제 상태 확인 API 호출</li>
26+
* <li>결제 상태에 따라 주문 상태 업데이트</li>
27+
* </ol>
28+
* </p>
29+
* <p>
30+
* <b>설계 근거:</b>
31+
* <ul>
32+
* <li><b>주기적 복구:</b> 콜백이 오지 않아도 자동으로 상태 복구</li>
33+
* <li><b>Eventually Consistent:</b> 약간의 지연 허용 가능</li>
34+
* <li><b>안전한 처리:</b> 각 주문별로 독립적으로 처리하여 실패 시에도 다른 주문에 영향 없음</li>
35+
* <li><b>성능 고려:</b> 배치로 처리하여 PG 시스템 부하 최소화</li>
36+
* </ul>
37+
* </p>
38+
*
39+
* @author Loopers
40+
* @version 1.0
41+
*/
42+
@Slf4j
43+
@RequiredArgsConstructor
44+
@Component
45+
public class PaymentRecoveryScheduler {
46+
47+
private final OrderRepository orderRepository;
48+
private final UserJpaRepository userJpaRepository;
49+
private final PurchasingFacade purchasingFacade;
50+
51+
/**
52+
* PENDING 상태인 주문들의 결제 상태를 복구합니다.
53+
* <p>
54+
* 1분마다 실행되어 PENDING 상태인 주문들을 조회하고,
55+
* 각 주문에 대해 PG 결제 상태 확인 API를 호출하여 상태를 복구합니다.
56+
* </p>
57+
* <p>
58+
* <b>처리 전략:</b>
59+
* <ul>
60+
* <li><b>배치 처리:</b> 한 번에 여러 주문 처리</li>
61+
* <li><b>독립적 처리:</b> 각 주문별로 독립적으로 처리하여 실패 시에도 다른 주문에 영향 없음</li>
62+
* <li><b>안전한 예외 처리:</b> 개별 주문 처리 실패 시에도 계속 진행</li>
63+
* </ul>
64+
* </p>
65+
*/
66+
@Scheduled(fixedDelay = 60000) // 1분마다 실행
67+
public void recoverPendingOrders() {
68+
try {
69+
log.debug("결제 상태 복구 스케줄러 시작");
70+
71+
// PENDING 상태인 주문들 조회
72+
List<Order> pendingOrders = orderRepository.findAllByStatus(OrderStatus.PENDING);
73+
74+
if (pendingOrders.isEmpty()) {
75+
log.debug("복구할 PENDING 상태 주문이 없습니다.");
76+
return;
77+
}
78+
79+
log.info("PENDING 상태 주문 {}건에 대한 결제 상태 복구 시작", pendingOrders.size());
80+
81+
int successCount = 0;
82+
int failureCount = 0;
83+
84+
// 각 주문에 대해 결제 상태 확인 및 복구
85+
for (Order order : pendingOrders) {
86+
try {
87+
// Order의 userId는 User의 id (Long)이므로 User를 조회하여 userId (String)를 가져옴
88+
var userOptional = userJpaRepository.findById(order.getUserId());
89+
if (userOptional.isEmpty()) {
90+
log.warn("주문의 사용자를 찾을 수 없습니다. 복구를 건너뜁니다. (orderId: {}, userId: {})",
91+
order.getId(), order.getUserId());
92+
failureCount++;
93+
continue;
94+
}
95+
96+
String userId = userOptional.get().getUserId();
97+
98+
// 결제 상태 확인 및 복구
99+
purchasingFacade.recoverOrderStatusByPaymentCheck(userId, order.getId());
100+
successCount++;
101+
} catch (Exception e) {
102+
// 개별 주문 처리 실패 시에도 계속 진행
103+
log.error("주문 상태 복구 중 오류 발생. (orderId: {})", order.getId(), e);
104+
failureCount++;
105+
}
106+
}
107+
108+
log.info("결제 상태 복구 완료. 성공: {}건, 실패: {}건", successCount, failureCount);
109+
110+
} catch (Exception e) {
111+
log.error("결제 상태 복구 스케줄러 실행 중 오류 발생", e);
112+
}
113+
}
114+
}
115+
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
boolean updated = orderStatusUpdater.updateByPaymentStatus(orderId, status, null, null);
52+
53+
if (!updated) {
54+
log.warn("타임아웃 후 상태 확인 실패. 주문 상태 업데이트에 실패했습니다. 나중에 스케줄러로 복구됩니다. (orderId: {})", orderId);
55+
}
56+
57+
} catch (InterruptedException e) {
58+
Thread.currentThread().interrupt();
59+
log.warn("타임아웃 후 상태 확인 중 인터럽트 발생. (orderId: {})", orderId);
60+
} catch (Exception e) {
61+
// 기타 오류: 나중에 스케줄러로 복구 가능
62+
log.error("타임아웃 후 상태 확인 중 오류 발생. 나중에 스케줄러로 복구됩니다. (orderId: {})", orderId, e);
63+
}
64+
}
65+
}
66+
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+

0 commit comments

Comments
 (0)