Jhipster&Spring 예외처리 2편

Updated:

Feign Client가 쓰이는 이유

Feign Exception의 구현에 대해 알아보기 전에 왜 Feign Client가 쓰이는지 알아보도록 하겠습니다.

Microservice Architecture는 모놀리식 방식과 다르게 여러 개의 core service가 각기 다른 애플리케이션으로 구성됩니다. 따라서, 다른 core service의 데이터나 기능이 필요한 경우 동기 호출 또는 비동기 호출을 사용합니다.

동기 통신 방식과 비동기 통신 방식의 차이점은 말 그대로, 동기는 요청 후 응답이 올때까지 서비스가 대기하고 있는 것이고 비동기는 요청 후 응답을 기다리지 않습니다. 따라서, 동기 호출방식은 결제 서비스와 같이 외부 서비스의 응답이 비즈니스 로직에 포함되어 반드시 응답을 받아야하는 경우에 쓰이며, 비동기 호출방식은 가입 후 가입환영 이메일을 보내는 등의 타 서비스에게 보내기만 하면 되는 경우에 주로 쓰입니다.(물론, 비동기 호출방식에서도 결과를 받아보도록하여 호출이 실패한 경우 다시 전송하도록 구현하는 것이 가장 바람직합니다.)

이때, 동기 통신 방식에서는 주로 Feign Client 방식을 사용하고 비동기 통신 방식엔 Kafka를 사용합니다.

Feign Client의 동기 호출이 실패한 경우 어떻게 될까?

Feign Client가 어떤 것이고 언제 쓰이는 지 알아보았으니 이제 본론으로 들어가볼까요?

Feign Client 호출이 실패했을 때, 예외처리를 하지 않는다면 어떻게 될까요?

예를 들어 아래 그림과 같은 상황을 상상해보죠.

위 그림의 시나리오는 다음과 같습니다.

  1. 사용자의 도서 연체료가 쌓여있어 대여를 할 수 없는 상황입니다.
  2. 사용자는 도서 연체료 결제를 외부 서비스에 요청합니다.
  3. 하지만 사용자의 포인트가 연체료보다 적어 결제가 실패하였습니다.
  4. 결제 실패 에러(결과)를 사용자에게 전달합니다.
  5. 사용자는 Internal Error 500을 전달받습니다.
  6. 사용자는 왜 결제가 실패한지 모르게 됩니다.

물론 사용자가 자신의 잔여 포인트가 얼마인지 확인해보고 잔여 포인트 부족으로 결제가 실패했음을 나중에 확인할 수도 있습니다. 하지만 사용자에게 Internal Error 500과 같은 원인을 알 수 없는 에러메세지를 보여주는 것은 분명 지양해야할 것 입니다.

그렇다면 결제 서비스가 에러 메세지를 전달하게 하면 되지 않을까요??

네, 그래도 결과는 동일합니다. 왜냐하면 feign 의 경우 200 <= response status < 300 일때만 정상이라고 판단하며 이외의 값에 대해서는 모두 Internal Error 500으로 처리합니다. (단, feign.client.config.default.decode404 가 true 이면, 404 도 정상으로 포함됩니다.)

그럼 왜 에러메세지를 해석하지 못하냐구요? 바로 Error Decoder가 없기 때문입니다.

따라서, Feign Client의 에러 메세지를 받아 사용자에게 해당 메세지를 전달해주기 위해서는 Error Decoder를 구현해야만 합니다.

Notice MSA프로젝트이며 Feign Client를 사용하면서도 Feign Client의 요청 실패 전략으로 쓰이는 Circuit Breaker를 구현하지 않은 이유는 무엇인가요? 라는 의문이 생길 텐데요, 제가 현재 구현한 프로그램은 MSA 구현을 위한 간단한 Sample 프로젝트로, 정말 최~대한 알기 쉽고 따라하기 쉽고 이해하기 쉬운 개념과 코드로 구현해야 했습니다. 하지만, Hystrix를 활용한 Circuit Breaker같은 경우 적용하기에 약간 높은&복잡한 난이도 라고 생각하여 그 하위버전인 Error Decoder를 활용하여 예외처리를 하고자 했습니다. 참고 부탁드려요~!

Feign Exception을 구현해보자

FeignClientException.java 구현하기

먼저 다른 Custom 예외처리와 마찬가지로 Feign Exception을 위한 클래스를 만들어야합니다.
Feign Exception은 1편에서 구현한 `RentUnavailable.java`와는 다른 구조로 아래와 같습니다.

```java
public class FeignClientException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    private final int status;
    private final String errorMessage;

    private final Map<String, Collection<String>> headers;

    public FeignClientException(Integer status, String errorMessage, Map<String, Collection<String>> headers) {
        super(errorMessage);
        this.status = status;
        this.errorMessage = errorMessage;
        this.headers = headers;

    }
    /**
    * Http Status Code
    * @return
    */
    public Integer getStatus() {
        return status;
    }

    public String getErrorMessage() {
        return errorMessage;
    }

    /**
    * FeignResponse Headers
    * @return
    */
    public Map<String, Collection<String>> getHeaders() {
        return headers;
    }
}
```
먼저 살펴볼 점은 status와 errorMessage는 기존의 customException과 동일하나 Header의 정보가 map과 같은 형식으로 담겨있음을 알 수 있습니다.

Feign에서 던지는 Exception 또한 외부서비스로부터의 웹 Response이기 때문에 이는 Json 형식으로 이루어져있고 Json형식의 ResponsEntity 를 Decode하기 위해 위와 같이 구현하였습니다.

FeignClientExceptionErrorDecoder.java 구현하기

이제 위에서 던진 FeignClientException을 Decode하기 위한 Decoder를 구현해야합니다. 위에서 언급하였듯이 Feign에서 던지는 응답은 Json형식의 Response입니다. 그것이 비록 에러일지라도요. 따라서, Decoder는 Json을 String으로 변환 -> String을 Decode하는 과정을 거쳐 Response의 body에 담긴 메세지를 가져옵니다.


public class FeignClientExceptionErrorDecoder implements ErrorDecoder {
    private static final Logger LOGGER = LoggerFactory.getLogger(FeignClientExceptionErrorDecoder.class);
    private StringDecoder stringDecoder = new StringDecoder();

    @Override
    public  FeignClientException decode(final String methodKey, Response response) {
        String message = "Null Response Body.";
        if (response.body() != null) {
            try {
                JSONObject jsonObject = new JSONObject(response.body().toString());
                message = jsonObject.getString("message");
                message = stringDecoder.decode(response, String.class).toString();
            } catch (IOException | JSONException e) {
                LOGGER.error(methodKey + "Error Deserializing response body from failed feign request response.", e);
            }
        }
        return new FeignClientException(response.status(), message, response.headers());
    }
}

이때 implements ErrorDecoder가 눈에 띄이실텐데요, ErrorDecoder는 기본적으로 Feign Client Library에서 제공하는 클래스입니다. Feign에서 던지는 Error를 format화 해주는 클래스라고 보면 됩니다. 예를 들어 timestamp의 날짜를 YY:MM:dd 로 변경해줍니다.

따라서, 지금 우리가 구현하는 Decoder는 기본적인 ErrorDecoder를 Customizing하는 과정입니다. 모든 예외처리가 그렇듯, 기본적으로 제공되는 Exception 처리로는 부족하니 말입니다.

Feign Configuration에 선언

FeignException을 위한 세팅과정은 여기가 끝입니다! 이후엔 기존의 예외처리방식과 동일하죠.

FeignConfiguration 클래스에 위에서 구현한 FeignClientExceptionErrorDecoder를 @Bean으로 등록해주어 FeignClientException이 발생하였을때 FeignClientExceptionErrorDecoder가 동작하도록 해줍니다.

@Configuration
@EnableFeignClients(basePackages = "com.skcc.rental")
@Import(FeignClientsConfiguration.class)
public class FeignConfiguration {

    /**
     * Set the Feign specific log level to log client REST requests.
     */
    @Bean
    feign.Logger.Level feignLoggerLevel() {
        return feign.Logger.Level.BASIC;
    }

    @Bean
    @ConditionalOnMissingBean(value = ErrorDecoder.class)
    public FeignClientExceptionErrorDecoder commonFeignErrorDecoder() {
        return new FeignClientExceptionErrorDecoder();
    }
}

Controller와 @ControllerAdvice 세팅하기

자, 그럼 외부 서비스에서 에러가 발생했을 때에 어떻게 에러를 Throw할까요?

우선, 404 에러를 제외한 모든 Exception을 Decode하기 위해 Controller에서는 try-catch전략을 사용했습니다.

@RestController
@RequestMapping("/api")
public class RentalResource {

...(중략)...
    @PutMapping("/rentals/release-overdue/user/{userId}")
    public ResponseEntity releaseOverdue(@PathVariable("userId") Long userId)  {
        ...(중략.)..
        try{
            userClient.usePoint(latefeeDTO);

        }catch (FeignClientException e){
            if (!Integer.valueOf(HttpStatus.NOT_FOUND.value()).equals(e.getStatus())) {
                throw e;
            }
        }
        RentalDTO rentalDTO = rentalMapper.toDto(rentalService.releaseOverdue(userId));
        return ResponseEntity.ok().body(rentalDTO);


    }
}

먼저 userClient.usePoint(..)로 결제를 요청한 뒤, Exception이 발생하면 404에러가 아닌경우 catch문을 통해 에러를 throw합니다.

Throw된 에러는 위에서 구현한 FeignClientExceptionErrorDecoder를 통해 외부 서비스에서 보낸 에러메세지를 Decode합니다. Decode에 성공하면 Decode한 메세지와 상태값, 헤더를 받아 FeignClientException 객체를 생성하여 리턴하는 데요, 리턴된 객체는 1편에서 소개한 @ControllerAdvice에 의해 처리됩니다.

@ControllerAdvice
public class ExceptionTranslator implements ProblemHandling, SecurityAdviceTrait {
    ...(중략)...
    @ExceptionHandler(FeignClientException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ResponseEntity<ApiErrorDetails> handleReleaseOverdueUnavailableException(FeignClientException fce){
        ApiErrorDetails errorDetails = new ApiErrorDetails();
        errorDetails.setTimeStamp(LocalDateTime.now());
        errorDetails.setCode(1001);
        errorDetails.setMessage(fce.getErrorMessage());
        return new ResponseEntity(errorDetails, HttpStatus.BAD_REQUEST);
    }
}

외부 서비스에서 보내는 에러 메세지 세팅하기

샘플 프로젝트인 도서 대여 프로그램은 Rental 서비스에서 연체 해제를 요청받기 때문에, 사용자가 연체료 결제를 요청하면 userClient.usePoint로 동기 호출을 하게됩니다.

이때 userClient는 Gateway의 컨트롤러로 전달되는데 그 이유는 Jhipster는 Gateway에 User관리기능을 포함하고 있기 때문입니다. 따라서 Point 결제 기능이 있는 Gateway의 User 서비스가 이를 처리합니다.

아래는 포인트 결제가 이루어지는 메소드입니다.


public User usePoints(int points) throws UsePointsUnavailableException{
        if(this.point>=points) {
            ...생략..
        }else{
            throw new UsePointsUnavailableException("잔여 포인트가 모자라 결제가 불가능 합니다.");
        }
    }

포인트 결제가 실패하면 UsePointsUnavailableException이라는 1편에서 소개한 방식의 Custom Exception에 메세지를 “잔여 포인트가 모자라 결제가 불가능합니다.”로 설정하여 에러를 전달합니다.

생성된 에러는 1편의 소개대로 @ControllerAdvice에 의해 처리되어 포인트결제를 요청한 Rental 서비스로 Response를 보냅니다.

결과 살펴보기

  1. 사용자가 연체료 결제를 신청합니다.
  2. 연체료는 2000, 잔여는 1000포인트 이기 때문에 “잔여 포인트가 모자라 결제가 불가능합니다.”라는 에러 메세지가 전달됩니다.

짜잔, 성공입니다!

정리

서비스 내부의 예외처리는 Spring에서 제공하는 @ControllerAdvice, @ExceptionHandler 등으로 Custom처리가 가능합니다. 또한 처리된 에러메세지를 사용자에게 보낼 수 있습니다.

하지만 외부 서비스의 에러는 ErrorDecoder없이는 외부 서비스에서 보낸 구체적인 에러가 무엇인지 알 수 없으며 상태값만으로 예외처리하는 것은 외부서비스에서 보내는 에러가 다양해질수록 어떤 문제인지 알 수 없어지는 한계에 부딪치기 때문에 지양해야합니다. 단순히 “문제가 발생하였습니다!”로 문제 상황에 대해 사용자를 설득시킬 순 없으니까요.

따라서 Feign Client 사용시에는 기존의 ErrorDecoder를 커스터마이징하여 외부 서비스에서 어떠한 문제가 있었는지 상세하게 확인해야하며, 사용자에게 보내도 바람직한 메세지로 전달되어야합니다.

Feign Exception에 관하여 찾아보면 정말 다양한 방법들이 소개되어있는데 생각보다 정말 간단한 방법도 많이 나오긴 합니다! 하지만, 제가 구현한 Jhipster 프로젝트에서는 위와 같은 방법 이외에는 통하지 않았습니다. (눙물..) 있다면 댓글로 알려주세요..

Jhipster에서 예외처리를 고민하시는 분들, MSA 구현을 하며 Feign Exception에 대해 고민하시는 분들께 도움이 되었으면 좋겠네요 :)

감사합니다!