본문 바로가기
Spring

feign client 파헤쳐보기

by 권성호 2023. 6. 10.

사실이 아니라 공부한 내용과 생각을 정리한 글입니다. 언제든 가르침을 주신다면 감사하겠습니다.

 

지금까지 feign client를 많이 사용해 왔지만 제대로 알고 사용하고 있다는 생각이 안 들어서 한번 파헤쳐봐야겠다는 생각이 들었다.

feign client 동작에 대한 핵심적인 흐름은 SynchronousMethodHandler의 invoke 메서드와 executeAndDecode 메서드를 보면 알 수 있다.

그전에 먼저 SynchronousMethodHandler에 정의된 필드들을 살펴보자.

  private final MethodMetadata metadata;
  private final Target<?> target;
  private final Client client;
  private final Retryer retryer;
  private final List<RequestInterceptor> requestInterceptors;
  private final Logger logger;
  private final Logger.Level logLevel;
  private final RequestTemplate.Factory buildTemplateFromArgs;
  private final Options options;
  private final Decoder decoder;
  private final ErrorDecoder errorDecoder;
  private final boolean decode404;

내가 feign을 사용하면서 흔하게 등록했던 빈들이 대부분이다.

그렇다. 내가 feign client에 대한 설정으로 등록했던 빈들은 스프링에 의해 SynchronousMethodHandler 가 생성될 때 위에서 언급한 필드에 주입되었다.

그렇게 커스텀하게 정의된 빈이 주입되었고 그 빈을 기반으로 invoke 메서드와 executeAndDecode 메서드가 동작하는 일종의 전략페턴을 사용한 것이다.(전략페턴에 대한 다른 글: https://ksh-dev.tistory.com/62)

그럼 이제 invoke 메서드와 executeAndDecode를 보면서 내가 정의했던 빈들이 실제로 어느 시점에 어떻게 동작하고 있었던 것인지 살펴보자.

 

- invoke 메소드

  @Override
  public Object invoke(Object[] argv) throws Throwable {
    RequestTemplate template = buildTemplateFromArgs.create(argv);
    Retryer retryer = this.retryer.clone();
    while (true) {
      try {
        return executeAndDecode(template);
      } catch (RetryableException e) {
        retryer.continueOrPropagate(e);
        if (logLevel != Logger.Level.NONE) {
          logger.logRetry(metadata.configKey(), logLevel);
        }
        continue;
      }
    }
  }

- executeAndDecode 메서드

  Object executeAndDecode(RequestTemplate template) throws Throwable {
    Request request = targetRequest(template);

    if (logLevel != Logger.Level.NONE) {
      logger.logRequest(metadata.configKey(), logLevel, request);
    }

    Response response;
    long start = System.nanoTime();
    try {
      response = client.execute(request, options);
      // ensure the request is set. TODO: remove in Feign 10
      response.toBuilder().request(request).build();
    } catch (IOException e) {
      if (logLevel != Logger.Level.NONE) {
        logger.logIOException(metadata.configKey(), logLevel, e, elapsedTime(start));
      }
      throw errorExecuting(request, e);
    }
    long elapsedTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);

    boolean shouldClose = true;
    try {
      if (logLevel != Logger.Level.NONE) {
        response =
            logger.logAndRebufferResponse(metadata.configKey(), logLevel, response, elapsedTime);
        // ensure the request is set. TODO: remove in Feign 10
        response.toBuilder().request(request).build();
      }
      if (Response.class == metadata.returnType()) {
        if (response.body() == null) {
          return response;
        }
        if (response.body().length() == null ||
                response.body().length() > MAX_RESPONSE_BUFFER_SIZE) {
          shouldClose = false;
          return response;
        }
        // Ensure the response body is disconnected
        byte[] bodyData = Util.toByteArray(response.body().asInputStream());
        return response.toBuilder().body(bodyData).build();
      }
      if (response.status() >= 200 && response.status() < 300) {
        if (void.class == metadata.returnType()) {
          return null;
        } else {
          return decode(response);
        }
      } else if (decode404 && response.status() == 404 && void.class != metadata.returnType()) {
        return decode(response);
      } else {
        throw errorDecoder.decode(metadata.configKey(), response);
      }
    } catch (IOException e) {
      if (logLevel != Logger.Level.NONE) {
        logger.logIOException(metadata.configKey(), logLevel, e, elapsedTime);
      }
      throw errorReading(request, response, e);
    } finally {
      if (shouldClose) {
        ensureClosed(response.body());
      }
    }
  }

위 코드를 기반으로 그간 무지성으로 사용해 왔던 feign 설정 빈의 동작 방식을 파헤쳐보자


RequestInterceptor - 요청을 커스텀하기

executeAndDecode의 시작 부분에서 targetRequest 메서드를 호출하고 있는데, 코드를 까보면 아래와 같다.

  Object executeAndDecode(RequestTemplate template) throws Throwable {
    Request request = targetRequest(template);
 	// ....생략   
}
  Request targetRequest(RequestTemplate template) {
    for (RequestInterceptor interceptor : requestInterceptors) {
      interceptor.apply(template);
    }
    return target.apply(new RequestTemplate(template));
  }

targetRequet 메서드에서 등록된 requestInterceptor를 모두 순회하면서 apply 메서드를 하나씩 실행하는 것을 확인할 수 있다.

apply 메서드는 RequestTemplate을 인자로 받고 있기 때문에 메서드 내부에서 RequestTemplate을 조작할 수 있다.

즉, feign의 RequestInterceptor 설정 빈을 등록하여, 요청을 커스텀할 수 있는 기능을 끼워 넣을 수 있다. 대표적으로 요청 헤더에 특정 값을 세팅하는 행위를 끼워 넣을 수 있다.

Logger & LoggingLevel - 로깅 커스텀하기

invoke 메서드와 executeAndDecode 메서드에 정의된 로직이 실행되면서 발생할 수 있는 여러 상황에 대한 로깅을 위해 feign 은 Logger 클래스를 정의했다.

Logger 클래스는 아래 4개의 메서드를 가지고 있다.

  1. logRequest : 요청을 로깅하기 위한 목적
  2. logAndRebufferResponse: 응답을 로깅하기 위한 목적
  3. logRetry : 재시도 상황을 로깅하기 위한 목적
  4. logIoException: 예외 발생 상황을 로깅하기 위한 목적
  5. log: logRequest & logRetry & logAndRebufferResponse & logIoException에서 호출되고 있는 재정의 가능한 메서드

또한 feign의 자체적인 로깅 레벨도 Level 이넘을 통해 정의해 두었다. (NONE, BASIC, HEADERS, FULL)

다시 invoke 메서드와 executeAndDecode로 돌아와 보면, Level에 따라 Logger에서 정의한 로깅 메서드를 실행할지 말지 결정하는 코드를 볼 수 있다.

대표적으로 Logger.Level.NONE 이 아닐 때, 요청&응답을 로깅하기 위해 logRequest와 logAndRebufferResponse 메서드를 호출하는 부분을 살펴보자.

  Object executeAndDecode(RequestTemplate template) throws Throwable {
	// ... 생략
    if (logLevel != Logger.Level.NONE) {
      logger.logRequest(metadata.configKey(), logLevel, request);
    }

	// ..... 생략
    try {
      if (logLevel != Logger.Level.NONE) {
        response =
            logger.logAndRebufferResponse(metadata.configKey(), logLevel, response, elapsedTime);
        // ensure the request is set. TODO: remove in Feign 10
        response.toBuilder().request(request).build();
      }
    // .... 생략  
  }

feign 설정을 통해 Logger level을 BASIC으로 등록하고 Logger를 상속받아 logRequest와 logAndRebufferResponse를 재정의한 커스텀 Logger를 등록하면, 요청과 응답에 대한 로깅을 커스텀할 수 있다. (참고로 feign Logger의 디폴트 로깅레벨은 NONE이다.)

이런 식으로 Logger level을 변경하고 Logger를 상속받아 메서드를 재정의한 빈을 등록하여, 요청 & 응답 & 재시도 & 예외 상황에 대한 로깅을 커스텀할 수 있다.

ErrorDecoder & decode404 - 오류처리를 커스텀하기

executeAndDecode메서드의 하단 로직을 보면 응답을 해석하는 로직을 확인할 수 있다.

      if (response.status() >= 200 && response.status() < 300) {
        if (void.class == metadata.returnType()) {
          return null;
        } else {
          return decode(response);
        }
      } else if (decode404 && response.status() == 404 && void.class != metadata.returnType()) {
        return decode(response);
      } else {
        throw errorDecoder.decode(metadata.configKey(), response);
      }

응답 상태 코드가 200대가 아닌 경우 기본적으로 errorDecoder.decode 메서드를 호출하고, 예외적으로 decode404 가 true 일 경우만 404 응답코드는 errorDecoder.decode 메서드를 호출하지 않는다.  (decode404는 기본값이 true 이기 때문에 feign 은 기본적으로 404 응답코드는 정상적인 응답으로 간주하는 것 같다.)

decode404를 재정의 함으로써 404 응답에 대한 처리를 커스텀할 수 있고 errorDecoder를 재정의한 빈을 등록함으로써 오류처리를 커스텀할 수 있다.

Option - 설정을 커스텀하기

executeAndDecode 메서드를 따라가다보면 실제로 통신을 위해 clinet.execute 메소드를 호출하는 부분을 찾을 수 있다.

  Object executeAndDecode(RequestTemplate template) throws Throwable {
    // ... 생략
    try {
      response = client.execute(request, options);
      // ensure the request is set. TODO: remove in Feign 10
      response.toBuilder().request(request).build();
    } catch (IOException e) {
      if (logLevel != Logger.Level.NONE) {
        logger.logIOException(metadata.configKey(), logLevel, e, elapsedTime(start));
      }
      // ... 생략
  }

feign에서 디폴트로 정의되는 Client의 execute 메서드를 따라가 보자.

    @Override
    public Response execute(Request request, Options options) throws IOException {
      HttpURLConnection connection = convertAndSend(request, options);
      return convertResponse(connection).toBuilder().request(request).build();
    }

convertAndSend 메서드를 따라가 보면 Options의 connectTimeoutMillis와 readTimeoutMillis를 connection 설정에 세팅해 주는 부분을 찾을 수 있다.

    HttpURLConnection convertAndSend(Request request, Options options) throws IOException {
      // .. 생략
      connection.setConnectTimeout(options.connectTimeoutMillis());
      connection.setReadTimeout(options.readTimeoutMillis());
      connection.setAllowUserInteraction(false);
      connection.setInstanceFollowRedirects(true);
      connection.setRequestMethod(request.method());
      //... 생략
  }

즉, Option 빈을 재정의함으로써 feign의 connectTimeout과 readTimeout을 커스텀할 수 있다.

 

그박에 커스텀 요소들

executeAndDecode 메서드를 따라가다보면 위에서 언급한 요소들 말고도 커스텀 할 수 있는 많은 요소들이 열려있는 것을 알 수 있다. 

예를들어 재시도 정책과 관련한 부분을 Retryer 를 통해 커스텀 할 수 있고, 응답에 대한 해석을 Decode 를 통해 커스텀 할 수 있어보인다.

필요에 따라 적절한 커스텀 포인트를 찾아 커스텀하면 될 것 같다.


참고자료

댓글