diff --git a/external/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java b/external/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java index 93dca34..6640d32 100644 --- a/external/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java +++ b/external/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java @@ -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); diff --git a/internal/build.gradle b/internal/build.gradle index 36f176a..8b75b39 100644 --- a/internal/build.gradle +++ b/internal/build.gradle @@ -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' diff --git a/internal/src/main/java/com/samhap/kokomen/global/config/RetryConfig.java b/internal/src/main/java/com/samhap/kokomen/global/config/RetryConfig.java new file mode 100644 index 0000000..ecf5275 --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/global/config/RetryConfig.java @@ -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; + } +} diff --git a/internal/src/main/java/com/samhap/kokomen/global/config/TosspaymentsConfirmRetryPolicy.java b/internal/src/main/java/com/samhap/kokomen/global/config/TosspaymentsConfirmRetryPolicy.java new file mode 100644 index 0000000..9cd5083 --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/global/config/TosspaymentsConfirmRetryPolicy.java @@ -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; + } +} diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java index 12a1402..b59a17e 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java @@ -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; @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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; } @@ -115,9 +138,11 @@ 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); @@ -125,7 +150,8 @@ public void cancelPayment(CancelRequest request) { 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()); diff --git a/internal/src/main/resources/application.yml b/internal/src/main/resources/application.yml index a024ef2..26d54e7 100644 --- a/internal/src/main/resources/application.yml +++ b/internal/src/main/resources/application.yml @@ -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 diff --git a/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java b/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java index 079a686..b7d90c6 100644 --- a/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java +++ b/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java @@ -4,6 +4,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.samhap.kokomen.global.BaseTest; @@ -56,7 +58,7 @@ class PaymentFacadeServiceTest extends BaseTest { @Test void 결제_승인에_성공한다() { ConfirmRequest request = createConfirmRequest(); - when(tosspaymentsClient.confirmPayment(any())).thenReturn(createSuccessResponse()); + when(tosspaymentsClient.confirmPayment(any(), any())).thenReturn(createSuccessResponse()); PaymentResponse response = paymentFacadeService.confirmPayment(request); @@ -70,9 +72,10 @@ class PaymentFacadeServiceTest extends BaseTest { void 서버_원인_400_에러가_발생하면_SERVER_BAD_REQUEST_상태로_변경한다() { ConfirmRequest request = createConfirmRequest(); HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getStatusCode()).thenReturn(HttpStatus.BAD_REQUEST); when(clientError.getResponseBodyAs(Failure.class)) .thenReturn(new Failure("INVALID_API_KEY", "잘못된 API 키입니다.")); - when(tosspaymentsClient.confirmPayment(any())).thenThrow(clientError); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(clientError); assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) .isInstanceOf(InternalServerErrorException.class) @@ -86,9 +89,10 @@ class PaymentFacadeServiceTest extends BaseTest { void 클라이언트_원인_400_에러가_발생하면_CLIENT_BAD_REQUEST_상태로_변경한다() { ConfirmRequest request = createConfirmRequest(); HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getStatusCode()).thenReturn(HttpStatus.BAD_REQUEST); when(clientError.getResponseBodyAs(Failure.class)) .thenReturn(new Failure("INVALID_CARD_NUMBER", "카드 번호가 유효하지 않습니다.")); - when(tosspaymentsClient.confirmPayment(any())).thenThrow(clientError); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(clientError); assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) .isInstanceOf(BadRequestException.class); @@ -103,7 +107,7 @@ class PaymentFacadeServiceTest extends BaseTest { HttpServerErrorException serverError = mock(HttpServerErrorException.class); when(serverError.getResponseBodyAs(TosspaymentsPaymentResponse.class)) .thenReturn(createSuccessResponse()); - when(tosspaymentsClient.confirmPayment(any())).thenThrow(serverError); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(serverError); assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) .isInstanceOf(HttpServerErrorException.class); @@ -119,7 +123,7 @@ class PaymentFacadeServiceTest extends BaseTest { HttpServerErrorException serverError = mock(HttpServerErrorException.class); when(serverError.getResponseBodyAs(TosspaymentsPaymentResponse.class)) .thenThrow(new RuntimeException("파싱 실패")); - when(tosspaymentsClient.confirmPayment(any())).thenThrow(serverError); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(serverError); assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) .isInstanceOf(HttpServerErrorException.class); @@ -132,7 +136,7 @@ class PaymentFacadeServiceTest extends BaseTest { @Test void 결제_승인_시_연결_타임아웃이_발생하면_CONNECTION_TIMEOUT_상태로_변경한다() { ConfirmRequest request = createConfirmRequest(); - when(tosspaymentsClient.confirmPayment(any())) + when(tosspaymentsClient.confirmPayment(any(), any())) .thenThrow(new ResourceAccessException("I/O error", new SocketTimeoutException("Connect timed out"))); assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) @@ -145,7 +149,7 @@ class PaymentFacadeServiceTest extends BaseTest { @Test void 결제_승인_시_읽기_타임아웃이_발생하면_NEED_CANCEL_상태로_변경한다() { ConfirmRequest request = createConfirmRequest(); - when(tosspaymentsClient.confirmPayment(any())) + when(tosspaymentsClient.confirmPayment(any(), any())) .thenThrow(new ResourceAccessException("I/O error", new SocketTimeoutException("Read timed out"))); assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) @@ -158,7 +162,7 @@ class PaymentFacadeServiceTest extends BaseTest { @Test void 결제_승인_시_SocketTimeoutException_외_네트워크_오류가_발생하면_NEED_CANCEL_상태로_변경한다() { ConfirmRequest request = createConfirmRequest(); - when(tosspaymentsClient.confirmPayment(any())) + when(tosspaymentsClient.confirmPayment(any(), any())) .thenThrow(new ResourceAccessException("I/O error", new ConnectException("Connection refused"))); assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) @@ -172,8 +176,9 @@ class PaymentFacadeServiceTest extends BaseTest { void 결제_승인_시_400_에러_응답_파싱에_실패하면_InternalServerErrorException을_던진다() { ConfirmRequest request = createConfirmRequest(); HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getStatusCode()).thenReturn(HttpStatus.BAD_REQUEST); when(clientError.getResponseBodyAs(Failure.class)).thenReturn(null); - when(tosspaymentsClient.confirmPayment(any())).thenThrow(clientError); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(clientError); assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) .isInstanceOf(InternalServerErrorException.class) @@ -186,7 +191,7 @@ class PaymentFacadeServiceTest extends BaseTest { @Test void 결제_승인_시_예상치_못한_예외가_발생하면_NEED_CANCEL_상태로_변경한다() { ConfirmRequest request = createConfirmRequest(); - when(tosspaymentsClient.confirmPayment(any())).thenThrow(new RuntimeException("예상치 못한 오류")); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(new RuntimeException("예상치 못한 오류")); assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) .isInstanceOf(RuntimeException.class); @@ -195,6 +200,86 @@ class PaymentFacadeServiceTest extends BaseTest { assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); } + @Test + void 결제_승인_시_5xx_에러_후_재시도에_성공하면_COMPLETED_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpServerErrorException serverError = mock(HttpServerErrorException.class); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(serverError) + .thenReturn(createSuccessResponse()); + + PaymentResponse response = paymentFacadeService.confirmPayment(request); + + assertThat(response.paymentKey()).isEqualTo("payment_key"); + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.COMPLETED); + verify(tosspaymentsClient, times(2)).confirmPayment(any(), any()); + } + + @Test + void 결제_승인_시_연결_타임아웃_후_재시도에_성공하면_COMPLETED_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(new ResourceAccessException("I/O error", new SocketTimeoutException("Connect timed out"))) + .thenReturn(createSuccessResponse()); + + PaymentResponse response = paymentFacadeService.confirmPayment(request); + + assertThat(response.paymentKey()).isEqualTo("payment_key"); + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.COMPLETED); + verify(tosspaymentsClient, times(2)).confirmPayment(any(), any()); + } + + @Test + void 결제_승인_시_409_에러_후_재시도에_성공하면_COMPLETED_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException conflictError = mock(HttpClientErrorException.class); + when(conflictError.getStatusCode()).thenReturn(HttpStatus.CONFLICT); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(conflictError) + .thenReturn(createSuccessResponse()); + + PaymentResponse response = paymentFacadeService.confirmPayment(request); + + assertThat(response.paymentKey()).isEqualTo("payment_key"); + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.COMPLETED); + verify(tosspaymentsClient, times(2)).confirmPayment(any(), any()); + } + + @Test + void 결제_승인_시_클라이언트_400_에러는_재시도하지_않는다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getStatusCode()).thenReturn(HttpStatus.BAD_REQUEST); + when(clientError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("INVALID_CARD_NUMBER", "카드 번호가 유효하지 않습니다.")); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(BadRequestException.class); + + verify(tosspaymentsClient, times(1)).confirmPayment(any(), any()); + } + + @Test + void 결제_승인_시_409_재시도_소진_후_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException conflictError = mock(HttpClientErrorException.class); + when(conflictError.getStatusCode()).thenReturn(HttpStatus.CONFLICT); + when(conflictError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("IDEMPOTENT_REQUEST_PROCESSING", "이전 요청이 처리 중입니다.")); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(conflictError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(InternalServerErrorException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + verify(tosspaymentsClient, times(3)).confirmPayment(any(), any()); + } + @Test void 결제_취소에_성공한다() { TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() diff --git a/internal/src/test/resources/application.yml b/internal/src/test/resources/application.yml index d4d7769..402f876 100644 --- a/internal/src/test/resources/application.yml +++ b/internal/src/test/resources/application.yml @@ -9,3 +9,9 @@ spring: jackson: property-naming-strategy: SNAKE_CASE default-property-inclusion: non_null +retry: + tosspayments: + max-attempts: 3 + initial-interval: 500 + multiplier: 2.0 + max-interval: 2000