Java

OpenFeign 알아보기

재심 2024. 8. 30. 15:04

목차

    [OpenFeign이란]

    Open Feign은 Netflix에 의해 처음 만들어진 Declarative(선언적인) HTTP Client 도구.

    Netflix OSS가 공개되고 나서 Spring Cloud 진영은 Spring Cloud Netflix라는 프로젝트로 Netflix OSS를 Spring Cloud 생태계로 포함시켰는데, Feign은 단독으로 사용될 수 있도록 별도의 starter로 제공되었다.
    이후에 넷플릭스는 내부적으로 feign의 사용 및 개발을 중단하기로 결정하였고, OpenFeign이라는 새로운 이름과 함께 오픈소스 커뮤니티로 넘겨졌다.

     

    참고자료

     

    [OpenFeign에서 사용할 수 있는 HttpClient]

    OpenFeign에서는 내부적으로 사용할 수 있는 HttpClient를 설정할 수 있도록 지원하고 있다.

    기본값으로두면 내부적으로 sun.net.www.http.HttpClient 클래스를 사용하게 된다.

    • sun.net.www.http.HttpClient: 자바에서 제공하는 기본 내장 HttpClient
    • okHttp: Square에서 개발한 HttpClient 라이브러리. 안드로이드 진영에서 많이 사용한다고 함
    • Apache HtttpClient: 현재 5버전까지 나와있으며 HTTP/2 지원, 비동기 응답처리 등을 지원한다고 함. 많이 사용하는 라이브러리 중 하나라고 한다. 

     

    [설정값 의미]

    • maxConnectionsPerRoute: 특정 호스트(route)에 대해 설정할 수 있는 최대 연결 수를 지정합니다. 하나의 호스트에 대해 여러 개의 연결이 필요할 때 이 옵션을 통해 최대 연결 수를 제한할 수 있습니다.
    • maxConnections: 전체 클라이언트가 유지할 수 있는 최대 연결 수를 지정합니다. 이는 단일 호스트가 아닌 전체 호스트를 대상으로 설정됩니다.

     

    [Configuration]

    application.yaml 파일에 명시된 옵션 값은 FeignAutoConfiguration, FeignHttpClientProperties 2개 클래스에 의해 초기화

    @Configuration(
      proxyBeanMethods = false
    )
    @ConditionalOnClass({Feign.class})
    @EnableConfigurationProperties({FeignClientProperties.class, FeignHttpClientProperties.class, FeignEncoderProperties.class})
    public class FeignAutoConfiguration {
      private static final Log LOG = LogFactory.getLog(FeignAutoConfiguration.class);
      @Autowired(
        required = false
      )
     
    ...생략
    @ConfigurationProperties(
      prefix = "spring.cloud.openfeign.httpclient"
    )
    public class FeignHttpClientProperties {
      public static final boolean DEFAULT_DISABLE_SSL_VALIDATION = false;
      public static final int DEFAULT_MAX_CONNECTIONS = 200;
      public static final int DEFAULT_MAX_CONNECTIONS_PER_ROUTE = 50;
      public static final long DEFAULT_TIME_TO_LIVE = 900L;
      public static final TimeUnit DEFAULT_TIME_TO_LIVE_UNIT;
      public static final boolean DEFAULT_FOLLOW_REDIRECTS = true;
      public static final int DEFAULT_CONNECTION_TIMEOUT = 2000;
      public static final int DEFAULT_CONNECTION_TIMER_REPEAT = 3000;
      private boolean disableSslValidation = false;
      private int maxConnections = 200;
      private int maxConnectionsPerRoute = 50;
      private long timeToLive = 900L;
    ...생략

     

    구동될 때 로그

    Creating shared instance of singleton bean 'spring.cloud.openfeign.httpclient-org.springframework.cloud.openfeign.support.FeignHttpClientProperties'
    Creating shared instance of singleton bean 'org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration$DifferentManagementContextConfiguration'
    Creating shared instance of singleton bean 'childManagementContextInitializer'

     

    [SynchronousMethodHandler]

    Feign 클라이언트의 요청을 동기적으로 처리하는 역할을 함. 

    인터페이스에 선언된 내용을 실제 HTTP 요청으로 변환하여 요청을 전송한 후 응답을 받아서 호출 결과로 반환한다고 한다. 

     

    invoke 메서드에서 요청을 생성한다. 

    **RequestTemplate: HTTP Request 구성

    **Retryer:  요청 실패 시 재시도 하기 위한 설정

    public Object invoke(Object[] argv) throws Throwable {
      RequestTemplate template = this.buildTemplateFromArgs.create(argv);
      Request.Options options = this.findOptions(argv);
      Retryer retryer = this.retryer.clone();

     

    RequestTemplate 클래스도 Feign에서 정의한 것으로 Request를 날릴 때 사용하는 것으로 보인다.

    디버깅해보면 Request를 위한 정보들이 들어가 있다. 

     

    Retryer를 보면 maxAttemps같은 필드들이 보인다. 

    HTTP 요청 시 invoke 메서드가 호출되고, 실제로 HTTP 요청을 보내고 받는 부분은 executeAndDecode 메서드이다.

     

    executeAndDecode에서는 request를 만들고 client를 통해 execute (실제 HTTP 요청을 보냄) 한 후 response를 받는 역할을 하는 듯하다. 

    targetRequest에는 파라미터로 앞서 만든 RequestTemplate을 넘겨서 하나의 Request 객체를 만든다. (Request도 Feign에서 정의한 클래스이다) 

     

     

    Client도 마찬가지로 Feign에서 정의한 인터페이스인데 구현체가 3개 있다. 

    별다른 설정을 하지 않으면 Default가 사용된다.

     

    디버깅 시 Default 구현체가 선택된 모습 

     

    다시 돌아와서.. execute의 정의를 보면 HttpURLConnection 객체를 이용해서 convertAndSend를 하고 있다. 

    HttpURLConnection 객체는 java.net 패키지에서 기본적으로 제공하는 클래스이다. 

     

    convertAndSend를 하면 HTTP 연결에 필요한 값들을 세팅해주고, 

     

    HttpURLConnection convertAndSend(Request request, Request.Options options) throws IOException {
      URL url = new URL(request.url());
      HttpURLConnection connection = this.getConnection(url);
      if (connection instanceof HttpsURLConnection) {
        HttpsURLConnection sslCon = (HttpsURLConnection)connection;
        if (this.sslContextFactory != null) {
          sslCon.setSSLSocketFactory(this.sslContextFactory);
        }
     
        if (this.hostnameVerifier != null) {
          sslCon.setHostnameVerifier(this.hostnameVerifier);
        }
      }
     
      connection.setConnectTimeout(options.connectTimeoutMillis());
      connection.setReadTimeout(options.readTimeoutMillis());
      connection.setAllowUserInteraction(false);
      connection.setInstanceFollowRedirects(options.isFollowRedirects());
      connection.setRequestMethod(request.httpMethod().name());
      Collection<String> contentEncodingValues = (Collection)request.headers().get("Content-Encoding");
      boolean gzipEncodedRequest = this.isGzip(contentEncodingValues);
      boolean deflateEncodedRequest = this.isDeflate(contentEncodingValues);
      boolean hasAcceptHeader = false;
      Integer contentLength = null;
      Iterator var10 = request.headers().keySet().iterator();
     
      while(true) {
        while(var10.hasNext()) {
          String field = (String)var10.next();
          if (field.equalsIgnoreCase("Accept")) {
            hasAcceptHeader = true;
          }
     
          Iterator var12 = ((Collection)request.headers().get(field)).iterator();
     
          while(var12.hasNext()) {
            String value = (String)var12.next();
            if (field.equals("Content-Length") && !gzipEncodedRequest && !deflateEncodedRequest) {
              contentLength = Integer.valueOf(value);
              connection.addRequestProperty(field, value);
            }
     
            if (field.equals("Accept-Encoding")) {
              connection.addRequestProperty(field, String.join(", ", (Iterable)request.headers().get(field)));
              break;
            }
     
            connection.addRequestProperty(field, value);
          }
        }
     
        if (!hasAcceptHeader) {
          connection.addRequestProperty("Accept", "*/*");
        }
     
        boolean hasEmptyBody = false;
        byte[] body = request.body();
        if (body == null && request.httpMethod().isWithBody()) {
          body = new byte[0];
          hasEmptyBody = true;
        }
     
        if (body != null) {
          if (this.disableRequestBuffering && !hasEmptyBody) {
            if (contentLength != null) {
              connection.setFixedLengthStreamingMode(contentLength);
            } else {
              connection.setChunkedStreamingMode(8196);
            }
          }
     
          connection.setDoOutput(true);
          OutputStream out = connection.getOutputStream();
          if (gzipEncodedRequest) {
            out = new GZIPOutputStream((OutputStream)out);
          } else if (deflateEncodedRequest) {
            out = new DeflaterOutputStream((OutputStream)out);
          }
     
          try {
            ((OutputStream)out).write(body);
          } finally {
            try {
              ((OutputStream)out).close();
            } catch (IOException var19) {
            }
     
          }
        }
     
        return connection;
      }
    }

    내부적으로 HttpURLConnection 객체의 getOutputStream() 메서드를 호출한다. 

    HttpURLConnection은 추상클래스이며 상속받은 클래스들은 sun.net.www 패키지에서 대부분 구현하고 있다. 

     

    그래서 HttpURLConnection 클래스의 getOutputStream() 을 호출하게 된다. 

     

    이곳에서 실제로 HTTP 호출이 발생하는듯 하다. 그래서 connect() 라는 메서드도 보이고.. 

     

    private OutputStream getOutputStream0() throws IOException {
          assert isLockHeldByCurrentThread();
          try {
              if (!doOutput) {
                  throw new ProtocolException("cannot write to a URLConnection"
                                 + " if doOutput=false - call setDoOutput(true)");
              }
     
              if (method.equals("GET")) {
                  method = "POST"; // Backward compatibility
              }
              if ("TRACE".equals(method) && "http".equals(url.getProtocol())) {
                  throw new ProtocolException("HTTP method TRACE" +
                                              " doesn't support output");
              }
     
              // if there's already an input stream open, throw an exception
              if (inputStream != null) {
                  throw new ProtocolException("Cannot write output after reading input.");
              }
     
              if (!checkReuseConnection())
                  connect();
     
              boolean expectContinue = false;
              String expects = requests.findValue("Expect");
              if ("100-Continue".equalsIgnoreCase(expects) && streaming()) {
                  expectContinue = true;
              }
     
              if (streaming() && strOutputStream == null) {
                  writeRequests();
              }
     
              if (expectContinue) {
                  http.setIgnoreContinue(false);
                  expect100Continue();
              }
              ps = (PrintStream)http.getOutputStream();
              if (streaming()) {
                  if (strOutputStream == null) {
                      if (chunkLength != -1) { /* chunked */
                           strOutputStream = new StreamingOutputStream(
                                 new ChunkedOutputStream(ps, chunkLength), -1L);
                      } else { /* must be fixed content length */
                          long length = 0L;
                          if (fixedContentLengthLong != -1) {
                              length = fixedContentLengthLong;
                          } else if (fixedContentLength != -1) {
                              length = fixedContentLength;
                          }
                          strOutputStream = new StreamingOutputStream(ps, length);
                      }
                  }
                  return strOutputStream;
              } else {
                  if (poster == null) {
                      poster = new PosterOutputStream();
                  }
                  return poster;
              }
          } catch (ProtocolException e) {
              // Save the response code which may have been set while enforcing
              // the 100-continue. disconnectInternal() forces it to -1
              int i = responseCode;
              disconnectInternal();
              responseCode = i;
              throw e;
          } catch (RuntimeException | IOException e) {
              disconnectInternal();
              throw e;
          }
      }

     

    checkReuseConnection()이 false이면 connect를 하게 된다. 

    이 때 ReentrantLock을 사용해서 lock을 걸고 connecting 변수에 대한 동시성 처리를 한 다음 unlock을 한다. (CAS연산과 동일하다고 보면 됨) 

    이후 plainConnect() 메서드를 호출한다. 

     

    public void connect() throws IOException {
          lock();
          try {
              connecting = true;
          } finally {
              unlock();
          }
          plainConnect();
      }

     

    plainConnect()에서는 plainConnect0()를 호출한다.

    plainConnect0() 에서 비로소 HttpClient를 사용하여 HTTP 호출을 할 준비를 한다. 

    (이 때 프록시는 DIRECT (클라이언트가 서버와 직접 연결) 이며, 타임아웃은 5초)

     

    메서드를 타고타고 가다보면.. New 내부에서 HttpClient를 생성하고 있다.

    이 때 HttpClient는 sun.net.www.http 패키지에 속해 있는 클라이언트를 사용한다. 

    다시 본론으로 돌아와서 HttpClient 생성자로 돌아가보면 여기서는 OpenServer() 메서드를 호출함

    OpenServer() 메서드 안에서도 다시 OpenServer() 를 하게된다.

    여기서 비로소 소켓 연결을 하게 된다. 

     

    소켓을 만들게 되며.. java.net 패키지의 Socket 클래스를 사용한다. 

     

    Socket 생성자에서 내부적으로 작업하는데 프록시를 타지 않고, 별도의 socket factory를 지정하지 않았으니.. 아래 구문에 걸려서 구현체가 생성된다. 

    내부적으로 NioSocketImpl 이라는 구현체를 생성하게 되는데, 이 구현체도 sun에서 만든 클래스이다.

     

    이런식으로 Socket 객체를 만든 후 다시 doConnect()로 돌아와서 소켓연결을 시도하게 된다. 

    impl (nio) 을 가져와서 연결 시도

     

    Net.connect 를 호출하는데 Net 도 sun 패키지 내에 있음. 

     

    최종적으로.. connect0 라는 native 메서드를 호출하여 소켓을 맺게 된다. 

    연결된 이후 writeRequests() 메서드를 호출하는 등등하여 통신이 이루어지고 마무리됨. 

     

    결국 내부적으로 http request를 전송하는 듯 하다.

    어쨋든 convertAndSend를 하고나면 http 응답 결과가 넘어오게 된다.