🚀[트러블슈팅] Toss API 오류 대응을 위한 커스텀 ErrorHandler 적용 및 재시도 제어 개선
2025. 7. 2. 00:57

1. 문제 인식

외부 결제 API인 Toss 호출 시, Toss 서버의 일시적인 오류에 대비하여 @Retryable을 통한 자동 재시도 로직을 구성해두었습니다. 그러나 실제 재시도 된 실패 로그들을 확인해보니, 클라이언트 측 오류인 400번대에 대해서도 재시도가 수행되고 있는 문제를 발견하였습니다.

아래의 테스트 로그를 보면, 400 오류(ALREADY_PROCESSED_PAYMENT) 상황에서도 2회의 재시도가 발생했슴을 확인할 수 있습니다.

이는 사용자의 요청 오류로, 재시도를 하더라도 성공할 수 없는 요청입니다.

이 문제를 통해, 외부 API 에러에 대한 세밀한 분기 처리의 필요성을 깨달았습니다.


2. 원인 분석

현재 @Retryable은 ApiException이 발생하면 재시도하도록 설정되어 있습니다.

하지만 기존 RestTemplate은 기본 ResponseErrorHandler를 사용하고 있어, Toss API로부터 발생한 모든 오류 응답(400/500)을 동일한 예외로 처리하게 됩니다.

즉, Toss에서 400번 에러를 보내도 @Retryable은 이를 500번처럼 인식하고 무조건 재시도하게 되는 것입니다.


3. 해결 전략

1️⃣ Toss API 오류 형식 분석 및 분기 처리

: Toss 응답을 분석하여 400번대 에러는 재시도 대상이 아님을 명확히 분기 처리

2️⃣ Custom ResponseErrorHandler 구현

  • TossApiResponseErrorHandler를 새로 정의하여 RestTemplate의 기본 오류 처리기를 대체
  • 에러 응답의 바디를 파싱하여 Toss의 code 값을 기준으로, 재시도 여부를 정교하게 분기 처리
  • @Retryable이 작동해야 하는 상황에만 예외를 감싸서 재시도 유도
@Slf4j
@RequiredArgsConstructor
public class TossApiResponseErrorHandler implements ResponseErrorHandler {

    private final ObjectMapper objectMapper;

    @Override
    public boolean hasError(ClientHttpResponse response) throws IOException {
        return response.getStatusCode().is4xxClientError() || response.getStatusCode().is5xxServerError();
    }

    @Override
    public void handleError(ClientHttpResponse response) throws IOException {
        String responseBody = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);
        HttpStatusCode statusCode = response.getStatusCode();

        log.error("[Toss ErrorHandler] statusCode: {}, body: {}", statusCode, responseBody);

        String errorCode = null;
        try {
            Map<String, String> parsed = objectMapper.readValue(responseBody, Map.class);
            errorCode = parsed.get("code");
        } catch (Exception e) {
            log.warn("Toss 에러 바디 파싱 실패: {}", e.getMessage());
        }

        if (statusCode.is4xxClientError()) {
            throw new ApiException("[4xx] " + errorCode + ": 재시도 불필요", ErrorType.TOSS_PAYMENT_FAILED);
        }

        throw new ApiException("[Toss API Error] " + statusCode, ErrorType.TOSS_PAYMENT_FAILED);
    }
}

3️⃣ RestTemplate에 커스텀 핸들러 적용

@Bean
public RestTemplate tossRestTemplate(ObjectMapper objectMapper) {
    RestTemplate restTemplate = new RestTemplate();
    restTemplate.setErrorHandler(new TossApiResponseErrorHandler(objectMapper));
    return restTemplate;
}

: Toss 전용 RestTemplate에만 한정적으로 적용하여, 다른 api에 영향을 주지 않고 Toss api 전용 오류 처리 구조를 확립함


4. 개선 효과 및 결과

  • 커스텀 ErrorHandler로 에러별 정교한 분기 처리
    • 불필요한 재시도 제거 -> api 자원 낭비 방지
    • 재시도 정책이 Toss 서버 문제에만 적용되어 정확도 향상
  • UX 개선 및 응답 속도 향상
    • 고객 오류에 대하여 즉시 응답 가능
    • 평균 응답 시간 3초 -> 1초로 단축

5. 회고

이번 예외 분기처리 작업을 통하여 단순히 @Retrybale을 사용하는 것만으로는 외부 API 오류 상황을 충분히 통제할 수 없다는 점을 깨달았습니다. Toss api처럼 다양한 오류 코드와 의미를 갖는 응답을 제공하는 경우, 충분히 분기처리가 가능하기 때문에 무분별한 재시도는 오히려 시스템 리소스를 낭비하고 UX를 악화시키는 원인이 될 수 있다고 느꼈습니다. 토스에서 제공하는 에러 응답 코드를 분석하여 분기처리 한 덕분에 사용자 실수(4XX)에 대해서는 빠르게 실패를 반환하고, 시스템 문제(5XX)에 대해서만 재시도를 수행하도록 하여 재시도 정책을 더 의도적으로 다듬을 수 있었고, 그 결과 불필요한 호출을 줄이고 평균 응답 속도도 3초에서 1초로 단축하는 성과를 얻을 수 있었습니다.

앞으로 외부 시스템과 연동할 때에는 시스템의 안정성을 높이기 위하여 모든 에러 케이스를 사전에 예측하고, 내부 에러 헨들러와 별개로 외부의 에러 핸들러도 따로 관리하며, 각각의 경우에 맞는 정교한 실패 처리 로직을 설계하는데 더욱 주의를 기울이려 합니다.

 

 

LIST