본문 바로가기

Project

결제 시나리오 1. 결제 재시도 구현

배경

결제 파트를 맡으면서 단순히 결제 연동을 구현하는 것보다 사용자의 편의성과 안정성을 높이는 것이 더 중요한 문제라는 것을 인지했다.
결제는 돈과 직결되기 때문에 실패 시 적절한 대응이 필요했고 그래서 생각한 방안은 결제 시나리오 중 하나인 결제 재시도 구현이었습니다.

결제 과정에서는 네트워크 장애, 일시적인 PG사 장애, API 연결 TimeOut 등 다양한 이유로 결제가 실패할 수 있는데 이때는 무조건 실패처리 하는 것이 아니라 재시도를 통해 결제가 성공할 수 있도록 보장하는 것이 사용자 경험 개선의 핵심 요소라고 생각했습니다.

하지만 모든 실패 상황에서 무조건 재시도를 하면 불필요한 리소스 낭비와 서버 부하가 발생할 수 있다고 판단 하여 재시도가 필요한 경우와 불가능한 경우를 구분하여 적절한 조치를 취했습니다.


선택지

기능 선택한 기술  고려한 대안
결제 실패 재시도 @Retryable Spring Scheduler 활용, 직접 재시도 로직 구현

 

왜 @Retryable을 선택했나?

  • @Scheduler는 실시간 응답이 어렵고, 직접 재시도 로직을 구현하는 것은 코드가 복잡해지고 유지보수가 어려움이 있습니다.
  • @Retryable은 간단한 설정만으로도 재시도 로직을 적용할 수 있고, 트랜잭션과의 연계도 가능하기 때문에 가장 적합하다고 판단했습니다.

구현 기능 및 주요 로직

  • 결제 승인 또는 취소 요청이 실패할 경우, 일정 횟수까지 자동으로 재시도
  • 불필요한 재시도를 방지하기 위해서 재시도가 필요한 오류와 불가능한 오류를 구분하여 적용
@Retryable(
         maxAttempts = 3,
         backoff = @Backoff(delay = 2000, multiplier = 2),
         retryFor = { PaymentException.class, CustomException.class },
         noRetryFor = ClientPaymentException.class
)
public JsonNode approvePayment(String paymentKey, String orderId, int amount) throws Exception {
     String url = "https://api.tosspayments.com/v1/payments/confirm";
     JSONObject jsonObject = createApprovalRequestJson(paymentKey, orderId, amount);

     try {
         HttpResponse<String> response = executePost(url, jsonObject.toString());
         log.info("Payment executed successfully: {}", response.body());

         JsonNode responseJson = objectMapper.readTree(response.body());
         handlingPaymentError(responseJson);
         return responseJson;
     } catch (HttpStatusCodeException | ResourceAccessException e) {
         throw new CustomException(ServerErrorResponseCode.NETWORK_ERROR);
     } catch (Exception e) {
         log.error("Payment failed: {}\n Error Trace: {}", e.getMessage(), e.getStackTrace());
         throw e;
     }
 }
private void handlingPaymentError(JsonNode responseJson){
        String errorCodeString = "";
        String errorMessage = "";
        if(responseJson.get("code") != null) {
            errorCodeString = responseJson.get("code").asText();
            errorMessage = responseJson.get("message").asText();
        }

        Optional<ClientPaymentErrorResponseCode> clientEx = ClientPaymentErrorResponseCode.has(errorCodeString);
        if (!errorCodeString.isBlank()) {
            if (clientEx.isPresent()) {
                throw new ClientPaymentException(clientEx.get());
            } else {
                PaymentErrorResponseCode paymentFailed = PaymentErrorResponseCode.setPaymentFailedMessage(errorMessage);
                throw new PaymentException(paymentFailed);
            }
        }
    }

1. @Retryable 주요 설정

  • maxAttempts = 3 → 최대 3번까지 재시도합니다.
  • backoff = @Backoff(delay = 2000, multiplier = 2)
    • 처음 재시도는 2초 후에 실행됩니다.
    • 두 번째 재시도는 4초(2초 × 2), 세 번째 재시도는 8초(4초 × 2) 후에 실행됩니다.
    • 즉 점점 증가하는 방식(지수 백오프)으로 재시도 간격을 늘려 서버 부하를 줄이도록 설계되었습니다.
  • retryFor = { PaymentException.class, CustomException.class }
    • PaymentException 또는 CustomException이 발생하면 재시도합니다.
    • 예를 들어 TossPayments에서 보내주는 "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요." 또는 서비스에서 발생하는 서버 오류 같은 경우 는 재시도하면 성공할 가능성이 있기 때문입니다.
  • noRetryFor = ClientPaymentException.class 
    • ClientPaymentException이 발생하면 재시도하지 않습니다.
    • 예를 들어 “잔액 부족”, “유효하지 않은 카드 정보” 같은 문제는 재시도해도 성공할 가능성이 없기 때문입니다.

2. approvePayment() 주요 동작

  • 최종 승인 요청
    • executePost(url, jsonObject.toSting())를 통해 TossPayments 측에 최종 승인 요청을 보냅니다.
    • 만약 TossPayments 측에서 오류를 보낼 시 Json 형태로 Message와 Code를 보내줍니다.
  • 오류 처리
    • 응답 데이터를 JsonNode로 변환 후, handlingPaymentError()를 호출하여 결제 오류 여부를 판단합니다.
  • 예외 처리
    • HttpStatusCodeException, ResourceAccessException 발생 시 CustomException을 던져서 재시도를 유도합니다.
    • 기타 예외 발생 시 로그를 기록하고 예외를 다시 던집니다.

3. handlingPaymentError 주요 동작

  • 응답 JSON에서 오류 코드 및 메시지 추출
    • 응답 데이터에 codemessage 필드가 있으면 가져옵니다.
  • 클라이언트 오류 여부 확인
    • ClientPaymentErrorResponseCode.has(errorCodeString)을 호출하여 클라이언트 오류인지 확인합니다.
  • 클라이언트 오류면 ClientPaymentException 발생
    • 이 예외는 notryFor에 지정되어 있어 재시도를 하지 않습니다.
  • 서버 오류면 PaymentException 발생
    • PaymentException은 retryFor에 포함되어 있어 자동으로 재시도됩니다.

결론 및 회고

이번 결제 재시도 기능을 구현하면서 결제 과정의 신뢰성과 사용자 경험을 향상시키는 것이 핵심 목표였습니다.
단순히 결제 API를 호출하는 것이 아니라, 실패 시 재시도를 통해 결제가 성공할 가능성을 높이고,
불필요한 재시도를 방지하여 서버 부하를 최소화하는 전략을 적용했습니다.

Spring의 @Retryable을 활용함으로써 재시도 로직을 간결하게 구현할 수 있었고, 재시도가 필요한 오류와 불가능한 오류를 구분하여 효율저인 결제 실패 대응이 가능하도록 만들었습니다.