1. 🤔왜 커스텀 예외 햄들러가 필요한가?
Spring의 RestTemplate은 기본적으로 HTTP 4xx/5xx 응답을 받으면 RestClientException을 던진다.
하지만 Toss와 같은 외부 API는 에러 응답의 바디에 의미 있는 정보(code, message)를 담는 경우가 많다.
나의 경우엔 400번대 에러와 500번대 에러를 각각 구분하여 재처리(retry) 대상으로 삼을지 구분하고 싶었다.
- 400에러 : 재시도 불필요 (ex. 사용자의 잘못된 요청)
- 500에러 : 재시도 대상 (ex. Toss의 서버 장애)
이때 ResponseErrorHandler를 구현하여 RestTemplate에 설정하면, 에러 응답에 대한 세밀한 제어가 가능하다.
2. Toss API용 커스텀 예외 핸들러 구현
@Slf4j
@RequiredArgsConstructor
public class TossApiResponseErrorHandler implements ResponseErrorHandler {
private final ObjectMapper objectMapper;
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
// 4xx, 5xx인 경우만 에러로 간주
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);
// Toss 에러 JSON 파싱
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()) {
// 4xx 에러는 재시도 로직 불필요
throw new ApiException("[4xx] " + errorCode + ": 재시도 불필요", ErrorType.TOSS_PAYMENT_FAILED);
}
// 5xx는 재시도 하도록 @Retryable과 연동
throw new ApiException("[Toss API Error] " + statusCode, ErrorType.TOSS_PAYMENT_FAILED);
}
}
- 스프링의 ResponseErrorHandler를 상속받은 커스텀 에러 핸들러
- hasError() : 4xx, 5xx 번대 응답만 에러로 인식하도록
- handleError() : hasError() 메서드를 통과하면, 응답 바디의 `code`를 파싱하여 로그 및 예외 처리
- ApiException : 커스텀 예외를 던져서 retray 여부 제어
3. RestTemplate에 적용 (Bean 등록)
@Configuration
public class TossRestTemplateConfig {
@Bean
public RestTemplate tossRestTemplate(ObjectMapper objectMapper) {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setMessageConverters(List.of(new MappingJackson2HttpMessageConverter()));
// 에러 핸들러 설정
restTemplate.setErrorHandler(new TossApiResponseErrorHandler(objectMapper));
return restTemplate;
}
}
- 생성자에 ObjectMapper를 주입
- RestTemplate은 @Bean으로 등록하여 스프링 컨테이너에 관리되도록 설정함.
- Toss API 응답 시, 위에서 정의한 `TossApiResponseErrorHandler`가 자동으로 작동함
4. RestTemplate 사용 예 (의존성 주입)
@Component
@RequiredArgsConstructor
public class TossPaymentClient {
@Qualifier("tossRestTemplate")
private final RestTemplate restTemplate; // RestTemplate 빈
}
- @Qualifier로 tossRestTemplate을 명시해야 원하는 @Bean이 주입됨
- 여러 종류의 RestTemplate이 있을 경우 명확한 구분을 위해 필수
6. ✨ ErrorHandler와 ExceptionHandler의 차이 구분하기
Spring에서는 ErrorHandler와 ExceptionHandler가 처리하는 에러의 출처와 목적이 다르기 때문에, 명확히 구분하여 설계하고 명명하는 것이 중요하다.
- ErrorHandler
- 처리 대상 : 시스템 또는 외부 연동 중 발생한 HTTP 수준의 에러 (ex. 4xx, 5xx)
- 적용 위치 : RestTemplate에 설정하여 응답 검사
- 역할 : 외부 API 응답을 검사하고 예외로 변환
- 예시 클래스 : `TossApiResponseErrorHandler`, `RestTemplateResponseErrorHandler`
- ExceptionHandler
- 처리 대상 : 내부 애플리케이션 로직에서 발생한 Java 예외 (ex. NullPointerException, IllegalArgumentException)
- 적용 위치 : @ControllerAdvice에 등록하여 전역 예외 처리
- 역할 : 내부에서 발생한 예외를 사용자에게 일관된 응답 포맷 제공
- 예시 클래스 : `GlobalExceptionHandler`, `CustomExceptionHandler`
5. 💭회고 : 커스텀 예외 핸들러를 직접 구현해보며
이번에 Toss API와의 연동 과정에서, 단순히 RestTemplate의 기본 예외 처리 방식에 의존하는 것으로는 충분하지 않다고 느꼈다. Toss API는 에러 응답에도 JSON 포맷으로 유의미한 정보(code, message)를 반환하기 때문에, 이를 무시한 채 동일한 형태의 RestClientException만 던져서는 문제 원인을 파악하거나, 재시도 여부를 판단하기가 어려웠다.
그래서 ResponseErrorHandler를 직접 구현하여, 400번대와 500번대 응답을 분기 처리하고, 응답 바디를 파싱하여 로깅 및 예외 메시지에 포함하는 방식으로 개선했다. 특히 400 응답은 사용자 잘못이므로 재시도하지 않고 종료할 수 있도록, 그리고 500 응답은 Toss 서버의 일시적 문제일 수 있어서 계획한대로 @Retryable 매서드가 작동하도록 유도했다.
이 과정에서 RestTemplate을 커스터마이징한 후 @Bean으로 등록하고, @Qualifier로 정확히 주입받아야 원하는 핸들러가 적용된 RestTemplate을 사용할 수 있다는 것도 함께 배웠다. 이번 리팩토링은 예외처리를 분기처리하는 것 뿐만 아니라, 외부 api의 안정성을 높이기 위한 설계라는 점에서 나에게 의미있었다.
- 참고 자료 : https://www.baeldung.com/spring-rest-template-error-handling
'👩🏻💻Project' 카테고리의 다른 글
| 🚀[트러블슈팅] Toss API 오류 대응을 위한 커스텀 ErrorHandler 적용 및 재시도 제어 개선 (2) | 2025.07.02 |
|---|---|
| 🔐Spring Security가 적용된 Controller 단위 테스트에서 403 에러 해결 (1) | 2025.07.01 |
| 🔄️Spring Boot에서 재시도 로직 간단하게 구현하기 - Spring Retry 사용법 (0) | 2025.06.24 |
| 🚈실시간 기차 예매 서비스 프로젝트의 Docker 관련 트러블슈팅 (0) | 2025.05.26 |
| [JWT] 커스텀 필터와 Resolver 기반의 인증 구조를 Spring Security로 리팩토링하기! (0) | 2025.05.11 |