1. 개요
- Feign 클라이언트의 기본기는 여기에서 살펴봤다.
- 이번 문서에서는 기존 실습에 더해 Retry 와 Recover 기능을 사용하는 방법 에 대해 알아본다.
- 또한 WireMock 을 사용하여 이들 기능을 모두 테스트하는 법 에 대해 알아본다.
2. 프로젝트 구조
- 실습 파일을 만들 때 경로가 많이 헷갈리므로 프로젝트 구조를 미리 공유한다.
. ├─ main/java │ └─com.example.feign │ ├─ FeignApplication.java │ ├─ client │ │ ├─ BaseFeignClientPackage.java │ │ ├─ JsonFeignClient.java │ │ ├─ JsonFeignClientService.java │ │ └─ JsonFeignPost.java │ └─ config │ ├─ FeignClientConfiguration.java │ └─ FeignClientRetryLogger.java └─ test/java └─com.example.feign └── client └── JsonFeignClientTest.java
3. Dependency
- 모든 작업 전에 openfeign dependency 를 추가하자.
- build.gradle
dependencies { ... implementation("org.springframework.cloud:spring-cloud-starter-openfeign") ... }
- build.gradle
4. Configuration
- Feign Client 를 사용하려면 @EnableFeignClients 를 등록해야 한다.
- Feign Client 전용 basePackage 와 Configuration 을 만들고, 이 안에 @EnableFeignClients 를 등록하자.
- 각 클래스의 위치는 처음 소개한 [프로젝트 구조] 를 참고한다.
- BaseFeignClientPackage
public interface BaseFeignClientPackage { }
- FeignClientConfiguration
@Configuration @ComponentScan(basePackageClasses = {BaseFeignClientPackage.class}) @EnableFeignClients(basePackageClasses = {BaseFeignClientPackage.class}) public class FeignClientConfiguration { }
- BaseFeignClientPackage
5. Feign Client
- 이전에도 해봤지만 Feign Client 는 인터페이스 정의 와 어노테이션 설정만으로 간단히 구현이 가능하다.
- JsonFeignClient 를 정의하면서, Feign Client 통신에서 사용할 데이터 JsonFeignPost 를 정의하고, 이들을 사용할 JsonFeignClientService 를 정의하자.
- JsonFeignClient
@FeignClient(value = "json-api", url = "${external.json-api.url}") public interface JsonFeignClient { @GetMapping(value = "/posts") List<JsonFeignPost> getPosts(); @GetMapping(value = "/posts/{postId}") JsonFeignPost getPostById(@PathVariable("postId") Long postId); }
- JsonFeignPost
public class JsonFeignPost { Long userId; Long id; String title; String body; boolean completed; public Long getUserId() { return userId; } public Long getId() { return id; } public String getTitle() { return title; } public String getBody() { return body; } public boolean isCompleted() { return completed; } }
- JsonFeignClientService
@Component public class JsonFeignClientService { private final JsonFeignClient jsonFeignClient; public JsonFeignClientService(final JsonFeignClient jsonFeignClient) { this.jsonFeignClient = jsonFeignClient; } public List<JsonFeignPost> getPosts() { return jsonFeignClient.getPosts(); } public JsonFeignPost getPostById(Long postId) { return jsonFeignClient.getPostById(postId); } }
- JsonFeignClient
6. Retry Configuration
- Feign 라이브러리에서 제공하는 Retry Bean 을 사용할 수도 있지만, 이는 글로벌 설정이라는 단점이 있다.
- 여기에서는 특정 상황에서만 Retry 를 시도할 수 있도록 스프링에서 제공하는 @Retryable 을 사용할 것이다.
- 이를 위해 스프링에서 제공하는 retry dependency 를 추가하고, 기능을 활성화하기 위해 @EnableRetry 를 등록하자.
- build.gradle
implementation("org.springframework.retry:spring-retry")
- FeignClientConfiguration
@Configuration @EnableRetry(proxyTargetClass = true) // 새로 추가함 @ComponentScan(basePackageClasses = {BaseFeignClientPackage.class}) @EnableFeignClients(basePackageClasses = {BaseFeignClientPackage.class}) public class FeignClientConfiguration { }
- build.gradle
- 다음으로 Retry 기능을 사용할 메서드에 @Retryable 을 등록하자.
- JsonFeignClientService
@Component public class JsonFeignClientService { private final JsonFeignClient jsonFeignClient; public JsonFeignClientService(final JsonFeignClient jsonFeignClient) { this.jsonFeignClient = jsonFeignClient; } @Retryable( value = FeignException.class, // FeignExcpetion 이 발생했을 때만 Retry 기능이 활성화된다. maxAttemptsExpression = "${external.json-api.retry.maxAttempts:3}", // 총 3번 Retry 한다. backoff = @Backoff(delayExpression = "${external.json-api.retry.maxDelay:1000}") // 매번 1초씩 sleep 후 Retry 한다. ) public List<JsonFeignPost> getPosts() { return jsonFeignClient.getPosts(); } public JsonFeignPost getPostById(Long postId) { return jsonFeignClient.getPostById(postId); } }
- JsonFeignClientService
- Retry 기능이 어떻게 동작하는지 확인하면 좋으니 Logger 도 정의한다.
- FeignClientRetryLogger
@Component public class FeignClientRetryLogger extends RetryListenerSupport { @Override public <T, E extends Throwable> void onError(final RetryContext context, final RetryCallback<T, E> callback, final Throwable throwable) { System.out.printf( // 실무에서는 로그로 대체하자. "[RETRY_CONTEXT]-%s, [RETRY_COUNT]-%s, [EXCEPTION]-%s%n", context.getAttribute(RetryContext.NAME), context.getRetryCount(), throwable.getMessage() ); } }
- FeignClientRetryLogger
7. Test
- Retry 기능이 설정한 대로 잘 동작하는지 테스트할 때 Mock Server 를 사용하면 편리하다.
- Mock Server 를 사용하면 200 응답이든, 502 응답이든 원하는 응답을 가정할 수 있기 때문이다.
- Mock Server 를 사용하려면 아래 test dependency 를 추가해야 한다.
- (주의) 스프링 클라우드 버전에 따라 추가해야 할 dependency 가 다르다.
- build.gradle
// 스프링 클라우드 버전이 Hoxton 인 경우 testImplementation("org.springframework.cloud:spring-cloud-starter-contract-stub-runner") // 최신 버전을 사용하는 경우 (확인한 건 2021.0.3 버전이었음) testImplementation("org.springframework.cloud:spring-cloud-contract-wiremock")
- 아래는 테스트 코드 이다.
- JsonFeignClientTest
@AutoConfigureWireMock(port = 0) // Mock Server 자동 구성 설정 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @TestPropertySource(properties = { "external.json-api.url=http://localhost:${wiremock.server.port}" }) class JsonFeignClientTest { @Autowired private JsonFeignClientService jsonFeignClientService; @Test public void throwsFeignExcpetion() { // given stubFor( get("/posts") .willReturn(status(502)) // FeignException [502 BadGateway] ); // when Throwable throwable = catchThrowable(jsonFeignClientService::getPosts); // then verify( 3, getRequestedFor(urlEqualTo("/posts")) ); assertThat(throwable).isInstanceOf(FeignException.class); assertThat(throwable.getMessage()).contains("502 Bad Gateway"); } }
- JsonFeignClientTest
- 테스트를 실행하면 Mock Server 의 랜덤 포트로 요청이 나가고, retry 가 총 3번 수행된 것을 알 수 있다.
- Request received
2022-08-21 19:18:47.808 INFO 76687 --- [tp1678413715-50] WireMock : Request received: 127.0.0.1 - GET /posts Accept: [*/*] User-Agent: [Java/11.0.11] Connection: [keep-alive] Host: [localhost:10024] Matched response definition: { "status" : 502 } Response: HTTP/1.1 502 Matched-Stub-Id: [7855e4d8-6660-46d2-b259-3d400ed0f4b7]
- Retry 로그
[RETRY_METHOD]-public java.util.List<com.example.feign.client.JsonFeignPost> com.example.feign.client.JsonFeignClientService.getPosts(), [RETRY_COUNT]-1, [EXCEPTION]-[502 Bad Gateway] during [GET] to [http://localhost:10024/posts] [JsonFeignClient#getPosts()]: [] ... [RETRY_METHOD]-public java.util.List<com.example.feign.client.JsonFeignPost> com.example.feign.client.JsonFeignClientService.getPosts(), [RETRY_COUNT]-2, [EXCEPTION]-[502 Bad Gateway] during [GET] to [http://localhost:10024/posts] [JsonFeignClient#getPosts()]: [] ... [RETRY_METHOD]-public java.util.List<com.example.feign.client.JsonFeignPost> com.example.feign.client.JsonFeignClientService.getPosts(), [RETRY_COUNT]-3, [EXCEPTION]-[502 Bad Gateway] during [GET] to [http://localhost:10024/posts] [JsonFeignClient#getPosts()]: []
- [RETRY_COUNT] 가 1씩 증가하며 3번 기록되었다.
- Request received
8. Recover
- 모든 Retry 가 실패했을 때 특정 동작을 수행하고 싶다면 @Recover 를 사용할 수 있다.
- 사용법은 간단하나, @Retryable 를 사용하는 메서드의 시그니처를 지켜야 한다는 점에 주의해야 한다.
- 정확한 내용은 이곳을 참고하자.
- Recover 동작을 테스트하기 위해 일부 코드를 추가하였다.
- JsonFeignClientService
@Component public class JsonFeignClientService { private final JsonFeignClient jsonFeignClient; ... // 새로운 테스트를 위해 getPostById 메서드에도 @Retryable 추가 @Retryable( value = FeignException.class, // FeignException 이 발생했을 때만 Retry 기능이 활성화된다. maxAttemptsExpression = "${external.json-api.retry.maxAttempts:3}", // 총 3번 Retry 한다. backoff = @Backoff(delayExpression = "${external.json-api.retry.maxDelay:1000}") // 매번 1초씩 sleep 후 Retry 한다. ) public JsonFeignPost getPostById(Long postId) { return jsonFeignClient.getPostById(postId); } // Recover 수행 메서드 추가 // 메서드 이름과 FeignException 인자를 제거하면 바로 위 getPostById 의 시그니처와 동일한 시그니처를 갖는다. @Recover public JsonFeignPost afterAllRetries(FeignException exception, Long postId) { System.out.printf("##### Recover method called. exception = %s, postId = %d%n", exception, postId); return null; } }
- JsonFeignClientTest
class JsonFeignClientTest { @Autowired private JsonFeignClientService jsonFeignClientService; @Test public void throwsFeignExcpetion() { // given stubFor( get("/posts/1") .willReturn(status(502)) // FeignException [502 BadGateway] ); // when JsonFeignPost result = jsonFeignClientService.getPostById(1L); // then verify( 3, getRequestedFor(urlEqualTo("/posts/1")) ); assertThat(result).isEqualTo(null); } }
- JsonFeignClientService
- 테스트 실행 로그를 보면, 모든 Retry 요청이 시도된 후 Recover 메서드가 수행 되었다.
##### Recover method called. exception = feign.FeignException$BadGateway: [502 Bad Gateway] during [GET] to [http://localhost:11236/posts/1] [JsonFeignClient#getPostById(Long)]: [], postId = 1
- 또한 메서드의 반환 값은 Recover 메서드의 반환 값 을 취하고 있다.
assertThat(result).isEqualTo(null);