안녕하세요. 저는 상품시스템팀에서 개발하고 있는 고정섭입니다.
이 글에서는 배달의민족 광고시스템 백엔드에서 feign 을 적용하면서 겪었던 것들에 대해서 공유 하고자 합니다. 이 글은 이전에 작성된 게시글에서 추가 보완사항에 대해서 작성되었습니다.

http client

  • feign 에서 지원하는 http client 는 ApacheHttpClient 와 OkHttpClient 가 있습니다. default 는 feign.Client.Default#Default 에서 생성된 것입니다.
    • HttpClientFeignLoadBalancedConfiguration 와 OkHttpFeignLoadBalancedConfiguration 을 볼면 알 수 있습니다.
    • apacheHttpClient 로 설정하기
      • 의존성 추가하기
        compile("io.github.openfeign:feign-httpclient")
        
      • feign client 를 apache http client 사용하도록 설정
        feign.httpclient.enabled: true
        
    • OkHttpClient 로 설정하기
      • 의존성 추가하기
        compile("io.github.openfeign:feign-okhttp")
        
      • feign client 를 apache http client 사용하도록 설정
        feign.okhttp.enabled: true
        
  • OkHttpClient 의 장단점에 대해서는 링크를 통해서 확인 할 수 있습니다.
      OkHttp has HTTP/2, a built-in response cache, web sockets, and a simpler API. 
      It’s got better defaults and is easier to use efficiently. 
      It’s got a better URL model, a better cookie model, a better headers model and a better call model. OkHttp makes canceling calls easy. 
      OkHttp has carefully managed TLS defaults that are secure and widely compatible. 
      Okhttp works with Retrofit, which is a brilliant API for REST. 
      It also works with Okio, which is a great library for data streams. 
      OkHttp is a small library with one small dependency (Okio) and is less code to learn. 
      OkHttp is more widely deployed, with a billion Android 4.4+ devices using it internally.
    

logging

error response 처리

  • feign 의 경우 200 <= response status < 300 일때만 정상이라고 판단하고 (feign.client.config.default.decode404 가 true 이면, 404 도 정상으로 포함), 그 이외의 http status 값은 errorDecoder 가 수행이 됩니다.
  • error decoder 를 커스터마이징 하는 방법은 이전 게시글에서 보실 수 있습니다.
  • 이번에는 error decoder 를 사용하지 않고, 직접 httpStatus code 값에 따라 판단하는 방법을 알아보겠습니다.
  • method 의 return type 을 feign 의 Response 로 지정하면 feign 에서는 별도 처리 없이 바로 http 호출 결과를 반환합니다.
  • sample 로 controller 를 하나를 받고, 메시지에는 fail 에 대한 값을 넣고 httpStatus 값은 받은 값을 리턴하게 했습니다.
      @RestController
      public class HelloController {
          // ...
          @GetMapping("/fail/{status}")
          public ResponseEntity<HelloResponse<String>> sample(@PathVariable("status") int status) {
              return new ResponseEntity<>(
                      HelloResponse.builder().code(12345).message("It's fail").data("this is messages").build(), 
                      HttpStatus.valueOf(status));
          }
          // ...
      }
    
  • response dto 는 이렇게 만들었습니다.
      public class HelloResponse<T> {
          private int code;
          private String message;
          private T data;
      }
    
  • feign client 에서 method return type 을 feign 의 feign.Response 로 하고 있습니다.
      @FeignClient(name = "hello-client", url = "http://localhost:8080")
      public interface HelloClient {
          // ...
          @RequestLine("GET /fail/{status}")
          Response fail(@Param("status") int status);
          // ...
      }
    
  • test code 에서 실제로 호출해보겠습니다.
      def "호출 테스트"() {
          Response response = helloClient.fail(HttpStatus.UNAUTHORIZED.value())
    
          // 5xx 는 진짜 에러로 처리 
          if (HttpStatus.Series.valueOf(response.status) == HttpStatus.Series.SERVER_ERROR) {
              throw new IllegalStateException("server error 발생");
          }
    
          System.out.println(response)
      }
    
      io.github.mayaul.basic.HelloClient       : [HelloClient#fail] ---> GET http://localhost:8080/fail/401 HTTP/1.1
      io.github.mayaul.basic.HelloClient       : [HelloClient#fail] header: header1
      io.github.mayaul.basic.HelloClient       : [HelloClient#fail] ---> END HTTP (0-byte body)
      io.github.mayaul.basic.HelloClient       : [HelloClient#fail] <--- HTTP/1.1 401 (33ms)
      io.github.mayaul.basic.HelloClient       : [HelloClient#fail] content-type: application/json;charset=UTF-8
      io.github.mayaul.basic.HelloClient       : [HelloClient#fail] date: Thu, 19 Dec 2019 01:17:35 GMT
      io.github.mayaul.basic.HelloClient       : [HelloClient#fail] transfer-encoding: chunked
      io.github.mayaul.basic.HelloClient       : [HelloClient#fail] 
      io.github.mayaul.basic.HelloClient       : [HelloClient#fail] {"status":12345,"message":"It's fail","data":"this is messages"}
      io.github.mayaul.basic.HelloClient       : [HelloClient#fail] <--- END HTTP (63-byte body)
        
      {"code":4000,"message":"It's fail","data":"this is messages"}
    
    • feign.Response 의 경우는 httpStatus code 값을 가지고 있는데, 이 값을 가지고 와서 특정 httpStatus code 에 대한 처리를 feign 의 기본동작과 달리 설정할 수 있습니다.

feign configuration class 에서 @Configuration 넣어보기

  • configuration class 에 @Configuration 을 하지 않으면 Bean 이 Singleton 이 되지 않는 것으로 알고 있습니다.
  • 저의 이전 게시글에서는 feign 의 configuration class 의 @Configuration 을 넣지 않는것을 추천하였는데, feign guide 문서에는 이와 반대로 하고 있습니다.
      FooConfiguration does not need to be annotated with @Configuration. 
      However, if it is, then take care to exclude it from any @ComponentScan that would otherwise include this configuration as it will become the default source for feign.Decoder, feign.Encoder, feign.Contract, etc., when specified. 
      This can be avoided by putting it in a separate, non-overlapping package from any @ComponentScan or @SpringBootApplication, or it can be explicitly excluded in @ComponentScan.
    
  • feign 용 configuration class 을 @Configuration 을 붙이고, @SpringBootApplication 또는 @ComponentScan 에서 탐색가능한 경로에 있다면 모든 feign 용 client 에 적용이 됩니다.
    • 예를 들어, Header 를 넣는 configuration 을 @Configuration을 넣는다면 모든 feign client 에 적용이 됩니다.
        @Configuration
        public class FeignHeader1Configuration {
        @Bean
        public RequestInterceptor requestInterceptor() throws InterruptedException {
            return requestTemplate -> requestTemplate.header("header", "header1");
        }
        }
      
        @FeignClient(value = "client", url = "${external-api.http-bin}")
        public interface Client {
        @RequestLine("GET /status/{status}")
        void status(@Param("status") int status);
        }
        @FeignClient(value = "client2", url = "${external-api.http-bin}", configuration = {FeignHeader1Configuration.class})
        public interface Client2 {
        @RequestLine(value = "GET /status/{status}")
        void status(@Param("status") int status);
        }
      
    • @FeignClientFeignHeader1Configuration 적용하지 않은 Client 에도 header 가 넣어져서 호출이 됩니다.
  • 모든 feign client 에 적용이 되어야하는 설정은 @Configuration 으로 넣어서 자동으로 Bean 이 생성되어서 적용이 되게 하고, 그 이외 client 별로 적용이 되어야 하는건 @FeignClient 에 configuration attribute 로 설정합니다.
    • 같은 bean 설정이 있다면 @FeignClient configuration attribute 로 설정한 것으로 덮어씌워집니다.
      @Configuration
      public class FeignHeader1Configuration {
          @Bean
          public RequestInterceptor requestInterceptor() throws InterruptedException {
              return requestTemplate -> requestTemplate.header("header", "header1");
          }
      }
        
      public class FeignHeader2Configuration {
          @Bean
          public RequestInterceptor requestInterceptor() throws InterruptedException {
              return requestTemplate -> requestTemplate.header("header", "header2");
          }
      }
    
      @FeignClient(value = "client2", url = "${external-api.http-bin}", configuration = {FeignHeader2Configuration.class})
      public interface Client2 {
          @RequestLine(value = "GET /status/{status}")
          void status(@Param("status") int status);
      }
    
    • client2 를 호출하면 FeignHeader1Configuration 에 있는 header1 아니라 header2 가 header 에 넣어져서 호출이 됩니다.
    • @FeignClient configuration attribute 로 같은 설정을 여러번하면, 맨 마지막에 있는 것으로 덮어씌워집니다.
  • feign client 에 동일한 configuration class 을 설정을 해도 bean 이 중복으로 생성이 됩니다.
    • 각 bean 을 설정을 하는곳에 log 을 찍어놓고 확인을 해보면, 동일한 configuration 이지만, 여러개의 feign client 에 적용을 한다면 적용된 client 만큼 bean 이 생성이 됩니다.
    • 모두 동일하게 적용이 되어야 하는 ContractLocalDate, LocalTime, LocalDateTime 를 ISO format 으로 보내는 설정정도는 모든 client 에 적용이 될 만한 것으로 생각이 됩니다.
      • 이런 설정은, @Configuration annotation 을 붙여 @ComponentScan에 탐색이 되도록 하여, bean 을 1개만 만들고 모든 client 에 적용 시키는 방법이 좋을 것 같습니다.

@Configuration 을 feign 용 configuration 에서 사용하기

  • 위에서 설명을 했듯이 Feign용 configuration class 에 @Configuration 을 넣으면 원하지 않는 client 에 적용이 됩니다.
  • 그래서, 별도의 annotation 을 만들고 @ComponentScan 에서 exclude 되도록 설정을 하는 것도 방법입니다.
    • 실수로 @Configuraiton annotation 을 붙일경우의 방어도 될 것 같습니다.

    • feign configuration 을 component scan 에서 ignore 처리를 위해서 마커용 annotation 을 생성합니다
        @Target({ElementType.TYPE})
        @Retention(RetentionPolicy.RUNTIME)
        @Documented
        public @interface FeignConfiguration { }
      
    • application 에서 @ComponentScanExcludeFilters 를 설정합니다.
        @SpringBootApplication
        @EnableFeignClients
        @ComponentScan(basePackages = "io.github.mayaul", excludeFilters = {@ComponentScan.Filter(type = FilterType.ANNOTATION, value = FeignConfiguration.class)})
        public class Application { }
      
    • feign 개별 client 용 configuration class 에 적용을 합니다.
        @Configuration
        @FeignConfiguration
        public class FeignHeader2Configuration {
        @Bean
        public RequestInterceptor requestInterceptor() throws InterruptedException {
            return requestTemplate -> requestTemplate.header("header", "header2");
        }
        }
      
    • 이렇게 하면 원하는 @FeignClient 에 configuration 에 FeignHeader2Configuration 넣은것만 적용되고, 다른 client 에는 영향을 주지 않게 됩니다.

feign client 용 interface 만 정의하고 실제 구현체는 달리 사용하기

  • feign 을 쓰다보면 1개 interface 로 2개 이상의 feign client 를 만들고 싶을때가 있습니다.
  • 예를 들어 sms message 를 보내는 restful 서비스가 있고, retry 여부를 결정을 하고 싶을 때가 있습니다.
    •   interface MessageClient {
        @RequestLine("POST /sms")
        ResponseDto sendSms(@RequestBody RequestDto RequestDto);
        }
      
    • retry 하지 않는 client
        @FeignClient(name = "message-api", url = "${external.sms.url}")
        public interface MessageNoRetryClient extends MessageClient {  }
      
    • retry 하는 client
        @FeignClient(name = "message-api-retryable", url = "${external.sms.url}", configuration = {FeignRetryConfiguration.class})
        public interface MessageRetryClient extends MessageClient {  }
      

      이렇게 하면 같은 API 를 사용하면서 retry 를 하는 client 와 retry 를 하지 않는 client 를 만들 수 있습니다.

      일전에 이전 블로그에서 connection 을 가지고 오지 못했다거나, httpStatus code 가 0 이하인 invalid 한 status code 값이어야 RetryableException 이 발생 이 된다고 했으나
      기본적으로 RetryableException 발생이 된다고 해도, feign 에서 기본적으로 만드는 bean 은 feign.Retryer#NEVER_RETRY 입니다.
      Retry 가 필요한경우 별도의 Configuration class 를 만들어 feign.Retryer.Default 를 생성해서 넣어주면 됩니다.
      생성자 parameter 로 재시도 주기와 최대재시도 횟수등을 정할 수 있으니 기본적이 재시도 동작을 설정 할 수 있습니다.
      Retry 설정에 자세한 내용은 이전 게시글 내용을 참고해주세요

@RequestParam 에 Collection 이 들어갈때 key=value1&key=value2 가 아니라 key=value1,value2 하기

  • 기존에 feign 에서 @RequestParam 에 collection 을 넣으면 key=value1&key=value2 이렇게 호출이 됩니다.
    • 이 내용은 이미 OpenFeign github 에 이슈 리포팅이 되어 있습니다.
  • 결론적으로 OpenFeign 9.6.0 버전에는 fix 가 되어 있습니다.
  • fix 된 버전을 사용하고, SpringContract 가 아닌 Feign 의 default contract 를 사용해야합니다.
    • Spring 의 @GetMapping, @PostMapping, @PutMapping, @DeleteMapping 등은 Rest 호출을 받기 위해서 만들어진거지, 호출을 위한 목적이 아니니 이참에 feign 으로 바꾸는게 나을 것 같습니다.
  • SpringBoot 2.1.x 버전에 맞는 SpringCloud 버전은 Greenwich 인데, 여기 버전에서는 OpenFeign 버전이 10.1.0 으로 변경되었습니다.
  • SpringBoot 2.0.x 버전에 맞는 SpringCloud 버전은 Finchley 인데, Finchley.SR4 까지가 최신버전이고 Finchley.SR2 부터 반영되었습니다.

    SR 버전이란?

      if there is a critical bug in one of them that needs to be available to everyone, the release train will push out "service releases" with names ending ".SRX", where "X" is a number.
    

    최신버전을 사용을 하는것이 좋습니다.MVN Repository

  • 이제 사용하는 방법에 대해서 알아보겠습니다.
    • 일단 httpbin.org 에는 array 를 받는것이 없어서, 제가 sample 로 controller 를 하나 만들었습니다.
        @RestController
        public class HelloController {
        // ...
        @GetMapping("/array")
        public ResponseEntity<List<String>> array(@RequestParam("values") List<String> values) {
            return new ResponseEntity<>(values, HttpStatus.OK);
        }
              
        // ...
        }
      
        @FeignClient(name = "hello-client", url = "http://localhost:8080")
        public interface HelloClient {
        // ...
        @RequestLine("GET /array?values={values}")
        List<String> array(@Param("values") List<String> values);
        // ...
        }
      

      기본적으로 이렇게 호출을하면 로그는 아래와 같습니다.

        io.github.mayaul.basic.HelloClient       : [HelloClient#array] ---> GET http://localhost:8080/array?values=a&values=b&values=c HTTP/1.1
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] header: header1
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] ---> END HTTP (0-byte body)
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] <--- HTTP/1.1 200 (34ms)
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] content-type: application/json;charset=UTF-8
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] date: Thu, 19 Dec 2019 00:10:15 GMT
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] transfer-encoding: chunked
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] 
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] ["a","b","c"]
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] <--- END HTTP (13-byte body)
      
        @FeignClient(name = "hello-client", url = "http://localhost:8080")
        public interface HelloClient {
        // ...
        @RequestLine(value = "GET /array?values={values}", collectionFormat = CollectionFormat.CSV)
        List<String> array(@Param("values") List<String> values);
        // ...
        }
      

      와 같이 collectionFormat 을 지정할 수 있고, CSV 로 하면

        io.github.mayaul.basic.HelloClient       : [HelloClient#array] ---> GET http://localhost:8080/array?values=a,b,c HTTP/1.1
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] header: header1
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] ---> END HTTP (0-byte body)
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] <--- HTTP/1.1 200 (15ms)
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] content-type: application/json;charset=UTF-8
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] date: Thu, 19 Dec 2019 00:14:34 GMT
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] transfer-encoding: chunked
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] 
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] ["a","b","c"]
        io.github.mayaul.basic.HelloClient       : [HelloClient#array] <--- END HTTP (13-byte body)
      

      이렇게 CSV 로 보내지는것을 볼 수 있습니다. 기본값은 처음에 보았던것처럼 펼쳐서 보내는 EXPLODED 입니다.

    value description
    EXPLODED 구분자가 없고, 펼쳐서 보낸다.(default)
    CSV , 를 구분자로 한다.
    SSV [space](빈문자열) 를 구분자로 한다.
    TSV [tab](탭) 를 구분자로 한다.
    PIPES | 를 구분자로 한다.

마지막으로

  • 3번째 이야기가 나올지는 모르겠습니다.
  • 잘못된 부분이 있거나, 좀더 상세히 알고 싶은부분이 있으면 알려주세요. 좀더 파보도록 할게요.
    • 이번 2번째 이야기도, 첫번째 게시글을 보고 말씀해주신 분들의 내용에 대해서 좀더 파악해보고 작성했습니다.
  • 감사합니다.