Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ public TosspaymentsClient(TossPaymentsClientBuilder tossPaymentsClientBuilder) {
this.restClient = tossPaymentsClientBuilder.getTossPaymentsClientBuilder().build();
}

public TosspaymentsPaymentResponse confirmPayment(TosspaymentsConfirmRequest request) {
public TosspaymentsPaymentResponse confirmPayment(TosspaymentsConfirmRequest request, String idempotencyKey) {
return restClient.post()
.uri("/v1/payments/confirm")
.header("Idempotency-Key", idempotencyKey)
.body(request)
.retrieve()
.body(TosspaymentsPaymentResponse.class);
Expand Down
1 change: 1 addition & 0 deletions internal/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.retry:spring-retry'

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.samhap.kokomen.global.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.support.RetryTemplate;

@Configuration
public class RetryConfig {

@Value("${retry.tosspayments.max-attempts}")
private int maxAttempts;

@Value("${retry.tosspayments.initial-interval}")
private long initialInterval;

@Value("${retry.tosspayments.multiplier}")
private double multiplier;

@Value("${retry.tosspayments.max-interval}")
private long maxInterval;

@Bean
public RetryTemplate tosspaymentsConfirmRetryTemplate() {
RetryTemplate retryTemplate = new RetryTemplate();

TosspaymentsConfirmRetryPolicy retryPolicy = new TosspaymentsConfirmRetryPolicy(maxAttempts);
retryTemplate.setRetryPolicy(retryPolicy);

ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
backOffPolicy.setInitialInterval(initialInterval);
backOffPolicy.setMultiplier(multiplier);
backOffPolicy.setMaxInterval(maxInterval);
retryTemplate.setBackOffPolicy(backOffPolicy);

return retryTemplate;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.samhap.kokomen.global.config;

import org.springframework.retry.RetryContext;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;

public class TosspaymentsConfirmRetryPolicy extends SimpleRetryPolicy {

public TosspaymentsConfirmRetryPolicy(int maxAttempts) {
super(maxAttempts);
}

@Override
public boolean canRetry(RetryContext context) {
Throwable lastException = context.getLastThrowable();
if (lastException != null && !isRetryableException(lastException)) {
return false;
}
return super.canRetry(context);
}

private boolean isRetryableException(Throwable throwable) {
if (throwable instanceof HttpServerErrorException) {
return true;
}
if (throwable instanceof ResourceAccessException) {
return true;
}
if (throwable instanceof HttpClientErrorException e) {
return e.getStatusCode().value() == 409;
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
import com.samhap.kokomen.payment.service.dto.ConfirmRequest;
import com.samhap.kokomen.payment.service.dto.PaymentResponse;
import java.net.SocketTimeoutException;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
Expand All @@ -31,6 +33,7 @@ public class PaymentFacadeService {
private final TosspaymentsTransactionService tosspaymentsTransactionService;
private final TosspaymentsPaymentService tosspaymentsPaymentService;
private final TosspaymentsClient tosspaymentsClient;
private final RetryTemplate tosspaymentsConfirmRetryTemplate;

public PaymentResponse confirmPayment(ConfirmRequest request) {
TosspaymentsPayment tosspaymentsPayment = tosspaymentsPaymentService.saveTosspaymentsPayment(request);
Expand All @@ -40,20 +43,33 @@ public PaymentResponse confirmPayment(ConfirmRequest request) {
} catch (KokomenException | HttpServerErrorException | ResourceAccessException e) {
// inner에서 상태 처리 완료
throw e;
} catch (Exception e) {
} catch (Exception e) {
// 예상치 못한 예외만 NEED_CANCEL 설정
tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL);
throw e;
}
}

private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request, TosspaymentsPayment tosspaymentsPayment) {
private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request,
TosspaymentsPayment tosspaymentsPayment) {
String idempotencyKey = UUID.randomUUID().toString();
try {
TosspaymentsPaymentResponse tosspaymentsConfirmResponse = tosspaymentsClient.confirmPayment(request.toTosspaymentsConfirmRequest());
tosspaymentsPayment.validateTosspaymentsResult(tosspaymentsConfirmResponse.paymentKey(), tosspaymentsConfirmResponse.orderId(),
TosspaymentsPaymentResponse tosspaymentsConfirmResponse = tosspaymentsConfirmRetryTemplate.execute(
context -> {
if (context.getRetryCount() > 0) {
log.warn("토스페이먼츠 결제 승인 재시도 {}회차, paymentKey = {}",
context.getRetryCount(), request.paymentKey());
}
return tosspaymentsClient.confirmPayment(request.toTosspaymentsConfirmRequest(),
idempotencyKey);
});
tosspaymentsPayment.validateTosspaymentsResult(tosspaymentsConfirmResponse.paymentKey(),
tosspaymentsConfirmResponse.orderId(),
tosspaymentsConfirmResponse.totalAmount());
TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment);
tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.COMPLETED);
TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(
tosspaymentsPayment);
tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult,
PaymentState.COMPLETED);
return tosspaymentsConfirmResponse;
} catch (HttpClientErrorException e) {
throw handleConfirmClientError(e, tosspaymentsPayment);
Expand All @@ -66,7 +82,8 @@ private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request, Tossp
}
}

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

if ("IDEMPOTENT_REQUEST_PROCESSING".equals(code)) {
log.error("토스 결제 처리 중 상태 지속 (409), paymentKey = {}", tosspaymentsPayment.getPaymentKey());
tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL);
return new InternalServerErrorException(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage(), e);
}

if (TosspaymentsInternalServerErrorCode.contains(code)) {
log.error("토스 결제 실패(서버 원인 400), code = {}, message = {}", code, failure.message());
tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.SERVER_BAD_REQUEST);
Expand All @@ -87,11 +110,13 @@ private RuntimeException handleConfirmClientError(HttpClientErrorException e, To
}

private void handleConfirmServerError(HttpServerErrorException e, TosspaymentsPayment tosspaymentsPayment) {
// TODO: retry
try {
TosspaymentsPaymentResponse tosspaymentsConfirmResponse = e.getResponseBodyAs(TosspaymentsPaymentResponse.class);
TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment);
tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.NEED_CANCEL);
TosspaymentsPaymentResponse tosspaymentsConfirmResponse = e.getResponseBodyAs(
TosspaymentsPaymentResponse.class);
TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(
tosspaymentsPayment);
tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult,
PaymentState.NEED_CANCEL);
} catch (Exception parseException) {
log.warn("토스 5xx 응답 파싱 실패, 상태만 업데이트합니다. paymentId = {}", tosspaymentsPayment.getId(), parseException);
tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL);
Expand All @@ -101,12 +126,10 @@ private void handleConfirmServerError(HttpServerErrorException e, TosspaymentsPa
private void handleConfirmNetworkError(ResourceAccessException e, TosspaymentsPayment tosspaymentsPayment) {
if (e.getRootCause() instanceof SocketTimeoutException socketTimeoutException) {
if (socketTimeoutException.getMessage().contains("Connect timed out")) {
// TODO: retry
tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CONNECTION_TIMEOUT);
return;
}
if (socketTimeoutException.getMessage().contains("Read timed out")) {
// TODO: retry
tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL);
return;
}
Expand All @@ -115,17 +138,20 @@ private void handleConfirmNetworkError(ResourceAccessException e, TosspaymentsPa
}

public void cancelPayment(CancelRequest request) {
TosspaymentsPaymentCancelRequest tosspaymentsPaymentCancelRequest = new TosspaymentsPaymentCancelRequest(request.cancelReason());
TosspaymentsPaymentCancelRequest tosspaymentsPaymentCancelRequest = new TosspaymentsPaymentCancelRequest(
request.cancelReason());
try {
TosspaymentsPaymentResponse response = tosspaymentsClient.cancelPayment(request.paymentKey(), tosspaymentsPaymentCancelRequest);
TosspaymentsPaymentResponse response = tosspaymentsClient.cancelPayment(request.paymentKey(),
tosspaymentsPaymentCancelRequest);
tosspaymentsTransactionService.applyCancelResult(response);
} catch (HttpClientErrorException e) {
Failure failure = e.getResponseBodyAs(Failure.class);
if (failure == null) {
log.error("결제 취소 실패(400) - 응답 파싱 실패, paymentKey: {}", request.paymentKey(), e);
throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage(), e);
}
log.error("결제 취소 실패(400) - paymentKey: {}, code: {}, message: {}", request.paymentKey(), failure.code(), failure.message());
log.error("결제 취소 실패(400) - paymentKey: {}, code: {}, message: {}", request.paymentKey(), failure.code(),
failure.message());
throw new BadRequestException(failure.message(), e);
} catch (HttpServerErrorException e) {
log.error("결제 취소 실패(5xx) - paymentKey: {}, status: {}", request.paymentKey(), e.getStatusCode());
Expand Down
30 changes: 30 additions & 0 deletions internal/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,33 @@ spring:
config:
activate:
on-profile: local
retry:
tosspayments:
max-attempts: 3
initial-interval: 500
multiplier: 2.0
max-interval: 2000
---
# dev profile
spring:
config:
activate:
on-profile: dev
retry:
tosspayments:
max-attempts: 3
initial-interval: 500
multiplier: 2.0
max-interval: 2000
---
# prod profile
spring:
config:
activate:
on-profile: prod
retry:
tosspayments:
max-attempts: 3
initial-interval: 500
multiplier: 2.0
max-interval: 2000
Loading