안녕하세요. 저는 비즈인프라개발팀에서 개발하고 있는 고정섭입니다.
이 글에서는 배달의민족 광고시스템 백엔드에서 feign 을 적용하면서 겪었던 것들에 대해서 공유 하고자 합니다.

소개


  • Feign 은 Netflix 에서 개발된 Http client binder 입니다.
  • Feign 을 사용하면 웹 서비스 클라이언트를 보다 쉽게 작성할 수 있습니다.
  • Feign 을 사용하기 위해서는 interface 를 작성하고 annotation 을 선언 하기만 하면됩니다.
    • 마치 Spring Data JPA 에서 실제 쿼리를 작성하지 않고 Interface 만 지정하여 쿼리실행 구현체를 자동으로 만들어주는 것과 유사합니다.
  • 설명보다는 소스코드를 한번 보고, RestTemplate 을 만들어 보셨던 분들은 많은 코드의 축소를 느끼게 되실겁니다.
    (잘 모르겠다 하시면, 동일한 호출을 RestTemplate 으로 만들어서 보시면 아시게 될거라 생각합니다.)

예제 소스 코드


  • 예제코드
    • 우선 예제코드를 git clone 하신다음에 아래 설명과 같이 예제코드를 사용을 권장드립니다.
    • 모든 예제코드는 test code 가 있습니다. spock 으로 작성이 되어있으나, 테스트 코드만 run 해보시면서 설명을 봐주세요.
      • spock 테스트 코드가 익숙하지 않다면, controller 를 만드신다음에 테스트 해보셔도 됩니다.

의존성


  • 기본적으로 필요한 의존성이 있습니다. 아래 의존성은 feign 을 사용하기 위한 최소의 의존성이고, 예제 프로젝트를 보시게 되면 아래 의존성보다는 좀더 많은 의존성이 있습니다.
      ext {
          ...   
          // https://spring.io/projects/spring-cloud 의 Release Trains 부분을 보세요.
          springCloudVersion = 'Finchley.RELEASE'
          ...
         
      }
         
      dependencyManagement {
          imports {
              mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
          }
      }
         
      dependencies {
          compile("org.springframework.cloud:spring-cloud-starter-openfeign")
      }
    
    • springCloudVersion 의 경우 spring boot 버전에 맞는 버전을 사용을 하셔야 합니다.
    • 주석에 있는 링크로 이동하셔서 사용하시는 spring boot 버전에 맞는 버전을 사용해주세요.

Feign 사용하기


@SpringBootApplication
@EnableFeignClients
public class Application {
 
    public static void main(String... args) {
        SpringApplication.run(Application.class, args);
    }
}
@FeignClient(value = "example", url = "${external-api.http-bin}")
public interface ExampleClient {
 
    @GetMapping("/status/{status}")
    void status(@PathVariable("status") int status);
}
  1. @EnableFeignClients 을 사용하셔야합니다.
    root package 에 있어야 하며, 그렇지 않은 경우 basePackages 또는 basePackageClasses 를 지정해주셔야 합니다.
  2. @EnableFeignClients 은 지정된 package 를 돌아다니면서 @FeignClient 를 찾아 구현체를 만들어 줍니다.

기본설정 값 및 override


  • feign 를 하다보면, 기본으로 설정되는 bean 들이 있습니다.
  • Spring 을 사용하는 환경이라면, org.springframework.cloud.openfeign.FeignClientsConfiguration 에서 어떤 Bean 이 만들어지는지 보면 됩니다.
  • default 설정을 override 하고 싶다면, configuration 에 설정된 값을 넣으면 됩니다.
        
      @FeignClient(value = "example", url = "https://httpbin.org", configuration = {FeignConfiguration.class})
      public interface ExampleClient {
        
          @GetMapping("/status/{status}")
          void status(@PathVariable("status") int status);
      }
    

Request 요청


Header 에 값 설정하기

  • Request Header 에 값을 넣는 방식은 몇가지가 있습니다. 이중에 목적에 맞는 방법을 선택을 하셔서 사용하시면 됩니다.
  • 저희팀에서는 주로 Configuration 을 이용해서 넣는 방법으로 합니다.
    이유는 method 별로 별도의 header 를 설정을 하는것이 아닌, feign client 별로 설정이 필요해서 였습니다.

    • 방법 1
        public class HeaderConfiguration {
            @Bean
            public RequestInterceptor requestInterceptor() {
                return requestTemplate -> requestTemplate.header("header", "header1", "header2");
            }
        }
      
        @FeignClient(value = "step2", url = "https://httpbin.org", configuration = {HeaderConfiguration.class})
        public interface Step2Client {
            ...
        }
      
      • configuration 을 이용해서 설정하기
        • Header 값을 설정할 configuration class 를 생성을 하고, RequestInterceptor 을 생성해줍니다.
        • 이제는 원하는 feign client 에 configuration 에 만든 class 를 설정해주면 됩니다.
    • 방법2
              
        @GetMapping(value = "/status/{status}", headers = "key2=value2")
        void status2(@PathVariable("status") int status);
              
        @GetMapping(value = "/status/{status}")
        void status3(@RequestHeader("key3") String headers, @PathVariable("status") int status);
              
        // 이 호출은 Header 에 값이 설정되지 않습니다.
        // @GetMapping 은 SpringMVcContract 를 사용해야하고, @Headers 는 feign Contract 를 사용해야 합니다.
        @org.springframework.web.bind.annotation.GetMapping(value = "/status/{status}")
        @feign.Headers("key3: value3")
        void status4(@PathVariable("status") int status);
      
      • annotation 이용하기
        • 3가지 방법모두 잘 되는데요, 단 3번째 방법의 경우 Contract 를 feign 에서 제공해준 Default Contract 를 사용해야합니다.
        • Contract 의 경우 feign client 구현체를 만들때 기반이 되는 내용인데요.
          spring 에서 제공한 Contract 와 feign 에서 사용하는 Contract 가 있습니다. Spring 에서 제공한 Contract 를 사용하면, spring 을 사용했던 경험으로 인해 feign client 를 보다 쉽게 만들 수 있습니다.

BasicAuth 인증하기

  • 이것도 방법은 여러개 있을지 모르겠지만, 제가 선택한건 Configuration 을 이용한 방법입니다.
      public class BasicAuthConfiguration {
        
          @Bean
          public BasicAuthRequestInterceptor basicAuthRequestInterceptor() {
              return new BasicAuthRequestInterceptor("mayaul", "1234567890");
          }
      }
    

하기 쉬운 실수


  • feign.Contract.Default 를 사용하는 실수
    • 위에서 말씀드렸듯이 Contract 는 2가지가 있습니다.
    • org.springframework.cloud:spring-cloud-starter-openfeign 을 사용하게 되면 SpringMvcContract 를 사용하게 되어 @GetMapping, @PostMapping, @RequestMapping 을 사용할 수 있습니다.
    • Feign 에서 제공하는 Default 를 사용하게 되면, 위에 있는 것이 아닌 feign.RequestLine 을 사용해야 합니다.
    • Feign 에서 제공하는 Default Contract 를 사용하는데 @GetMapping 과 같은 것을 사용한다면 아래와 같은 오류를 보게 됩니다.
        Caused by: java.lang.IllegalStateException: Method status not annotated with HTTP method type (ex. GET, POST)
        at feign.Util.checkState(Util.java:128)
        at feign.Contract$BaseContract.parseAndValidateMetadata(Contract.java:99)
        at feign.Contract$BaseContract.parseAndValidatateMetadata(Contract.java:66)
        at feign.ReflectiveFeign$ParseHandlersByName.apply(ReflectiveFeign.java:146)
        at feign.ReflectiveFeign.newInstance(ReflectiveFeign.java:53)
        at feign.Feign$Builder.target(Feign.java:218)
        at org.springframework.cloud.openfeign.HystrixTargeter.target(HystrixTargeter.java:39)
        at org.springframework.cloud.openfeign.FeignClientFactoryBean.getObject(FeignClientFactoryBean.java:261)
        at org.springframework.beans.factory.support.FactoryBeanRegistrySupport.doGetObjectFromFactoryBean(FactoryBeanRegistrySupport.java:171)
        ... 28 more
      
  • feign logger level 을 full 로 설정했는데 로그 출력이 되지 않음
    • feign logger level 을 보면, request 와 response 의 header, body 등의 상세한 로그가 나와야 합니다.
    • FULL 로 설정을 했음에도 나오지가 않는다면, @FeignClient 으로 client 를 만든 package log level 을 DEBUG 로 설정했는지 확인해보세요.
    • FULL 로 설정하면 볼 수 있는 로그
        [Class#method] ---> GET https://httpbin.org/status/200 HTTP/1.1
        [Class#method] ---> END HTTP (0-byte body)
        [Class#method] <--- HTTP/1.1 200 OK (1437ms)
        [Class#method] access-control-allow-credentials: true
        [Class#method] access-control-allow-origin: *
        [Class#method] connection: keep-alive
        [Class#method] content-length: 0
        [Class#method] content-type: text/html; charset=utf-8
        [Class#method] date: Mon, 13 May 2019 12:15:41 GMT
        [Class#method] referrer-policy: no-referrer-when-downgrade
        [Class#method] server: nginx
        [Class#method] x-content-type-options: nosniff
        [Class#method] x-frame-options: DENY
        [Class#method] x-xss-protection: 1; mode=block
        [Class#method] 
        [Class#method] <--- END HTTP (0-byte body)
      
  • PathVariable 에 값이 들어가지 않음
    • Spring MVC Controller 에서는 @PathVariable, @QueryParam 에 value(name) 을 넣지 않아도, field 의 이름이 default 로 설정되어 유입된 값이 셋팅이 되지만, feign 에서는 value(name) 값을 넣어줘야 @GetMapping @PostMapping 등에 있는 url 에 자동으로 넣어줍니다.
    • default 로 넣어줄거라 생각하고 안넣어주면 에러가 발생됩니다.
  • Feign client method parameter 에 @PathVariable, @QueryParam, @RequestBody 와 같은 annotation 을 설정하지 않음
    • 아무것도 설정하지 않으면 @RequestBody 로 생각해서 Http Body 로 데이터를 보낼려고 합니다.
    • 이 때, parameter 가 2개 이상이 되면 Method has too many Body parameters 이라는 에러가 발생됩니다.
        @PostMapping(value = "/anything")
        void anything(ExampleRequest request, ExampleRequest request2);
      
  • feign client 용 configuration class 에 @Configuration annotation 하면 안됨
    • feign client interface 에 사용할 목적을 가지고 configuration class 를 만든것에 @Configuration 을 사용을 하게 되면, 초반에 Bean 생성을 할 때, 이 configuration class 에 있는 Bean 도 Bean 을 만들어 버려서 모든 feign client 에 적용이 됩니다.
    • 습관적으로 configuration 이라서 @Configuration 을 설정하신다면, 의도하지 않은 설정이 되게 됩니다.

Java8 이상 에서 필수 설정


  • Get 에서 RequestParam 으로 LocalTime 을 보낼 때
    • 기본 설정을 그대로 사용을 할 경우 urlEncode를 한 값으로 보내는 것 같다. (여튼 잘 보내지지 않아요~)
    • 이 bean 설정을 넣고, feign client 에서 이 bean 이 설정된 configuration 을 사용을 하면, ISO Format 을 이용해서 보내게 됩니다.
        @Bean
        public FeignFormatterRegistrar localDateFeignFormatterRegister() {
            return registry -> {
                DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
                registrar.setUseIsoFormat(true);
                registrar.registerFormatters(registry);
            };
        }
      
  • Request Body, Response Body 에 LocalDate, LocalTime, LocalDateTime 이 있을 때
    • feign 에서 도 ObjectMapper 를 이용해서 Json 을 Serialize, Deserialize 를 합니다
    • 이 ObjectMapper 에 LocalDate, LocalTime, LocalDateTime 에 대한 설정이 들어가 있어야 합니다
    • Spring Boot 2.0 을 하고 있고 web-starter 를 의존성이 있다면, 이 의존성에 아래 2가지도 의존성에 있습니다. (저희 같은 경우에는 모든 프로젝트가 의존성을 가지고 있는 common module 에 넣었습니다.다만, Spring Batch 에는 없으니, Spring Batch 에서 feign client 를 호출을 할 필요가 있다면 위 2가지 의존성을 넣어줘야 합니다.
        compile('com.fasterxml.jackson.datatype:jackson-datatype-jdk8')
        compile('com.fasterxml.jackson.datatype:jackson-datatype-jsr310')
      

좀더 나아가기


  • error decode
    • ErrorDecoder 를 추가를 하고, 이 ErrorDecoder 를 사용하는 부분은 feign.SynchronousMethodHandler#executeAndDecode 여기에 있습니다
    • 기본 로직은
      • 일단 http status code 가 200에서 300 사이면, 설정된 decoder 를 실행합니다.
      • 그 이외 decode404 를 true 로 설정(기본값은 false), http status code 가 404 이면서 return type 이 void class 이어도 decoder 를 실행하게 됩니다.
        이 건 404 를 에러가 아닌 정상적인 값으로 판단하고 싶을때 사용하는 기능인것으로 보이네요.
      • 이 2개의 선행 조건문을 만족하지 않으면 errorDecoder 를 수행하게 됩니다.
          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);
          }
        
  • retryer
    • ribbon 을 이용하여, retry 를 할 수 있지만, feign 자체에서도 retryer 를 설정하여 retry 를 할 수 있습니다.
    • feign.SynchronousMethodHandler#invoke 에서 feign.RetryableException 을 throw 해야 retryer 가 작동합니다.
    • 기본 동작은,
      • connection 을 가지고 오지 못했다거나, httpstatus code 가 0 이하인 invalid 한 status code 값이어야 RetryableException 이 발생됩니다.
      • http status code 에 따라서, Retry 를 하고 싶은경우 별도의 ErrorDecode 를 만들어 거기서 httpStatus code 에 따라서 RetryableException 을 throw 해주면 됩니다.
      • Default Retryer 는 100ms 를 시작으로 1.5 곱하면서 재시도를 하고, 최대 5번 합니다.
        • RetryableException 에 retryAfter를 설정하면, 1초뒤에 재시도 할 수 있습니다.
        • 기본생성자가 아닌, 다른 생성자를 사용하면 retry 설정 할 수 있습니다.
            public Default() {
              this(100, SECONDS.toMillis(1), 5);
            }
                      
            public Default(long period, long maxPeriod, int maxAttempts) {
              this.period = period;
              this.maxPeriod = maxPeriod;
              this.maxAttempts = maxAttempts;
              this.attempt = 1;
            }
          
  • 예를 들어, ErrorDecoder 와 Retry 를 조합해서, 5xx 에러가 발생을 했을때 재시도를 하고 싶을때는 아래와 같이 하시면 됩니다.
    예제코드 io.github.mayaul.feign.tip.RetryClient 를 보시면 될 것 같습니다.

    재시도는 1초를 시작으로 최대 2초로 재시도 하고, 최대 3번으로 재시도 하도록 설정했습니다.
    최초 1초이고, 그 이후는 1.5를 곱하면서 재시도를 합니다.

    • 2개의 설정을 override 를 해야합니다.
        public class FeignRetryConfiguration {
            @Bean
            public Retryer retryer() {
                return new Retryer.Default(1000, 2000, 3);
            }
              
            @Bean
            public ErrorDecoder decoder() {
                return (methodKey, response) -> {
                    if (HttpStatusClass.SERVER_ERROR.contains(response.status())) {
              
                        return new RetryableException(format("%s 요청이 성공하지 못했습니다. Retry 합니다. - status: %s, headers: %s", methodKey, response.status(), response.headers()), null);
                    }
              
                    return new IllegalStateException(format("%s 요청이 성공하지 못했습니다. - status: %s, headers: %s", methodKey, response.status(), response.headers()));
                };
            }
        }
      
        @FeignClient(value = "retry", url = "${external-api.http-bin}", configuration = {FeignRetryConfiguration.class})
        public interface RetryClient {
              
            @GetMapping("/status/{status}")
            void status(@PathVariable("status") int status);
        }
      
      • 코드를 실행하면 결과는
          2019-05-30 12:18:22.005 [RetryClient#status] ---> GET https://httpbin.org/status/500 HTTP/1.1
          2019-05-30 12:18:22.005 [RetryClient#status] ---> END HTTP (0-byte body)
          2019-05-30 12:18:23.138 [RetryClient#status] <--- HTTP/1.1 500 INTERNAL SERVER ERROR (1132ms)
          2019-05-30 12:18:23.140 [RetryClient#status] <--- END HTTP (0-byte body)
          2019-05-30 12:18:24.676 [RetryClient#status] ---> RETRYING
          2019-05-30 12:18:24.676 [RetryClient#status] ---> GET https://httpbin.org/status/500 HTTP/1.1
          2019-05-30 12:18:24.676 [RetryClient#status] ---> END HTTP (0-byte body)
          2019-05-30 12:18:24.889 [RetryClient#status] <--- HTTP/1.1 500 INTERNAL SERVER ERROR (212ms)
          2019-05-30 12:18:24.891 [RetryClient#status] <--- END HTTP (0-byte body)
          2019-05-30 12:18:26.892 [RetryClient#status] ---> RETRYING
          2019-05-30 12:18:26.892 [RetryClient#status] ---> GET https://httpbin.org/status/500 HTTP/1.1
          2019-05-30 12:18:26.892 [RetryClient#status] ---> END HTTP (0-byte body)
          2019-05-30 12:18:27.108 [RetryClient#status] <--- HTTP/1.1 500 INTERNAL SERVER ERROR (215ms)
          2019-05-30 12:18:27.109 [RetryClient#status] <--- END HTTP (0-byte body)
        
      • 1.5 를 곱하면서 재시도를 하는것은 Retryer.Default 생성자에서 제공을 해주고 있지 않아, 이부분을 변경하고 싶다면 아래 2가지 중 하나를 해줘야 합니다.
        • 1.5 를 곱하는 로직은 feign.Retryer.Default#nextMaxInterval 여기에 있습니다.
        • RetryableException 에 재시도를 하는 시간을 retryAfter 에 전달하면 됩니다. 다만, Retryer 에 설정한 최대시간 보다는 적어야 합니다.
          최대시간보다 더 크다면, 최대시간으로 설정되어 재시도를 합니다.
            public RetryableException(String message, Date retryAfter) {
                super(message);
                this.retryAfter = retryAfter != null ? retryAfter.getTime() : null;
              }
          
        • feign.Retryer 를 상속하여 Retryer 를 구현하고 그걸 override 하도록 설정하면 됩니다.

추가로 설정하면 좋은 것


  • 이 내용은 저희팀 권남님의 블로그를 참고해서 넣은 내용입니다.
  • 이 2가지에 대해서 기본설정을 변경하는게 좋아요.
    • READ_UNKNOWN_ENUM_VALUES_AS_NULL 기본값(false)
    • FAIL_ON_UNKNOWN_PROPERTIES 기본값(true)
      • 이 값은 Jackson2 에서 명시적으로 설정을 하지 않았다면 기본값을 false 로 변경합니다.(Jackson2ObjectMapperBuilder#customizeDefaultFeatures)
          @EnableFeignClients
          ...
          @Configuration
          public class ServiceConfig implements Jackson2ObjectMapperBuilderCustomizer {
          ...
          @Override
          public void customize(Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder) {
              jacksonObjectMapperBuilder
                  .featuresToEnable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL)
                  .featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
          }
          }
        

마지막으로


  • Feign 을 사용하게 되면, RestTemplate 보다 Rest API 사용하는데 필요한 설정이 간편해집니다.
    • 이러한 편한점으로 인해 비지니스 로직에 더 집중을 할 수 있었습니다.
  • 다른 라이브러리에 비하면, 학습의 어려움이 거의 없습니다.
    • 위에 적은 하기쉬운 실수를 참고하시면 큰 어려움 없이 도입을 하실 수 있습니다.
  • 그래도 망설여지신다면 일단 한번 써보세요~(JUST DO IT!!)