우아한 feign 적용기
안녕하세요. 저는 비즈인프라개발팀에서 개발하고 있는 고정섭입니다.
이 글에서는 배달의민족 광고시스템 백엔드에서 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);
}
- @EnableFeignClients 을 사용하셔야합니다.
root package 에 있어야 하며, 그렇지 않은 경우basePackages
또는basePackageClasses
를 지정해주셔야 합니다. - @EnableFeignClients 은 지정된 package 를 돌아다니면서 @FeignClient 를 찾아 구현체를 만들어 줍니다.
- 혹시 수동으로 구현체를 만들고 싶다면 아래 링크를 참고해주세요.
수동으로 feign 구현체 만들기
기본설정 값 및 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 를 설정해주면 됩니다.
- configuration 을 이용해서 설정하기
- 방법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 를 보다 쉽게 만들 수 있습니다.
- annotation 이용하기
- 방법 1
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); }
- ErrorDecoder 를 추가를 하고, 이 ErrorDecoder 를 사용하는 부분은
- 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 하도록 설정하면 됩니다.
- 1.5 를 곱하는 로직은
- 코드를 실행하면 결과는
- 2개의 설정을 override 를 해야합니다.
추가로 설정하면 좋은 것
- jackson 에 대해서 아래 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); } }
이 내용은 저희팀 권남님의 블로그를 참고해서 넣은 내용입니다.
- 이 값은 Jackson2 에서 명시적으로 설정을 하지 않았다면 기본값을 false 로 변경합니다.(Jackson2ObjectMapperBuilder#customizeDefaultFeatures)
마지막으로
- Feign 을 사용하게 되면, RestTemplate 보다 Rest API 사용하는데 필요한 설정이 간편해집니다.
- 이러한 편한점으로 인해 비지니스 로직에 더 집중을 할 수 있었습니다.
- 다른 라이브러리에 비하면, 학습의 어려움이 거의 없습니다.
- 위에 적은 하기쉬운 실수를 참고하시면 큰 어려움 없이 도입을 하실 수 있습니다.
- 그래도 망설여지신다면 일단 한번 써보세요~(JUST DO IT!!)
- 이 내용에 이은 보충설명에 대한 포스팅을 게시했습니다. 같이 이어서 읽어 주세요.