본문 바로가기
카테고리 없음

[springboot, msa] Feign Client에 발생한 Exception 처리하기

by demonic_ 2021. 7. 7.
반응형

MSA를 구성하는데 A서버에서 User 서버를 호출하는데 Feign을 쓰기로 했다. 이때 가장 고민했던게

1) 데이터 주고받는 형식을 어떻게 할 것인지

2) 에러가 발생할 경우 관련 정보를 어떻게 받을 것인지

에 대해 고민했다. 이번글은 2번, 에러처리에 대한 데이터유형을 어떻게 정의할것인지에 대한 글이다.

 

참고로 이것은 정답이 아닌 이런 방법도 있구나 하는 정도로 보면 좋겠다.

결론만 말하면 User서버에는 에러시 에러코드와 메세지를 담아 JSON형태로 리턴하고, A서버에서는 이것을 받아 파싱하여 리턴(Request 한 주체)에게 전달하기로 했다.

그럼 시작.

 

A서버는 Feign 호출을 통해 User서버를 호출하는데 User서버에서 에러가 발생했을때 아무것도 조치하지 않으면 다음과 같은 표기가 된다.

 

[UserClient#testException()]: [{"timestamp":"2021-07-06T21:41:37.293+00:00","status":500,"error":"Internal Server Error","trace":"java.lang.Exception: 임의 에러 발생\n\tat com.kr.msa.aserver.controller.UserController.userTestException(User... (9446 bytes)]

 

위의 메세지는 ControllerAdvice에서 ExceptionHandler를 설정한 곳에서 찍힌 로그다. 먼가 주렁주렁 달고 온 느낌인데, 일단 넘어가고 이번엔 FeignExceptionHandler 를 등록해보겠다.

// 여기는 AServer의 ControllerAdvice
@Slf4j
@ControllerAdvice(basePackages = "com.kr.msa.aserver")
public class AServerControllerAdvice {
...
    @ExceptionHandler(FeignException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ResponseBody
    public String feignExceptionHandler(FeignException ex) {
        ex.printStackTrace();
        return ex.getMessage();
    }
...

 

에러메세지를 살펴보면 다음처럼 접속정보에 대한 상세한 내용들이 나온다.

[500] during [GET] to [http://localhost:9021/msa/user/test/exception] [UserClient#testException()]: [{"timestamp":"2021-07-06T21:49:22.273+00:00","status":500,"error":"Internal Server Error","trace":"java.lang.Exception: 임의 에러 발생\n\tat com.kr.msa.user.controller.UserController.userTestException(User... (9446 bytes)]

 

 

보면 호출한 url 경로라든가, 에러유형, 그리고 내용들이 한데 뒤섞여서 나오는데 이것을 Request한 주체에게 그대로 보여줄 순 없는 노릇이다. 여기서 '임의 에러 발생'이라는 문구를 제외한 것을 모두 걷어내야 중간과정을 거치지 않고 자동으로 에러문구를 리턴할 것이다.

 

그럼 이때 해야할 것이 2가지다. 첫번째는 대상이 되는 서버(User 서버)에서 에러가 발생시 리턴할 때 Json 형태로 리턴하게 할 것. 그리고 호출하는 A서버에서 받아 파싱하면서 불필요한 정보를 제거하는 것이다.

 

그럼 User 서버를 먼저 수정해보도록 하자.

 

여기서의 설정은 간단하다. ControllerAdvice과 에러리턴 유형을 이용해 리턴 Form을 정해두면 된다.

 

@Getter
public class ResponseErrorForm {
    private String code;
    private String message;

    public ResponseErrorForm(UserErrorCode errorCode, String message) {
        this.code = errorCode.name();
        this.message = message;
    }
}

 

여기서 Error코드는 이 프로젝트에서 Error 를 다음과 같은 enum으로 관리하기 떄문에 있는 것이며, 에러코드를 관리하지 않는 곳이라면 제거하고 message만 넣어도 무방하다.

중요한 것은 예외가 발생할 경우 이 유형으로 리턴을 할것이라는 점이다.

 

그럼 에러를 캐치할 ControllerAdvice를 다음과 같이 설정한다.

// 여기는 User서버의 ControllerAdvice
@RestControllerAdvice(basePackages = "com.kr.msa.user")
public class UserControllerAdvice {

    @ExceptionHandler(UserException.class)
    @ResponseStatus(INTERNAL_SERVER_ERROR)
    public ResponseErrorForm UserExceptionHandler(UserException e) {
        e.printStackTrace();

        return new ResponseErrorForm(e.getErrorCode(), e.getMessage());
    }


    @ExceptionHandler(Exception.class)
    @ResponseStatus(INTERNAL_SERVER_ERROR)
    public ResponseErrorForm ExceptionHandler(Exception e) {
        e.printStackTrace();

        return new ResponseErrorForm(UserErrorCode.E9999, e.getMessage());
    }
}

 

UserException 은 User 서버에서 임의로 만든 커스텀Exception 이고, Exception 으로 에러가 날때는 에러코드를 E9999 라는 형태로 코드를 입력하고 리턴하게 했다.

 

이제 Exception이 발생하면 반드시 이 둘중 하나를 거치게 될 것이다.

 

User서버에서는 이정도로 마무리하면 된다. 그럼 다시 호출해보자.

[500] during [GET] to [http://localhost:9021/msa/user/test/exception] [UserClient#testException()]: [{"code":"E9999","message":"임의 에러 발생"}]

 

이젠 메세지가 JSON 형태로 내려온다. 그러나 여전히 Http 관련 메세지가 많다. 이제는 A서버에서 설정을 추가하여 모두 다 걷어내고 JSON만 남기고 객체로 파싱하겠다.

 

우선 FeignException 을 커스텀하게 하나 만든다. 앞으로 Feign 관련 Exception이 발생 시 이 클래스에서 담아 리턴할 것이다.

public class FeignClientException extends RuntimeException{
    private final int status;
    private final String errorMessage;

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

    // 에러코드와 메세지를 정형화하기 위해 만듬
    private final CustomFeignErrorForm errorForm;


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

    /**
     * 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;
    }

    public CustomFeignErrorForm getErrorForm() {
        return errorForm;
    }
}

 

http status와 에러메세지 본문(String), 그리고 header(담겨온 헤더정보) 와 errorForm은 Json으로 오는 에러메세지 본문을 파싱하여 담을 객체다. CustFeignErrorForm 클래스는 다음과 같다.

@Getter
@NoArgsConstructor
public class CustomFeignErrorForm {
    private String code;
    private String message;

    public CustomFeignErrorForm(String code, String message) {
        this.code = code;
        this.message = message;
    }
}

 

그럼 Feign 으로 통신한 결과를 어떻게 여기에 담을까? 그건 바로 feign 에 있는 ErrorDecoder를 구현, 등록함으로써 가능하다. FeignClientExceptionErrorDecoder.java 파일을 만든다.

// 아래 설정은 AServer에서 해야할 설정
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kr.msa.aserver.config.api.feign.dto.CustomFeignErrorForm;
import feign.Response;
import feign.codec.ErrorDecoder;
import feign.codec.StringDecoder;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;

@Slf4j
public class FeignClientExceptionErrorDecoder implements ErrorDecoder {
    private ObjectMapper objectMapper = new ObjectMapper();
    private StringDecoder stringDecoder = new StringDecoder();


    @Override
    public FeignClientException decode(String methodKey, Response response) {
        String message = null;
        CustomFeignErrorForm errorForm = null;
        if (response.body() != null) {
            try {
                message = stringDecoder.decode(response, String.class).toString();
                errorForm = objectMapper.readValue(message, CustomFeignErrorForm.class);
            } catch (IOException e) {
                log.error(methodKey + "Error Deserializing response body from failed feign request response.", e);
            }
        }
        return new FeignClientException(response.status(), message, response.headers(), errorForm);
    }
}

 

Response를 message로 변형한 후 ObjectMapper를 이용해 JSON을 CustomFeignErrorForm으로 파싱했다. 그럼 message 안에 어떤 내용이 담기는지 확인해보자.

StringDecoder를 이용해 본문내용만 가져올 수 있다. 일전에 봤던 로그에서는 접속정보, 에러유형등 다 보였던 것에 반해 메세지만 가져오게 된다.

 

StringDecoder를 보면 다음과 같이 구성되어 있는데, 여기서 body만 가져와 리턴하는 것을 알 수 있다. retrofit 등과 같은 API연동 라이브러리에 익숙한 사람이라면 Response.body 가 익숙할 것이다.

public class StringDecoder implements Decoder {

  @Override
  public Object decode(Response response, Type type) throws IOException {
    Response.Body body = response.body();
    if (body == null) {
      return null;
    }
    if (String.class.equals(type)) {
      return Util.toString(body.asReader(Util.UTF_8));
    }
    throw new DecodeException(response.status(),
        format("%s is not a type supported by this decoder.", type), response.request());
  }
}

 

아무튼 받은 JSON 형태는 String 형태이므로 ObjectMapper 로 매핑하여 객체에 담았다.

 

그럼 이것을 Spring Bean에 등록해야 한다.(아직 이것을 등록하지 않았기 때문에 여기까지만 세팅한다 해서 바뀌는건 없다)

내 경우 Feign 설정도 같이할겸 한곳에 넣어두었다.

// 아래 설정은 AServer에서 해야할 설정
@Configuration
@EnableFeignClients(basePackages = "com.kr.msa.aserver")
@Import(AServerFeignConfiguration.class)
public class FeignConfiguration {

    @Bean
    public FeignClientExceptionErrorDecoder commonFeignErrorDecoder() {
        return new FeignClientExceptionErrorDecoder();
    }
}

 

마지막으로 임의로 만든 FeignClientException.class 를 ControllerAdvice에 추가해주도록 하자.

// 여기는 AServer의 ControllerAdvice
@Slf4j
@ControllerAdvice(basePackages = "com.kr.msa.aserver")
public class AServerControllerAdvice {
...
    @ExceptionHandler(FeignException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ResponseBody
    public String feignExceptionHandler(FeignException ex) {
        ex.printStackTrace();
        return ex.getMessage();
    }

    @ExceptionHandler(FeignClientException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ResponseBody
    public ResultForm FeignClientExceptionHandler(FeignClientException ex) {
        ex.printStackTrace();
        log.error("feign error status: " + ex.getStatus());
        if(ex.getStatus() == 401) {
            return new CustomFeignErrorForm("E401"
                    , "인증에 실패했습니다");
        }

        return ex.getErrorForm();
    }
...

 

FeignClientExceptionHandler 를 추가했다. 중간에 보면 401 에러에 대한 처리를 별도로 했는데, MSA라 하더라도 아무 인증없이 오픈해버리면 좀 위험하니 서로 인증하는 것을 넣어놨는데 인증에 실패할 경우 캐치하도록 한 것이다.

여기서는 FeignClientException 에 있는 errorForm 을 그대로 리턴하지만, 서버상황에 따라 커스텀 하는 것도 괜찮다. 에러가 날 경우 다음처럼 Response를 받는다.

{
    "code": "E9999",
    "message": "임의 에러 발생"
}

 

만약 에러코드에 따라 각기 다른 방식으로 처리 & 리턴해야 한다면 try catch문으로 해당 호출문을 감싼다음 FeignClientException 에 대한 후처리를 하면 된다.

 

 

끝.

 

 

참고:

https://engineering-skcc.github.io/msa/jhipster-feign/

 

Jhipster&Spring 예외처리 2편

MSA에서 외부 서비스와 통신할 때에 쓰이는 Feign! 하지만 Feign의 예외처리는 다른 예외처리 방식보다는 조금 더 복잡하고 어렵습니다. 그렇다면 어떻게 Feign Exception을 다뤄야할까요?

engineering-skcc.github.io

 

반응형

댓글