feign 좀더 나아가기
안녕하세요. 저는 상품시스템팀에서 개발하고 있는 고정섭입니다.
이 글에서는 배달의민족 광고시스템 백엔드에서 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
- feign 의 logging 설정의 경우,
DEBUG
level 에서만 작동을 합니다.
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); }
@FeignClient
에FeignHeader1Configuration
적용하지 않은Client
에도 header 가 넣어져서 호출이 됩니다.
- 예를 들어, Header 를 넣는 configuration 을
- 모든 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 로 같은 설정을 여러번하면, 맨 마지막에 있는 것으로 덮어씌워집니다.
- 같은 bean 설정이 있다면
- feign client 에 동일한 configuration class 을 설정을 해도 bean 이 중복으로 생성이 됩니다.
- 각 bean 을 설정을 하는곳에 log 을 찍어놓고 확인을 해보면, 동일한 configuration 이지만, 여러개의 feign client 에 적용을 한다면 적용된 client 만큼 bean 이 생성이 됩니다.
- 모두 동일하게 적용이 되어야 하는
Contract
와LocalDate, 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 에서
@ComponentScan
에ExcludeFilters
를 설정합니다.@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 으로 바꾸는게 나을 것 같습니다.
- Spring 의
- SpringBoot 2.1.x 버전에 맞는 SpringCloud 버전은 Greenwich 인데, 여기 버전에서는 OpenFeign 버전이 10.1.0 으로 변경되었습니다.
- SpringBoot 2.0.x 버전에 맞는 SpringCloud 버전은 Finchley 인데, Finchley.SR4 까지가 최신버전이고 Finchley.SR2 부터 반영되었습니다.
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번째 이야기도, 첫번째 게시글을 보고 말씀해주신 분들의 내용에 대해서 좀더 파악해보고 작성했습니다.
- 감사합니다.