From 2dc2685a5206f58a8111cd07f3e036d6c33691f3 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 12 Feb 2026 17:25:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EA=B2=B0=EC=A0=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/external/TosspaymentsClient.java | 3 +- internal/build.gradle | 1 + .../kokomen/global/config/RetryConfig.java | 26 +++++ .../TosspaymentsConfirmRetryPolicy.java | 36 ++++++ .../payment/service/PaymentFacadeService.java | 21 +++- .../service/PaymentFacadeServiceTest.java | 105 ++++++++++++++++-- 6 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 internal/src/main/java/com/samhap/kokomen/global/config/RetryConfig.java create mode 100644 internal/src/main/java/com/samhap/kokomen/global/config/TosspaymentsConfirmRetryPolicy.java 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..944ad0b --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/global/config/RetryConfig.java @@ -0,0 +1,26 @@ +package com.samhap.kokomen.global.config; + +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 { + + @Bean + public RetryTemplate tosspaymentsConfirmRetryTemplate() { + RetryTemplate retryTemplate = new RetryTemplate(); + + TosspaymentsConfirmRetryPolicy retryPolicy = new TosspaymentsConfirmRetryPolicy(3); + retryTemplate.setRetryPolicy(retryPolicy); + + ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy(); + backOffPolicy.setInitialInterval(500); + backOffPolicy.setMultiplier(2.0); + backOffPolicy.setMaxInterval(2000); + 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..07608c7 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); @@ -48,8 +51,15 @@ public PaymentResponse confirmPayment(ConfirmRequest request) { } private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request, TosspaymentsPayment tosspaymentsPayment) { + String idempotencyKey = UUID.randomUUID().toString(); try { - TosspaymentsPaymentResponse tosspaymentsConfirmResponse = tosspaymentsClient.confirmPayment(request.toTosspaymentsConfirmRequest()); + TosspaymentsPaymentResponse tosspaymentsConfirmResponse = tosspaymentsConfirmRetryTemplate.execute(context -> { + if (context.getRetryCount() > 0) { + log.warn("토스페이먼츠 결제 승인 재시도 {}/2회, paymentKey = {}", + context.getRetryCount(), request.paymentKey()); + } + return tosspaymentsClient.confirmPayment(request.toTosspaymentsConfirmRequest(), idempotencyKey); + }); tosspaymentsPayment.validateTosspaymentsResult(tosspaymentsConfirmResponse.paymentKey(), tosspaymentsConfirmResponse.orderId(), tosspaymentsConfirmResponse.totalAmount()); TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment); @@ -75,6 +85,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,7 +103,6 @@ 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); @@ -101,12 +116,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; } 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() From 0682088f137a80e69734ac89572938e964cda7b7 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 12 Feb 2026 17:36:58 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=ED=99=98=EA=B2=BD=20=EB=B3=80=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kokomen/global/config/RetryConfig.java | 21 ++++++-- .../payment/service/PaymentFacadeService.java | 51 ++++++++++++------- internal/src/main/resources/application.yml | 30 +++++++++++ internal/src/test/resources/application.yml | 6 +++ 4 files changed, 85 insertions(+), 23 deletions(-) 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 index 944ad0b..ecf5275 100644 --- a/internal/src/main/java/com/samhap/kokomen/global/config/RetryConfig.java +++ b/internal/src/main/java/com/samhap/kokomen/global/config/RetryConfig.java @@ -1,5 +1,6 @@ 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; @@ -8,17 +9,29 @@ @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(3); + TosspaymentsConfirmRetryPolicy retryPolicy = new TosspaymentsConfirmRetryPolicy(maxAttempts); retryTemplate.setRetryPolicy(retryPolicy); ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy(); - backOffPolicy.setInitialInterval(500); - backOffPolicy.setMultiplier(2.0); - backOffPolicy.setMaxInterval(2000); + backOffPolicy.setInitialInterval(initialInterval); + backOffPolicy.setMultiplier(multiplier); + backOffPolicy.setMaxInterval(maxInterval); retryTemplate.setBackOffPolicy(backOffPolicy); return retryTemplate; 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 07608c7..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 @@ -43,27 +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 = tosspaymentsConfirmRetryTemplate.execute(context -> { - if (context.getRetryCount() > 0) { - log.warn("토스페이먼츠 결제 승인 재시도 {}/2회, paymentKey = {}", - context.getRetryCount(), request.paymentKey()); - } - return tosspaymentsClient.confirmPayment(request.toTosspaymentsConfirmRequest(), idempotencyKey); - }); - 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); @@ -76,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); @@ -104,9 +111,12 @@ private RuntimeException handleConfirmClientError(HttpClientErrorException e, To private void handleConfirmServerError(HttpServerErrorException e, TosspaymentsPayment tosspaymentsPayment) { 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); @@ -128,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); @@ -138,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/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