resilience4j 사용해서 HTTP API 연동하기
created Oct 30, 2022 | updated Oct 30, 2022
개발을 하다보면 외부(External; Remote) 시스템의 HTTP API를 호출하는 경우가 생기곤 한다. spring boot에서 사용할 수 있는 다양한 REST(HTTP) Client가 존재하는데, 오늘은 resilience4j-feign을 사용하는 내용을 작성했다. 또한 외부 시스템의 문제(장애)가 내 시스템으로 연쇄 전파되지 않도록 Circuit Breaker와 Rate Limiter로 장애 허용 설정도 함께 한다.
개요
목표
- 외부 시스템의 HTTP API를 호출하기
- 외부 시스템을 호출 개수 제한하기
- 외부 시스템에 문제가 생겼을 때 차단하기
- HTTP Client Connection Pool 적용하기
구성요소
- springboot2
- resilience4j-feign
- resilience4j RateLimiter, CircuitBreaker
- Okhttp3
짚고 갈 개념들
Resilience4j
- 경량(lightweight)의 장애 허용(내결함성; fault tolerance) 라이브러리이다.
- Netflix Hystrix에서 영감을 받았고 java8과 함수형 프로그래밍을 위해 설계되었다.
- Vavr(java8+를 위한 함수형 라이브러리)만 사용하고 다른 외부 라이브러리 의존성은 없다.
- 고수준의 기능들(데코레이터들)을 제공하고 필요한 기능만을 선택해서 사용할 수 있다.
Feign and OpenFeign
- Java HTTP Client binder; REST client builder; Declarative Web Service Client
- java http clients 작성을 쉽게 만들어주는 것으로 interface를 생성하고 annotation을 추가하는 방식으로 사용한다.
- Netflix OSS(Open Source Service) 중 하나 였고, 현재는 OpenFeign 프로젝트로 완전히 이관되었다.
- Feign과 OpenFeign은 Spring Cloud에서 사용할 수 있다.
- Netflix OSS 컴포넌트들은
Spring Cloud Netflix
프로젝트로 통합되어 제공된다. - OpenFeign은
Spring Cloud OpenFeign
프로젝트로 통합되어 제공된다.
- Netflix OSS 컴포넌트들은
- 다른 REST Client로는 Retrofit, RestTemplate, WebClient 등이 있다.
Resilience4j Feign
- Resilience4j의 애드온 모듈 중 하나이다.
- HystrixFeign과 유사하고 "fault tolerance patterns"을 위한 resilience4j의 기능을 데코레이션해서 사용할 수 있다.
- 현재 데코레이션 가능한 기능 : CircuitBreaker, RateLimiter, Fallback
Okhttp
- HTTP Client이고 Square에서 개발된 오픈소스 라이브러리이다.
- HTTP/2 지원(enables SPDY)하고 Connection Pooling, GZIP, 응답 캐싱 등을 제공한다. 또한 일반적인 네트워크의 연결 문제를 복구할 수도 있고, 최신 TLS를 지원한다.
- 요청과 응답 API는 fluent builder와 immutability로 설계 되었고, 동기식과 비동기식을 지원한다.
- Okio 의존성만 있고, 라이브러리가 가볍다.
- 다른 HTTP Client로는 JDK HttpClient(Java11 JEP 321, legacy HttpUrlConnection 교체), Apache HttpClient, Netty 등이 있다.
주요 설정
의존성 설정
build.gradle
ext {
resilience4jVersion = '1.7.1'
openfeignVersion = '11.10'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation "io.github.resilience4j:resilience4j-spring-boot2:${resilience4jVersion}"
implementation "io.github.resilience4j:resilience4j-feign:${resilience4jVersion}"
implementation "io.github.openfeign:feign-okhttp:${openfeignVersion}"
implementation "io.github.openfeign:feign-jackson:${openfeignVersion}"
...
}
Okhttp3 Connection Pool 적용
@Configuration
public class HttpClientConfig {
@Bean
public OkHttpClient okHttpClient() {
return new OkHttpClient.Builder()
.connectionPool(new ConnectionPool(10, 60, TimeUnit.SECONDS))
.build();
}
}
REST API 작성
- 외부 시스템은 httpbin을 사용한다.
public interface HttpBinAPI {
@RequestLine("GET /get")
GetResponse get();
@RequestLine("POST /delay/{delay}")
GetResponse delay(@Param("delay") Integer delay);
@RequestLine("PUT /status/{codes}")
String status(@Param("codes") Integer codes);
}
REST Client와 Fault Tolerance 설정
- 설정 파일 작성 (circuit breaker와 rate limiter)
resilience4j:
circuitbreaker:
instances:
httpbin:
sliding-window-type: COUNT_BASED # COUNT_BASED(default) or TIME_BASED
sliding-window-size: 100 # calls or seconds
minimum-number-of-calls: 100
failure-rate-threshold: 50
wait-duration-in-open-state: 60s # open -> half-open
permitted-number-of-calls-in-half-open-state: 10
max-wait-duration-in-half-open-state: 0s # half-open -> open
slow-call-rate-threshold: 100
slow-call-duration-threshold: 60s
event-consumer-buffer-size: 10
ratelimiter:
instances:
httpbin:
timeout-duration: 5s
limit-refresh-period: 500ns
limit-for-period: 50
event-consumer-buffer-size: 10
- 설정 내용 적용
- YAML의 설정 정보를 사용하기 위해서는 CircuitBreakerRegistry와 RateLimiterRegistry를 사용한다.
- rate limiter, circuit breaker 기능은 FeignDecorators 생성 시 선언된 순서로 적용된다.
private static final String HTTP_BIN_NAME = "httpbin";
private static final String HTTP_BIN_URL = "https://httpbin.org:80/";
@Bean
public HttpBinAPI app2AppAPIClient() {
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(HTTP_BIN_NAME); circuitBreaker.getEventPublisher()
.onReset(event -> log.warn("CircuitBreaker > onReset > {}", event))
.onError(event -> log.warn("CircuitBreaker > onError > {}", event))
.onFailureRateExceeded(event -> log.warn("CircuitBreaker > onSlowCallRateExceeded > {}", event))
.onSlowCallRateExceeded(event -> log.warn("CircuitBreaker > onSlowCallRateExceeded > {}", event))
.onStateTransition(event -> log.warn("CircuitBreaker > onStateTransition > {}", event));
RateLimiter rateLimiter = rateLimiterRegistry.rateLimiter(HTTP_BIN_NAME); rateLimiter.getEventPublisher()
.onFailure(event -> log.warn("RateLimiter > onFailure > {}", event));
FeignDecorators feignDecorators = FeignDecorators.builder()
.withCircuitBreaker(circuitBreaker) .withRateLimiter(rateLimiter) .build();
return Resilience4jFeign.builder(feignDecorators)
.client(new feign.okhttp.OkHttpClient(okHttpClient)) // HTTP Client 주입
.decoder(new JacksonDecoder(objectMapper)) // Json 설정 주입
.target(HttpBinAPI.class, HTTP_BIN_URL);
}
@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
return builder -> {
builder.featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
builder.featuresToEnable(DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT);
builder.serializationInclusion(JsonInclude.Include.NON_NULL);
builder.serializationInclusion(JsonInclude.Include.NON_EMPTY);
builder.propertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE);
};
}
테스트
조건과 설정
- CircuitBreaker와 RateLimiter 적용 테스트를 해보자.
- 테스트를 위해 조금은 극단적으로 시나리오를 잡았고, 각 조건에 맞춰 설정을 수정하자.
- 조건 #1 : 외부 시스템에 오류가 발생한 경우 요청을 중지한다.
- 10건의 요청을 기준으로 실패 비율이 50%로가 넘는 경우 circuit breaker를 오픈(OPEN)한다.
- 하프 오픈(HALF-OPEN)전 10초를 대기한다. 하프 오픈 후에는 최대 5건의 요청만 허용한다.
- circuit breaker를 닫기 전에 1초 대기한다.
- 외부 시스템의 응답이 느린 경우 오류로 판단한다. 지연으로 인지하는 응답 시간은 3초로 한다.
# CircuitBreaker 설정 sliding-window-size: 10 minimum-number-of-calls: 5 failure-rate-threshold: 50 wait-duration-in-open-state: 10s permitted-number-of-calls-in-half-open-state: 5 max-wait-duration-in-half-open-state: 1s slow-call-rate-threshold: 80 slow-call-duration-threshold: 3s
- 조건 #2 : 외부 시스템에 초당 요청할 수 있는 개수는 정해져 있다.
- 제한된 수는 1초당 20건이다.
# RateLimiter 설정 limit-refresh-period: 1s limit-for-period: 20 timeout-duration: 10ms
테스트 API 작성
@RestController
@RequestMapping("/v1/test")
@RequiredArgsConstructor
public class RestTestController {
private final HttpBinAPI httpBinAPI;
@RequestMapping("/get")
public String get() {
httpBinAPI.get();
return "success\n";
}
@RequestMapping("/delay")
public String delay() {
httpBinAPI.delay(4);
return "delay\n";
}
@RequestMapping("/error")
public String status() {
httpBinAPI.status(500);
return "error\n";
}
}
테스트하기
- httpbin 구동하기
docker pull kennethreitz/httpbin
docker run -p 80:80 kennethreitz/httpbin
- 서버 실행하기
git clone https://github.com/oflouis/resilience4j-feign.git
cd resilience4j-feign
./gradlew clean bootRun
- 조건 #1 : 외부 시스템 오류 테스트
# 서킷 브레이커 열기 위한 호출
for i in $(seq 1 4); do curl "http://localhost:8080/v1/test/error"; curl "http://localhost:8080/v1/test/error"; curl "http://localhost:8080/v1/test/get"; done
# 주요 로그 확인
## 오류 발생 시 실패로 기록
CircuitBreaker 'httpbin' recorded an exception as failure:
## 오류률 초과 시 서킷 브레이커 오픈 ( 상태 전환 : CLOSED to OPEN )
Event FAILURE_RATE_EXCEEDED published: CircuitBreaker 'httpbin' exceeded failure rate threshold. Current failure rate: 80.0
Event STATE_TRANSITION published: CircuitBreaker 'httpbin' changed state from CLOSED to OPEN
## 서킷 브레이커 오픈된 경우 추가 요청 허용하지 않음
Event NOT_PERMITTED published: CircuitBreaker 'httpbin' recorded a call which was not permitted.
CircuitBreaker 'httpbin' is OPEN and does not permit further calls
# 서킷 브레이커 닫힘 확인을 위한 호출. 서킷 브레이커가 열린 후 10초 후에 실행
for i in $(seq 1 10); do curl "http://localhost:8080/v1/test/get"; done
# 주요 로그 확인
## 오픈 유지 시간 초과하고 서킷 브레이커 하프 오픈 ( 상태 전환 : OPEN to HALF_OPEN )
Event STATE_TRANSITION published: CircuitBreaker 'httpbin' changed state from OPEN to HALF_OPEN
## 성공으로 기록되는 요청들
CircuitBreaker 'httpbin' succeeded:
Event SUCCESS published: CircuitBreaker 'httpbin' recorded a successful call. Elapsed time: 36 ms
## 외부 시스템 정상으로 판단하고 서킷 브레이커 닫힘 ( 상태 전환 : HALF_OPEN to CLOSED )
Event STATE_TRANSITION published: CircuitBreaker 'httpbin' changed state from HALF_OPEN to CLOSED
- 조건 #1 : 외부 시스템 지연 오류 테스트
# 지연 발생으로 서킷 브레이커 오픈하기 위한 호출
for i in $(seq 1 10); do curl "http://localhost:8080/v1/test/delay"; done
# 주요 로그 확인
## 지연은 있지만 성공으로 기록
CircuitBreaker 'httpbin' succeeded:
Event SUCCESS published: CircuitBreaker 'httpbin' recorded a successful call. Elapsed time: 5054 ms
## 여러 차례의 지연으로 지연률 초과. 서킷 브레이커 오픈 ( 상태 전환 : CLOSED to OPEN )
Event SLOW_CALL_RATE_EXCEEDED published: CircuitBreaker 'httpbin' exceeded slow call rate threshold. Current slow call rate: 80.0
Event STATE_TRANSITION published: CircuitBreaker 'httpbin' changed state from CLOSED to OPEN
## 서킷 브레이커 오픈된 경우 추가 요청 허용하지 않음
Event NOT_PERMITTED published: CircuitBreaker 'httpbin' recorded a call which was not permitted.
- 조건 #2 : 외부 시스템 호출 초과 제한 테스트
# 허용 개수 초과 후 추가 요청이 제한되는지 확인하기 위한 호출
for i in $(seq 1 30); do curl "http://localhost:8080/v1/test/get"; done
# 주요 로그 확인
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is io.github.resilience4j.ratelimiter.RequestNotPermitted: RateLimiter 'httpbin' does not permit further calls] with root cause
io.github.resilience4j.ratelimiter.RequestNotPermitted: RateLimiter 'httpbin' does not permit further calls
...
전체 코드 확인하기
참고
- https://resilience4j.readme.io/docs
- https://github.com/OpenFeign/feign
- https://cloud.spring.io/spring-cloud-netflix/multi/multi_spring-cloud-feign.html
- https://www.baeldung.com/netflix-feign-vs-openfeign
- https://reflectoring.io/comparison-of-java-http-clients/
- https://stackoverflow.com/questions/42199614/jersey-rest-client-with-apache-http-client-4-5-vs-retrofit
- https://square.github.io/
- https://github.com/square/okhttp/issues/3472
- https://www.mocklab.io/blog/which-java-http-client-should-i-use-in-2020/
- https://square.github.io/okhttp/3.x/okhttp/okhttp3/OkHttpClient.html
- https://github.com/postmanlabs/httpbin
- https://stackoverflow.com/questions/5725430/http-test-server-accepting-get-post-requests