스프링 클라우드 넷플릭스 히스트릭스(Spring Cloud Netflix Hystrix)
RECOMMEND POSTS BEFORE THIS
1. Netflix Hystrix
마이크로서비스 아키텍처(microservice architecture)를 성공적으로 구축한 넷플릭스(netflix)가 제공하는 OSS(open source software)이다. Hystrix 컴포넌트는 애너테이션과 설정만으로 손쉽게 회로 차단기(circuit breaker) 패턴을 적용할 수 있다. 서비스 사이에 전파되는 장애를 차단하여 서비스 일부가 망가지더라도 시스템은 정상적 혹은 부분적으로 운영할 수 있도록 돕는다.
2. Practice
FeignClient와 함께 연동한 실습을 수행하였다. FeignClient 내부엔 다음과 같은 의존성이 존재하여 별도로 Hystrix 컴포넌트에 대한 의존성을 추가하지 않아도 된다.
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-hystrix</artifactId>
<version>10.10.1</version>
<scope>compile</scope>
</dependency>
다음과 같은 실습 환경을 구축하였다.
- 클라이언트(client) 역할은
JUnit테스트 코드가 수행한다. 서비스A는 회로 차단기가 적용되어 있다.서비스A가서비스B로 API 요청을 수행한다.- 두 경로로 요청을 수행한다.
/timeout-서비스B는 스레드를 정지시켜 의도적으로 타임아웃(timeout)을 발생시킨다./exception-서비스B는 의도적으로 예외를 던진다.
서비스A는 예외를 전달받지만 이를 대체할 응답(fallback plan)을 대신 클라이언트에게 전달한다.- 클라이언트는 예외 대신
서비스A가 전달한 대체 응답을 전달받는다.
2.1. Implemantation of Service A
지금부터 살펴보는 코드는 서비스A 프로젝트의 코드다. application YAML 파일에 다음과 같은 설정을 추가한다.
feign.hystrix.enabled=true설정을 추가한다.- 타임아웃이 발생하기 쉽게 기다리는 시간을 5초로 설정한다.
feign:
hystrix:
enabled: true
client:
config:
default:
connect-timeout: 5000
read-timeout: 5000
BlogController 클래스에 테스트를 위한 엔드포인트를 만든다. 요청을 그대로 서비스B에게 전달한다.
package cloud.in.action.controller;
import cloud.in.action.proxy.BlogClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class BlogController {
private final BlogClient blogClient;
public BlogController(BlogClient blogClient) {
this.blogClient = blogClient;
}
@GetMapping(value = "/timeout")
public String requestWithTimeoutException() {
return blogClient.requestWithTimeoutException();
}
@GetMapping(value = "/exception")
public String requestWithIntentionalException() {
return blogClient.requestWithIntentionalException();
}
}
BlogClient 인터페이스에 회로 차단을 위한 폴백(fallback) 기능을 추가한다. feign.hystrix.* 패키지에 속한 FallbackFactory 클래스를 사용해야 한다. 자세한 내용은 Incompatible FallbackFactory 인스턴스 예외를 다룬 글을 참조한다.
BlogClient는서비스B에게 API 요청을 수행한다.서비스B에 문제가 발생하여 회로 차단기가 회로를 열면 대체 응답이 반환된다.- 대체 응답에 대한 책임은
BlogClientFallbackFactory를 통해 생성된 객체에게 위임한다. BlogClientFallbackFactory는BlogClient에 문제가 생겼을 때를 대비한BlogClientFallbackPlan객체를 생성한다.BlogClientFallbackPlan클래스는BlogClient인터페이스를 구현하여 에러 발생 시 각 메서드별 적절한 응답을 반환한다.
package cloud.in.action.proxy;
import feign.hystrix.FallbackFactory;
import lombok.extern.log4j.Log4j2;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.GetMapping;
@FeignClient(
name = "blog-client",
url = "http://b-service:8080",
fallbackFactory = BlogClientFallbackFactory.class
)
public interface BlogClient {
@GetMapping(path = "/timeout")
String requestWithTimeoutException();
@GetMapping(path = "/exception")
String requestWithIntentionalException();
}
@Log4j2
@Component
class BlogClientFallbackFactory implements FallbackFactory<BlogClient> {
@Override
public BlogClient create(Throwable cause) {
log.error(cause.getMessage(), cause);
return new BlogClientFallbackPlan();
}
class BlogClientFallbackPlan implements BlogClient {
@Override
public String requestWithTimeoutException() {
return "timeout fallback";
}
@Override
public String requestWithIntentionalException() {
return "implicit exception fallback";
}
}
}
2.2. Implemantation of Service B
지금부터는 서비스B의 구현 코드다. 테스트를 위해 BlogController 클래스에 엔드포인트를 만든다. 이 컨트롤러는 고의적으로 예외를 발생시킨다.
/timeout경로는 10초간 스레드를 정지하여 타임아웃 예외를 발생시킨다./exception경로는 일부러 런타임 예외를 발생시킨다.
package cloud.in.action.controller;
import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@Log4j2
@RestController
public class BlogController {
@GetMapping(value = "/timeout")
public String requestWithTimeoutException() {
try {
Thread.sleep(10000);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
return "no time out occur";
}
private boolean implementationException() {
return true;
}
@GetMapping(value = "/exception")
public String requestWithIntentionalException() {
if (implementationException()) {
throw new RuntimeException("exception occur");
}
return "no exception occur";
}
}
3. Test
도커 컴포즈(docker compose)로 테스트 환경을 구축한다. 도커 컴포즈 YAML 파일은 다음과 같이 정의되어 있다.
version: "3.9"
services:
a-service:
build: ./a-service
ports:
- "8080:8080"
b-service:
build: ./b-service
ports:
- "8081:8080"
도커 컨테이너들을 실행한다.
$ docker-compose up
...
[+] Running 3/3
- Network 2021-03-13-spring-cloud-netflix-hystrix_default Created 0.0s
- Container 2021-03-13-spring-cloud-netflix-hystrix-a-service-1 Created 0.1s
- Container 2021-03-13-spring-cloud-netflix-hystrix-b-service-1 Created 0.1s
이제 모든 준비가 완료됬다. 테스트 코드를 실행해보자. 테스트 코드는 서비스A 모듈에 존재한다.
- 테스트에서 타임아웃이 발생하지 않도록
connectTimeout,readTimeout을 100초씩 설정한다. - 테스트를 위한
FeignClient사용 시 회로 차단기가 동작하지 않도록 설정한다.
package cloud.in.action;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
@FeignClient(name = "test-client", url = "http://localhost:8080")
interface TestClient {
@GetMapping(path = "/timeout")
String requestWithTimeoutException();
@GetMapping(path = "/exception")
String requestWithIntentionalException();
}
@Log4j2
@SpringBootTest(value = {
"feign.hystrix.enabled=false",
"feign.client.config.default.connect-timeout=100000",
"feign.client.config.default.read-timeout=100000"
})
class AServiceApplicationTests {
@Autowired
private TestClient testClient;
@Test
void request_api_expect_without_exception() {
log.info(testClient.requestWithTimeoutException());
log.info(testClient.requestWithIntentionalException());
}
}
위 테스트 코드를 실행하면 다음과 같은 로그를 확인할 수 있다.
서비스A로 요청한 두 경로 모두BlogClientFallbackPlan객체에 의해 대체된 응답을 전달받는다.
2023-02-16 00:39:10.921 INFO 22208 --- [ main] c.in.action.AServiceApplicationTests : timeout fallback
2023-02-16 00:39:11.000 INFO 22208 --- [ main] c.in.action.AServiceApplicationTests : implicit exception fallback
서비스A와 서비스B에 출력된 로그를 살펴보자.
서비스A는netflix-hystrix-a-service이다.서비스B는netflix-hystrix-b-service이다.서비스A는서비스B의 요청을 기다리다 타임아웃이 발생한다.- 해당 예외는
HystrixTimeoutException으로 전달된다.
- 해당 예외는
서비스B는 의도적인 예외를 반환한다.- 로그 내용 - java.lang.RuntimeException: exception occur
서비스A는서비스B의 예외를 전달받고 관련 로그를 출력한다.- 로그 내용 - feign.FeignException$InternalServerError: [500] during [GET] to [http://b-service:8080/exception]
- 해당 예외 처리 스택을 보면
Hystrix컴포넌트가 상위 메서드에 있음을 알 수 있다.
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 | com.netflix.hystrix.exception.HystrixTimeoutException: null
...
2021-03-13-spring-cloud-netflix-hystrix-b-service-1 | 2023-02-15 15:39:10.940 ERROR 1 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: exception occur] with root cause
2021-03-13-spring-cloud-netflix-hystrix-b-service-1 |
2021-03-13-spring-cloud-netflix-hystrix-b-service-1 | java.lang.RuntimeException: exception occur
2021-03-13-spring-cloud-netflix-hystrix-b-service-1 | at cloud.in.action.controller.BlogController.requestWithIntentionalException(BlogController.java:28) ~[classes!/:0.0.1-SNAPSHOT]
...
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 | 2023-02-15 15:39:10.995 ERROR 1 --- [x-blog-client-2] c.i.a.proxy.BlogClientFallbackFactory : [500] during [GET] to [http://b-service:8080/exception] [BlogClient#requestWithIntentionalException()]: [{"timestamp":"2023-02-15T15:39:10.948+0000","status":500,"error":"Internal Server Error","message":"exception occur","path":"/exception"}]
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 |
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 | feign.FeignException$InternalServerError: [500] during [GET] to [http://b-service:8080/exception] [BlogClient#requestWithIntentionalException()]: [{"timestamp":"2023-02-15T15:39:10.948+0000","status":500,"error":"Internal Server Error","message":"exception occur","path":"/exception"}]
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 | at feign.FeignException.serverErrorStatus(FeignException.java:231) ~[feign-core-10.10.1.jar!/:na]
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 | at feign.FeignException.errorStatus(FeignException.java:180) ~[feign-core-10.10.1.jar!/:na]
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 | at feign.FeignException.errorStatus(FeignException.java:169) ~[feign-core-10.10.1.jar!/:na]
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 | at feign.codec.ErrorDecoder$Default.decode(ErrorDecoder.java:92) ~[feign-core-10.10.1.jar!/:na]
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 | at feign.AsyncResponseHandler.handleResponse(AsyncResponseHandler.java:96) ~[feign-core-10.10.1.jar!/:na]
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 | at feign.SynchronousMethodHandler.executeAndDecode(SynchronousMethodHandler.java:138) ~[feign-core-10.10.1.jar!/:na]
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 | at feign.SynchronousMethodHandler.invoke(SynchronousMethodHandler.java:89) ~[feign-core-10.10.1.jar!/:na]
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 | at feign.hystrix.HystrixInvocationHandler$1.run(HystrixInvocationHandler.java:109) ~[feign-hystrix-10.10.1.jar!/:na]
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 | at com.netflix.hystrix.HystrixCommand$2.call(HystrixCommand.java:302) ~[hystrix-core-1.5.18.jar!/:1.5.18]
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 | at com.netflix.hystrix.HystrixCommand$2.call(HystrixCommand.java:298) ~[hystrix-core-1.5.18.jar!/:1.5.18]
2021-03-13-spring-cloud-netflix-hystrix-a-service-1 | at rx.internal.operators.OnSubscribeDefer.call(OnSubscribeDefer.java:46) ~[rxjava-1.3.8.jar!/:1.3.8]
댓글남기기