1. 개요
마이크로 서비스 간의 HTTP API 호출에 가끔 오류가 발생할 것으로 예상됩니다.
OpenFeign 이 포함된 Spring Boot 에서 기본 오류 처리기는 Not Found 와 같은 다운스트림 오류 를 내부 서버 오류 로 전파합니다 . 이것은 오류를 전달하는 가장 좋은 방법이 아닙니다. 그러나 Spring과 OpenFeign 모두 자체 오류 처리를 제공할 수 있습니다.
이 문서에서는 기본 예외 전파가 작동하는 방식을 살펴보겠습니다. 또한 자체 오류를 제공하는 방법도 배웁니다.
2. 기본 예외 전파 전략
Feign 클라이언트는 어노테이션 및 구성 속성을 사용하여 마이크로 서비스 간의 상호 작용을 간단하고 고도로 구성 가능하게 만듭니다. 그러나 랜덤의 기술적 이유, 잘못된 사용자 요청 또는 코딩 오류로 인해 API 호출이 실패할 수 있습니다.
다행스럽게도 Feign과 Spring에는 오류 처리를 위한 합리적인 기본 구현이 있습니다.
2.1. Feign의 기본 예외 전파
Feign은 ErrorDecoder를 사용 합니다 . 오류 처리를 위한 기본 클래스입니다. 이를 통해 Feign은 2xx가 아닌 상태 코드를 수신할 때마다 이를 ErrorDecoder의 디코딩 메서드로 전달합니다. decode 메서드 는 HTTP 응답 에 Retry-After 헤더가 있는 경우 RetryableException을 반환하고 그렇지 않으면 FeignException 을 반환 합니다. 재시도할 때 기본 재시도 횟수 이후에 요청이 실패하면 FeignException 이 반환됩니다.
decode 메서드 는 HTTP 메서드 키와 응답 을 FeignException 에 저장합니다 .
2.2. 스프링 레스트 컨트롤러의 기본 예외 전파
RestController 가 처리되지 않은 예외 를 수신 할 때마다 클라이언트에 500 내부 서버 오류 응답을 반환합니다.
또한 Spring은 타임스탬프, HTTP 상태 코드, 오류 및 경로와 같은 세부 정보가 포함된 잘 구성된 오류 응답을 제공합니다.
{
"timestamp": "2022-07-08T08:07:51.120+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/myapp1/product/Test123"
}
예제를 통해 이에 대해 자세히 살펴보겠습니다.
3. 적용 예
다른 외부 서비스에서 제품 정보를 반환하는 간단한 마이크로서비스를 구축해야 한다고 상상해 봅시다.
먼저 몇 가지 속성을 사용 하여 Product 클래스를 모델링해 보겠습니다 .
public class Product {
private String id;
private String productName;
private double price;
}
그런 다음 Get Product Endpoints 을 사용하여 ProductController 를 구현해 보겠습니다 .
@RestController("product_controller")
@RequestMapping(value ="myapp1")
public class ProductController {
private ProductClient productClient;
@Autowired
public ProductController(ProductClient productClient) {
this.productClient = productClient;
}
@GetMapping("/product/{id}")
public Product getProduct(@PathVariable String id) {
return productClient.getProduct(id);
}
}
다음으로 Feign Logger 를 Bean 으로 등록하는 방법을 살펴보겠습니다 .
public class FeignConfig {
@Bean
Logger.Level feignLoggerLevel() {
return Logger.Level.FULL;
}
}
마지막으로 외부 API와 인터페이스 하기 위해 ProductClient 를 구현해 보겠습니다.
@FeignClient(name = "product-client", url="http://localhost:8081/product/", configuration = FeignConfig.class)
public interface ProductClient {
@RequestMapping(value = "{id}", method = RequestMethod.GET")
Product getProduct(@PathVariable(value = "id") String id);
}
이제 위의 예를 사용하여 기본 오류 전파를 살펴보겠습니다.
4. 기본 예외 전파
4.1. WireMock 서버 사용
실험하려면 모의 프레임워크를 사용하여 호출하는 서비스를 시뮬레이션해야 합니다.
먼저 WireMockServer Maven 의존성을 포함해 보겠습니다.
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-jre8</artifactId>
<version>2.33.2</version>
<scope>test</scope>
</dependency>
그런 다음 WireMockServer 를 구성하고 시작 하겠습니다 .
WireMockServer wireMockServer = new WireMockServer(8081);
configureFor("localhost", 8081);
wireMockServer.start();
WireMockServer 는 Feign 클라이언트가 사용하도록 구성된 동일한 호스트 및 포트 에서 시작됩니다.
4.2. Feign 클라이언트의 기본 예외 전파
Feign의 기본 오류 핸들러인 ErrorDecoder.Default 는 항상 FeignException 을 발생시킵니다 .
WireMock.stubFor 를 사용하여 getProduct 메서드를 모의하여 사용할 수 없는 것처럼 보이도록 합시다.
String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
.willReturn(aResponse()
.withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));
assertThrows(FeignException.class, () -> productClient.getProduct(productId));
위의 테스트 사례에서 ProductClient 는 다운스트림 서비스에서 503 오류가 발생하면 FeignException 을 발생시킵니다.
다음으로 동일한 실험을 시도하되 404 Not Found 응답을 사용합니다.
String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
.willReturn(aResponse()
.withStatus(HttpStatus.NOT_FOUND.value())));
assertThrows(FeignException.class, () -> productClient.getProduct(productId));
다시 말하지만 일반적인 FeignException 이 발생합니다 . 이 상황에서 아마도 사용자는 잘못된 것을 요청했고 Spring 애플리케이션은 다르게 처리할 수 있도록 잘못된 사용자 요청임을 알아야 합니다.
FeignException 에는 HTTP 상태 코드를 포함 하는 상태 속성이 있지만 try / catch 전략은 속성이 아닌 유형을 기반으로 예외를 라우팅합니다.
4.3. 스프링 레스트 컨트롤러의 기본 예외 전파
이제 FeignException 이 요청자에게 다시 전파되는 방법을 살펴보겠습니다.
ProductController 가 ProductClient 에서 FeignException 을 가져오면 이를 프레임워크에서 제공하는 기본 오류 처리 구현으로 전달합니다.
제품 서비스를 사용할 수 없는 경우를 가정해 보겠습니다.
String productId = "test";
stubFor(WireMock.get(urlEqualTo("/product/" + productId))
.willReturn(aResponse()
.withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));
mockMvc.perform(get("/myapp1/product/" + productId))
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()));
여기에서 우리는 Spring INTERNAL_SERVER_ERROR 를 얻는 것을 볼 수 있습니다 . 서비스 오류마다 다른 결과가 필요할 수 있으므로 이 기본 동작이 항상 최선은 아닙니다.
5. ErrorDecoder를 사용하여 Feign 에서 사용자 지정 예외 전파
항상 기본 FeignException 을 반환하는 대신 HTTP 상태 코드를 기반으로 일부 애플리케이션별 예외를 반환해야 합니다.
사용자 지정 ErrorDecoder 구현 에서 decode 메서드를 재정의해 보겠습니다 .
public class CustomErrorDecoder implements ErrorDecoder {
@Override
public Exception decode(String methodKey, Response response) {
switch (response.status()){
case 400:
return new BadRequestException();
case 404:
return new ProductNotFoundException("Product not found");
case 503:
return new ProductServiceNotAvailableException("Product Api is unavailable");
default:
return new Exception("Exception while getting product details");
}
}
}
사용자 지정 디코딩 방법에서는 실제 문제에 대한 더 많은 컨텍스트를 제공하기 위해 몇 가지 응용 프로그램별 예외와 함께 다른 예외를 반환합니다. 또한 애플리케이션별 예외 메시지에 더 자세한 정보를 포함할 수 있습니다.
decode 메서드는 FeignException을 throw하는 대신 반환 한다는 점 에 유의 해야 합니다 .
이제 FeignConfig 에서 CustomErrorDecoder 를 Spring Bean 으로 구성해 보겠습니다 .
@Bean
public ErrorDecoder errorDecoder() {
return new CustomErrorDecoder();
}
또는 ProductClient 에서 CustomErrorDecoder 를 직접 구성할 수 있습니다 .
@FeignClient(name = "product-client-2", url = "http://localhost:8081/product/",
configuration = { FeignConfig.class, CustomErrorDecoder.class })
그런 다음 CustomErrorDecoder 가 ProductServiceNotAvailableException 을 반환 하는지 확인합니다 .
String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
.willReturn(aResponse()
.withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));
assertThrows(ProductServiceNotAvailableException.class,
() -> productClient.getProduct(productId));
다시, 제품이 없을 때 ProductNotFoundException 을 확인하는 테스트 사례를 작성해 보겠습니다 .
String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
.willReturn(aResponse()
.withStatus(HttpStatus.NOT_FOUND.value())));
assertThrows(ProductNotFoundException.class,
() -> productClient.getProduct(productId));
이제 Feign 클라이언트에서 다양한 예외를 제공하고 있지만 Spring은 예외를 모두 포착할 때 여전히 일반적인 내부 서버 오류를 생성 합니다. 이것은 우리가 원하는 것이 아니므로 어떻게 개선할 수 있는지 봅시다.
6. 스프링 레스트 컨트롤러 에서 사용자 지정 예외 전파
본 것처럼 기본 Spring Boot 오류 핸들러는 일반적인 오류 응답을 제공합니다. API 소비자는 관련 오류 응답과 함께 자세한 정보가 필요할 수 있습니다. 이상적으로는 오류 응답이 문제를 설명하고 디버깅에 도움이 될 수 있어야 합니다.
여러 가지 방법으로 RestController 의 기본 예외 처리기를 재정의할 수 있습니다 .
RestControllerAdvice 어노테이션 으로 오류를 처리하는 이러한 접근 방식 중 하나를 살펴보겠습니다 .
6.1. @RestControllerAdvice 사용
@ RestControllerAdvice 어노테이션을 사용하면 여러 예외를 단일 전역 오류 처리 구성 요소로 통합할 수 있습니다.
ProductController 가 다운스트림 오류를 기반으로 다른 사용자 정의 오류 응답을 반환해야 하는 시나리오를 상상해 봅시다 .
먼저 ErrorResponse 클래스를 만들어 오류 응답을 사용자 지정합니다.
public class ErrorResponse {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd-MM-yyyy hh:mm:ss")
private Date timestamp;
@JsonProperty(value = "code")
private int code;
@JsonProperty(value = "status")
private String status;
@JsonProperty(value = "message")
private String message;
@JsonProperty(value = "details")
private String details;
}
이제 ResponseEntityExceptionHandler 를 하위 클래스로 만들고 @ExceptionHandler 어노테이션을 오류 핸들러와 함께 포함시키 겠습니다.
@RestControllerAdvice
public class ProductExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler({ProductServiceNotAvailableException.class})
public ResponseEntity<ErrorResponse> handleProductServiceNotAvailableException(ProductServiceNotAvailableException exception, WebRequest request) {
return new ResponseEntity<>(new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR,
exception.getMessage(),
request.getDescription(false)),
HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler({ProductNotFoundException.class})
public ResponseEntity<ErrorResponse> handleProductNotFoundException(ProductNotFoundException exception, WebRequest request) {
return new ResponseEntity<>(new ErrorResponse(
HttpStatus.NOT_FOUND,
exception.getMessage(),
request.getDescription(false)),
HttpStatus.NOT_FOUND);
}
}
위의 코드에서 ProductServiceNotAvailableException 은 클라이언트에 대한 INTERNAL_SERVER_ERROR 응답으로 반환됩니다 . 반대로 ProductNotFoundException 과 같은 사용자별 오류 는 다르게 처리 되며 NOT_FOUND 응답 으로 반환됩니다 .
6.2. 스프링 레스트 컨트롤러 테스트
제품 서비스를 사용할 수 없을 때 ProductController 를 테스트해 보겠습니다 .
String productId = "test";
stubFor(WireMock.get(urlEqualTo("/product/" + productId))
.willReturn(aResponse()
.withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));
MvcResult result = mockMvc.perform(get("/myapp2/product/" + productId))
.andExpect(status().isInternalServerError()).andReturn();
ErrorResponse errorResponse = objectMapper.readValue(result.getResponse().getContentAsString(), ErrorResponse.class);
assertEquals(500, errorResponse.getCode());
assertEquals("Product Api is unavailable", errorResponse.getMessage());
다시, 동일한 ProductController 를 테스트 하지만 제품을 찾을 수 없음 오류가 발생합니다.
String productId = "test";
stubFor(WireMock.get(urlEqualTo("/product/" + productId))
.willReturn(aResponse()
.withStatus(HttpStatus.NOT_FOUND.value())));
MvcResult result = mockMvc.perform(get("/myapp2/product/" + productId))
.andExpect(status().isNotFound()).andReturn();
ErrorResponse errorResponse = objectMapper.readValue(result.getResponse().getContentAsString(), ErrorResponse.class);
assertEquals(404, errorResponse.getCode());
assertEquals("Product not found", errorResponse.getMessage());
위의 테스트는 ProductController 가 다운스트림 오류에 따라 다른 오류 응답을 반환하는 방법을 보여줍니다.
CustomErrorDecoder 를 구현하지 않았다면 RestControllerAdvice 는 일반 오류 응답을 받기 위한 폴백 으로 기본 FeignException 을 처리하는 데 필요합니다.
7. 결론
이 기사에서는 기본 오류 처리가 Feign 및 Spring에서 구현되는 방법을 살펴보았습니다 .
또한 CustomErrorDecoder 를 사용하여 Feign 클라이언트에서 그리고 RestControllerAdvice 를 사용 하여 Rest Controller에서 이를 사용자 정의하는 방법을 살펴보았습니다 .
항상 그렇듯이 이러한 모든 코드 예제는 GitHub 에서 찾을 수 있습니다 .