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

결제 과정에서는 네트워크 장애, 일시적인 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에서 오류 코드 및 메시지 추출
- 응답 데이터에
code와message필드가 있으면 가져옵니다.
- 응답 데이터에
- 클라이언트 오류 여부 확인
- ClientPaymentErrorResponseCode.has(errorCodeString)을 호출하여 클라이언트 오류인지 확인합니다.
- 클라이언트 오류면 ClientPaymentException 발생
- 이 예외는 notryFor에 지정되어 있어 재시도를 하지 않습니다.
- 서버 오류면 PaymentException 발생
- PaymentException은 retryFor에 포함되어 있어 자동으로 재시도됩니다.
결론 및 회고
이번 결제 재시도 기능을 구현하면서 결제 과정의 신뢰성과 사용자 경험을 향상시키는 것이 핵심 목표였습니다.
단순히 결제 API를 호출하는 것이 아니라, 실패 시 재시도를 통해 결제가 성공할 가능성을 높이고,
불필요한 재시도를 방지하여 서버 부하를 최소화하는 전략을 적용했습니다.
Spring의 @Retryable을 활용함으로써 재시도 로직을 간결하게 구현할 수 있었고, 재시도가 필요한 오류와 불가능한 오류를 구분하여 효율저인 결제 실패 대응이 가능하도록 만들었습니다.
'Project' 카테고리의 다른 글
| 결제 시나리오 2. 중복 결제 방지 (0) | 2025.03.17 |
|---|---|
| 결제 시나리오 1. 결제 재시도 - Self-Invocation 문제 (0) | 2025.03.17 |
| (Java / Spring) TossPayments PG 윗젯 Version2 연동 (0) | 2025.02.14 |
| JPA를 활용한 일정 앱 프로젝트 트러블 슈팅 및 회고 (0) | 2024.12.12 |
| 일정 관리 앱 프로젝트 트러블 슈팅 및 회고 (2) | 2024.12.09 |