이번 포스팅에서는 서킷브레이커(Circuitbreaker)
적용에 대해 알아보자.
우선 서킷브레이커가 무엇인지는 다른 블로그에도 많이 정리되어 있으니 여기서는 간략히만 짚고 넘어가자면, 사전적 정의로는 회로 차단기라고 해서 문제 있는 회로에 대해 전류가 더 흐르지 않도록 차단시키는 장치를 일컫는 용어로서, 이와 유사한 역할을 하는 서킷브레이커 패턴이 MSA 구성 요건으로 존재한다. MSA 구조의 애플리케이션은 여러 서비스들로 구성되어 서비스간 통신을 통해 그 기능들을 수행하게 되는데, 이 서비스들 중에는 문제 또는 비정상인 상태의 서비스도 존재할 가능성을 항상 갖게 된다.
이때 서킷브레이커를 적용하지 않았다면 비정상인 서비스에 대한 요청이 있게 될 경우 시스템의 에러를 그대로 반환하거나 비정상적인 응답을 받게 되고, 이로 인해 문제가 다른 서비스들로 번지거나 또는 쌓인 요청들에 의해 부하가 발생해 해당 서비스 복원에도 어려움이 발생할 수 있게 된다. 반면 서킷브레이커를 적용한다면 요청에 대해 실패를 반복하면 해당 서비스를 비정상이라고 판단하여 접근을 차단하고 그에 대한 응답값만 무시한 채 다른 서비스들은 정상적인 기능을 수행하도록 할 수도 있고, 서비스 복원 중에도 또 다른 문제가 발생하지 않도록 막아줄 수 있다. 항목으로 간단히 정리하면 다음과 같다.
- 장애 감지 및 격리
- 자동 시스템 복구
- 빠른 실패 및 고객 응답
- 장애 서비스로의 부하 감소
- 장애 대안 커스터마이징
나아가 이 서킷브레이커와 함께 알아두면 좋은 관련된 개념으로 보상 트랜잭션
과 SAGA 패턴
이 있다. 서킷브레이커는 서비스가 비정상일 경우 접근을 차단함으로써 추가적인 문제를 방지하는 데에 의의가 있지만, 분산 시스템에서 데이터 삽입 및 변경 요청 중 문제가 발생한 경우에는 데이터 일관성을 해칠 수 있으므로 대체 작업을 수행하거나 롤백과 같이 작업을 취소할 수 있어야 한다. 이러한 작업을 보상 트랜잭션
이라 하고, SAGA 패턴
역시 분산 시스템에서 트랜잭션을 관리하기 위한 패턴으로 여러 단계의 연속된 작업으로 구성을 정의하고, 각 단계가 성공적으로 완료되지 않을 경우 보상 트랜잭션을 통해 롤백하도록 하는 전략을 말한다. 따라서 서킷브레이커를 적용하게 된다면 롤백까지 완벽히 수행하도록 구현하는 게 중요하다. 다행히도 Spring에서는 트랜잭션이 실패하면 자연스럽게 롤백이 되도록 하는 @Transactional
어노테이션으로 트랜잭션의 안전성이 보장되기에 예외 처리만 잘 해주면 특별히 추가적인 수고를 들일 필요는 없다.
서론이 많이 길어졌다. 다시 돌아와서, 서킷브레이커의 동작 원리는 다음과 같다. CLOSED
, OPEN
, HALF_OPEN
의 세 가지 상태 구분에 따라 그 기능이 다르게 동작한다.
COLSED
: 정상적인 호출이 이루어진다.OPEN
: 오류가 일정 횟수 이상 또는 일정 시간 이상 발생하면 호출을 차단한다.HALF_OPEN
: 일정 시간 후에 다시 호출을 제한적으로 허용해보고, 성공하면 CLOSED
로, 실패하면 다시 OPEN
으로 돌아간다.Java 진영의 서킷브레이커 라이브러리로는 크게 Hystrix
와 Reslience4J
가 존재한다. 다만 이제는 Resilience4J
만 사용한다고 보면 된다. Hystrix
는 넷플릭스에서 만든 오픈소스이지만 deprecated 되었고, Hystrix
에서도 오픈소스인 Resilience4J
사용을 권장하고 있기 때문이다.
그럼 단계별로 의존성 및 옵션 설정, 그리고 코드들을 살펴보자.
빌드툴로 Maven을 사용하였으므로 xml 형식으로 의존성을 설정하였다.
<properties>
...
<resilience4jVersion>1.7.0</resilience4jVersion>
</properties>
<dependencies>
...
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot2</artifactId>
<version>${resilience4jVersion}</version>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-all</artifactId>
<version>${resilience4jVersion}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
위와 같이 네 개의 의존성을 추가하게 된다. 하나씩 짚고 넘어가자.
resilience4j-spring-boot2
: Resilience4J
자체에서 필요로 하는 의존성으로, Spring Boot 버전이 2이라면 spring-boot2를 사용하고, 3이라면 spring-boot3를 사용하면 된다.resilience4j-all
: resilience4j-spring-boot2
와는 별개로 Circuitbreaker
나 Retry
등에 대한 의존성들을 포함하고 있는 의존성이다.spring-boot-starter-aop
: Resilience4J
가 제공하는 대부분의 기능들이 Spring AOP를 이용한다.spring-boot-starter-actuator
: Actuator를 통해 Resilience4J
관련 정보를 제공하거나 API 호출을 통한 서킷브레이커 상태 변경에 이용된다.Java 코드로 옵션값을 설정할 수도 있지만 나는 application.yml에 설정하도록 하였다.
resilience4j.circuitbreaker:
circuit-breaker-aspect-order: 1
configs:
default:
slidingWindowType: COUNT_BASED
registerHealthIndicator: true
slidingWindowSize: 10
failureRateThreshold: 40
minimumNumberOfCalls: 7
waitDurationInOpenState: 10s
slowCallDurationThreshold: 3000
slowCallRateThreshold: 60
permittedNumberOfCallsInHalfOpenState: 5
automaticTransitionFromOpenToHalfOpenEnabled: true
recordExceptions:
- com.develemon.doran.orderservice.exception.RecordException
ignoreExceptions:
- com.develemon.doran.orderservice.exception.IgnoreException
instances:
baseCircuitBreakerConfig:
baseConfig: default
서킷브레이커 인스턴스의 이름을 baseCircuitBreakerConfig
으로 지정하여 이를 default로 등록하였다.
slidingWindowType
: COUNT_BASED
와 TIME_BASED
가 가능한데, default 값이 COUNT_BASED
이고, 여기서는 COUNT_BASED
로 지정하였다.registerHealthIndicator
: 서킷브레이커의 상태를 Spring Boot의 헬스 체크 엔드포인트에 등록한다. 이를 통해 애플리케이션의 상태를 모니터링할 수 있다.slidingWindowSize
: 슬라이딩 윈도우의 크기 옵션으로, 10번의 호출을 기준으로 상태를 결정하도록 하였다.failureRateThreshold
: 서킷브레이커의 상태가 OPEN
으로 변경될 때의 실패율 임계값을 백분율로 설정한다. 여기서는 실패율이 40%를 초과하면 서킷브레이커가 열리도록 하였다.minimumNumberOfCalls
: 최소 7번까지는 무조건 CLOSED
로 가정하고 호출한다.waitDurationInOpenState
: 서킷브레이커가 OPEN
상태를 유지하는 시간 옵션으로, 이 시간이 지나면 서킷브레이커는 HALF_OPEN
상태로 전환된다.slowCallDurationThreshold
: 몇 ms 동안 요청이 처리되지 않으면 실패로 간주할지 정하는 옵션으로, 여기서는 3초로 설정하였다.slowCallRateThreshold
: slidingWindowSize
중 몇 %가 slowCall이면 서킷브레이커 상태를 OPEN
으로 전환할지 정하는 옵션으로, 여기서는 60%로 지정하였다.permittedNumberOfCallsInHalfOpenState
: HALF_OPEN
상태에서 CLOSED
로 가기 위해 호출이 필요한 횟수로, 여기서는 5회로 지정하였다.automaticTransitionFromOpenToHalfOpenEnabled
: OPEN
상태에서 HALF_OPEN
으로 자동으로 가게 할 것인지에 대한 옵션그리고 리트라이도 사용하므로 다음과 같이 설정해주었다.
resilience4j.retry:
retry-aspect-order: 2
configs:
default:
maxAttempts: 3
waitDuration: 1000
retryExceptions:
- com.develemon.doran.orderservice.exception.RetryException
ignoreExceptions:
- com.develemon.doran.orderservice.exception.IgnoreException
instances:
baseRetryConfig:
baseConfig: default
마찬가지로 리트라이 인스턴스의 이름을 baseRetryConfig
으로 지정하여 이를 default로 등록하였다.
maxAttempts
: 최대 요청 시도 횟수를 3으로 지정waitDuration
: 대기 시간을 1초로 지정여기서 retry-aspect-order
를 2로 설정하였고, 서킷브레이커에서는 circuit-breaker-aspect-order
를 1로 설정하였는데, Resilience4J
모듈에 우선순위가 있어 우선순위를 직접 지정하기 위해 이 옵션을 사용한 것이다. 참고로 우선순위가 높은 것부터 나열하면 다음과 같다. 리트라이가 서킷브레이커보다 낮은 우선순위를 가지므로 높은 우선순위를 갖도록 하였다.
@Slf4j
@Service
public class ResilientItemServiceClient {
@Autowired
private ItemServiceClient itemServiceClient;
@Autowired
private SlackService slackService;
private static final String BASE_CIRCUIT_BREAKER_CONFIG = "baseCircuitBreakerConfig";
private static final String BASE_RETRY_CONFIG = "baseRetryConfig";
@Retry(name = BASE_RETRY_CONFIG)
@CircuitBreaker(name = BASE_CIRCUIT_BREAKER_CONFIG, fallbackMethod = "getItemSimpleWithoutPriceFallback")
public List<ItemSimpleWithoutPriceResponse> getItemSimpleWithoutPrice(List<String> itemUuidList) throws InterruptedException {
try {
return itemServiceClient.getItemSimpleWithoutPrice(itemUuidList);
} catch (Exception e) {
replacementCall("getOrderItems");
return new ArrayList<>();
}
}
public List<ItemSimpleWithoutPriceResponse> getItemSimpleWithoutPriceFallback(List<String> itemUuidList, RecordException exception) {
log.info("Fallback for getItemSimpleWithoutPrice: {}", exception.toString());
return new ArrayList<>();
}
public List<ItemSimpleWithoutPriceResponse> getItemSimpleWithoutPriceFallback(List<String> itemUuidList, IgnoreException exception) {
log.info("Fallback for getItemSimpleWithoutPrice: {}", exception.toString());
return new ArrayList<>();
}
public List<ItemSimpleWithoutPriceResponse> getItemSimpleWithoutPriceFallback(List<String> itemUuidList, CallNotPermittedException exception) {
log.info("Fallback for getItemSimpleWithoutPrice: {}", exception.toString());
HashMap<String, String> data = new HashMap<>();
data.put(exception.toString(), exception.getMessage());
slackService.sendMessage("[ORDER-SERVICE] o.s.c.l.core.RoundRobinLoadBalancer: No servers available for service: item-service", data);
throw exception;
}
private void replacementCall(String param) throws InterruptedException {
throw new RecordException("record exception");
}
}
OpenFeign
을 통해 서비스간 통신에 사용되는 ItemServiceClient
의 API에 서킷브레이커와 리트라이를 위와 같이 적용하였고, 해당 메서드의 리턴 타입과 동일하게 하여 fallback 메서드를 만들고 추가로 파라미터에 예외객체를 지정해줌으로써 API 호출에 문제가 발생하면 fallback 메서드가 자동으로 대신 실행될 수 있게 한다. 여기서는 예외객체를 분리하여 fallback 메서드를 지정해준 것 중에 CallNotPermittedException
이라는 예외객체가 던져지면 에러 메세지를 Slack으로 전송하도록 하였다.
MSA 구조인만큼 설계 요구사항의 복잡도가 높다. 위와 같이 서비스마다 서비스간 통신에 서킷브레이커를 공통으로 적용할 바에 차라리 API 게이트웨이에 직접 설정해주게 되면 관리가 보다 간소화되지 않을까?
충분히 합리적인 생각이고, 실제로 API 게이트웨이 적용한다고도 한다. 다만 나는 또 한편으로 서비스간 통신에 사용되는 API를 API 게이트웨이 서비스에 몰아 넣어주게 된다면 너무 많은 부하가 걸릴 것 같다는 생각과 함께, 만약 API 게이트웨 서비스 자체가 죽는다면 어떡하지? 라는 생각도 들었다. 그래서 일단은 서비스마다 서킷브레이커 설정을 주고 동시에 API 게이트웨이 서비스에도 설정해주기로 하였다.
이상 서킷브레이커 적용 포스팅은 여기서 마친다.