Skip to content

Commit 7e210e8

Browse files
authored
[REFACTOR] Retry 처리 (#19)
1 parent 4334b63 commit 7e210e8

8 files changed

Lines changed: 251 additions & 27 deletions

File tree

external/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ public TosspaymentsClient(TossPaymentsClientBuilder tossPaymentsClientBuilder) {
1515
this.restClient = tossPaymentsClientBuilder.getTossPaymentsClientBuilder().build();
1616
}
1717

18-
public TosspaymentsPaymentResponse confirmPayment(TosspaymentsConfirmRequest request) {
18+
public TosspaymentsPaymentResponse confirmPayment(TosspaymentsConfirmRequest request, String idempotencyKey) {
1919
return restClient.post()
2020
.uri("/v1/payments/confirm")
21+
.header("Idempotency-Key", idempotencyKey)
2122
.body(request)
2223
.retrieve()
2324
.body(TosspaymentsPaymentResponse.class);

internal/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dependencies {
1414
implementation 'org.springframework.boot:spring-boot-starter-web'
1515
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
1616
implementation 'org.springframework.boot:spring-boot-starter-validation'
17+
implementation 'org.springframework.retry:spring-retry'
1718

1819
runtimeOnly 'com.mysql:mysql-connector-j'
1920

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.samhap.kokomen.global.config;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
7+
import org.springframework.retry.support.RetryTemplate;
8+
9+
@Configuration
10+
public class RetryConfig {
11+
12+
@Value("${retry.tosspayments.max-attempts}")
13+
private int maxAttempts;
14+
15+
@Value("${retry.tosspayments.initial-interval}")
16+
private long initialInterval;
17+
18+
@Value("${retry.tosspayments.multiplier}")
19+
private double multiplier;
20+
21+
@Value("${retry.tosspayments.max-interval}")
22+
private long maxInterval;
23+
24+
@Bean
25+
public RetryTemplate tosspaymentsConfirmRetryTemplate() {
26+
RetryTemplate retryTemplate = new RetryTemplate();
27+
28+
TosspaymentsConfirmRetryPolicy retryPolicy = new TosspaymentsConfirmRetryPolicy(maxAttempts);
29+
retryTemplate.setRetryPolicy(retryPolicy);
30+
31+
ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
32+
backOffPolicy.setInitialInterval(initialInterval);
33+
backOffPolicy.setMultiplier(multiplier);
34+
backOffPolicy.setMaxInterval(maxInterval);
35+
retryTemplate.setBackOffPolicy(backOffPolicy);
36+
37+
return retryTemplate;
38+
}
39+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.samhap.kokomen.global.config;
2+
3+
import org.springframework.retry.RetryContext;
4+
import org.springframework.retry.policy.SimpleRetryPolicy;
5+
import org.springframework.web.client.HttpClientErrorException;
6+
import org.springframework.web.client.HttpServerErrorException;
7+
import org.springframework.web.client.ResourceAccessException;
8+
9+
public class TosspaymentsConfirmRetryPolicy extends SimpleRetryPolicy {
10+
11+
public TosspaymentsConfirmRetryPolicy(int maxAttempts) {
12+
super(maxAttempts);
13+
}
14+
15+
@Override
16+
public boolean canRetry(RetryContext context) {
17+
Throwable lastException = context.getLastThrowable();
18+
if (lastException != null && !isRetryableException(lastException)) {
19+
return false;
20+
}
21+
return super.canRetry(context);
22+
}
23+
24+
private boolean isRetryableException(Throwable throwable) {
25+
if (throwable instanceof HttpServerErrorException) {
26+
return true;
27+
}
28+
if (throwable instanceof ResourceAccessException) {
29+
return true;
30+
}
31+
if (throwable instanceof HttpClientErrorException e) {
32+
return e.getStatusCode().value() == 409;
33+
}
34+
return false;
35+
}
36+
}

internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
import com.samhap.kokomen.payment.service.dto.ConfirmRequest;
1717
import com.samhap.kokomen.payment.service.dto.PaymentResponse;
1818
import java.net.SocketTimeoutException;
19+
import java.util.UUID;
1920
import lombok.RequiredArgsConstructor;
2021
import lombok.extern.slf4j.Slf4j;
22+
import org.springframework.retry.support.RetryTemplate;
2123
import org.springframework.stereotype.Service;
2224
import org.springframework.web.client.HttpClientErrorException;
2325
import org.springframework.web.client.HttpServerErrorException;
@@ -31,6 +33,7 @@ public class PaymentFacadeService {
3133
private final TosspaymentsTransactionService tosspaymentsTransactionService;
3234
private final TosspaymentsPaymentService tosspaymentsPaymentService;
3335
private final TosspaymentsClient tosspaymentsClient;
36+
private final RetryTemplate tosspaymentsConfirmRetryTemplate;
3437

3538
public PaymentResponse confirmPayment(ConfirmRequest request) {
3639
TosspaymentsPayment tosspaymentsPayment = tosspaymentsPaymentService.saveTosspaymentsPayment(request);
@@ -40,20 +43,33 @@ public PaymentResponse confirmPayment(ConfirmRequest request) {
4043
} catch (KokomenException | HttpServerErrorException | ResourceAccessException e) {
4144
// inner에서 상태 처리 완료
4245
throw e;
43-
} catch (Exception e) {
46+
} catch (Exception e) {
4447
// 예상치 못한 예외만 NEED_CANCEL 설정
4548
tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL);
4649
throw e;
4750
}
4851
}
4952

50-
private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request, TosspaymentsPayment tosspaymentsPayment) {
53+
private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request,
54+
TosspaymentsPayment tosspaymentsPayment) {
55+
String idempotencyKey = UUID.randomUUID().toString();
5156
try {
52-
TosspaymentsPaymentResponse tosspaymentsConfirmResponse = tosspaymentsClient.confirmPayment(request.toTosspaymentsConfirmRequest());
53-
tosspaymentsPayment.validateTosspaymentsResult(tosspaymentsConfirmResponse.paymentKey(), tosspaymentsConfirmResponse.orderId(),
57+
TosspaymentsPaymentResponse tosspaymentsConfirmResponse = tosspaymentsConfirmRetryTemplate.execute(
58+
context -> {
59+
if (context.getRetryCount() > 0) {
60+
log.warn("토스페이먼츠 결제 승인 재시도 {}회차, paymentKey = {}",
61+
context.getRetryCount(), request.paymentKey());
62+
}
63+
return tosspaymentsClient.confirmPayment(request.toTosspaymentsConfirmRequest(),
64+
idempotencyKey);
65+
});
66+
tosspaymentsPayment.validateTosspaymentsResult(tosspaymentsConfirmResponse.paymentKey(),
67+
tosspaymentsConfirmResponse.orderId(),
5468
tosspaymentsConfirmResponse.totalAmount());
55-
TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment);
56-
tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.COMPLETED);
69+
TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(
70+
tosspaymentsPayment);
71+
tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult,
72+
PaymentState.COMPLETED);
5773
return tosspaymentsConfirmResponse;
5874
} catch (HttpClientErrorException e) {
5975
throw handleConfirmClientError(e, tosspaymentsPayment);
@@ -66,7 +82,8 @@ private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request, Tossp
6682
}
6783
}
6884

69-
private RuntimeException handleConfirmClientError(HttpClientErrorException e, TosspaymentsPayment tosspaymentsPayment) {
85+
private RuntimeException handleConfirmClientError(HttpClientErrorException e,
86+
TosspaymentsPayment tosspaymentsPayment) {
7087
Failure failure = e.getResponseBodyAs(Failure.class);
7188
if (failure == null) {
7289
log.error("토스 결제 실패(400) - 응답 파싱 실패", e);
@@ -75,6 +92,12 @@ private RuntimeException handleConfirmClientError(HttpClientErrorException e, To
7592
}
7693
String code = failure.code();
7794

95+
if ("IDEMPOTENT_REQUEST_PROCESSING".equals(code)) {
96+
log.error("토스 결제 처리 중 상태 지속 (409), paymentKey = {}", tosspaymentsPayment.getPaymentKey());
97+
tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL);
98+
return new InternalServerErrorException(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage(), e);
99+
}
100+
78101
if (TosspaymentsInternalServerErrorCode.contains(code)) {
79102
log.error("토스 결제 실패(서버 원인 400), code = {}, message = {}", code, failure.message());
80103
tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.SERVER_BAD_REQUEST);
@@ -87,11 +110,13 @@ private RuntimeException handleConfirmClientError(HttpClientErrorException e, To
87110
}
88111

89112
private void handleConfirmServerError(HttpServerErrorException e, TosspaymentsPayment tosspaymentsPayment) {
90-
// TODO: retry
91113
try {
92-
TosspaymentsPaymentResponse tosspaymentsConfirmResponse = e.getResponseBodyAs(TosspaymentsPaymentResponse.class);
93-
TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment);
94-
tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.NEED_CANCEL);
114+
TosspaymentsPaymentResponse tosspaymentsConfirmResponse = e.getResponseBodyAs(
115+
TosspaymentsPaymentResponse.class);
116+
TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(
117+
tosspaymentsPayment);
118+
tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult,
119+
PaymentState.NEED_CANCEL);
95120
} catch (Exception parseException) {
96121
log.warn("토스 5xx 응답 파싱 실패, 상태만 업데이트합니다. paymentId = {}", tosspaymentsPayment.getId(), parseException);
97122
tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL);
@@ -101,12 +126,10 @@ private void handleConfirmServerError(HttpServerErrorException e, TosspaymentsPa
101126
private void handleConfirmNetworkError(ResourceAccessException e, TosspaymentsPayment tosspaymentsPayment) {
102127
if (e.getRootCause() instanceof SocketTimeoutException socketTimeoutException) {
103128
if (socketTimeoutException.getMessage().contains("Connect timed out")) {
104-
// TODO: retry
105129
tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CONNECTION_TIMEOUT);
106130
return;
107131
}
108132
if (socketTimeoutException.getMessage().contains("Read timed out")) {
109-
// TODO: retry
110133
tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL);
111134
return;
112135
}
@@ -115,17 +138,20 @@ private void handleConfirmNetworkError(ResourceAccessException e, TosspaymentsPa
115138
}
116139

117140
public void cancelPayment(CancelRequest request) {
118-
TosspaymentsPaymentCancelRequest tosspaymentsPaymentCancelRequest = new TosspaymentsPaymentCancelRequest(request.cancelReason());
141+
TosspaymentsPaymentCancelRequest tosspaymentsPaymentCancelRequest = new TosspaymentsPaymentCancelRequest(
142+
request.cancelReason());
119143
try {
120-
TosspaymentsPaymentResponse response = tosspaymentsClient.cancelPayment(request.paymentKey(), tosspaymentsPaymentCancelRequest);
144+
TosspaymentsPaymentResponse response = tosspaymentsClient.cancelPayment(request.paymentKey(),
145+
tosspaymentsPaymentCancelRequest);
121146
tosspaymentsTransactionService.applyCancelResult(response);
122147
} catch (HttpClientErrorException e) {
123148
Failure failure = e.getResponseBodyAs(Failure.class);
124149
if (failure == null) {
125150
log.error("결제 취소 실패(400) - 응답 파싱 실패, paymentKey: {}", request.paymentKey(), e);
126151
throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage(), e);
127152
}
128-
log.error("결제 취소 실패(400) - paymentKey: {}, code: {}, message: {}", request.paymentKey(), failure.code(), failure.message());
153+
log.error("결제 취소 실패(400) - paymentKey: {}, code: {}, message: {}", request.paymentKey(), failure.code(),
154+
failure.message());
129155
throw new BadRequestException(failure.message(), e);
130156
} catch (HttpServerErrorException e) {
131157
log.error("결제 취소 실패(5xx) - paymentKey: {}, status: {}", request.paymentKey(), e.getStatusCode());

internal/src/main/resources/application.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,33 @@ spring:
1919
config:
2020
activate:
2121
on-profile: local
22+
retry:
23+
tosspayments:
24+
max-attempts: 3
25+
initial-interval: 500
26+
multiplier: 2.0
27+
max-interval: 2000
28+
---
29+
# dev profile
30+
spring:
31+
config:
32+
activate:
33+
on-profile: dev
34+
retry:
35+
tosspayments:
36+
max-attempts: 3
37+
initial-interval: 500
38+
multiplier: 2.0
39+
max-interval: 2000
40+
---
41+
# prod profile
42+
spring:
43+
config:
44+
activate:
45+
on-profile: prod
46+
retry:
47+
tosspayments:
48+
max-attempts: 3
49+
initial-interval: 500
50+
multiplier: 2.0
51+
max-interval: 2000

0 commit comments

Comments
 (0)