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 LoggerBean 으로 등록하는 방법을 살펴보겠습니다 .

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  에서 CustomErrorDecoderSpring  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 에서 찾을 수 있습니다 .

Generic footer banner