🎯 CircuitBreaker와 Retry가 뭘까?
프로젝트를 진행하면서 주문 서버에 재고 관리 로직이 함께 들어가 있었다.
재고 관리 로직은 주문, 결제, 상품 관리 등 다양한 로직에 사용되어 트래픽이 많을 것으로 예상되어 확장성을 가지면 좋겠다고 생각하였고, 각 기능별로 유지보수를 위하여 서버를 분리해 보는 학습을 해보면 좋겠다고 생각하였다.
회복탄력성은 Open Feign의 resilience4j-circuitbreaker 와 resilience4j-retry를 사용하였다.
MSA 학습을 위하여 뉴스피드 도메인을 간단히 설계하였다.
https://topaz-raincoat-203.notion.site/65cae744cebd44cbabc3771e222e3737?pvs=4
위와 같이 분리된 서버 간에 데이터를 주고받는 경우가 있을 수 있는데, 하나의 서버에서 장애가 발생하면 이전에 요청한 서버도 연속적으로 장애가 날 수 있다. 또한 장애가 발생한 서버에 지속적으로 요청하지 않도록 처리해 줄 필요가 있다.
위와 같이 CircuitBreaker를 적용하여 A가 요청한 B서버에서 장애가 발생했을 때, A도 연달아 장애가 발생하지 않고 B에서 받아올 응답값을 빈 배열 []로 대체한 것을 알 수 있다.
이렇게 CircuitBreaker를 활용하 요청한 서버에서 장애가 발생하였을 경우 대체할 값을 지정해 주도록 할 수 있다.
Retry는 요청이 실패했을 경우에 특정 횟수만큼 다시 한번 요청을 보내는 것을 말한다.
즉, Retry횟수를 5회로 지정해 놓으면, 요청에 대한 응답이 없더라도 총 5번까지 요청을 계속 보내고, 그 후에도 안된다면 에러응답을 반환하는 방식이다.
🎯 CircuitBreaker와 Retry 테스트
CircuitBreaker와 Retry가 무엇인지 간단히 알아보았고, 실제 동작을 확인하는 과정을 거쳤다.
테스트는 A 서버가 B서버에 요청을 하고 응답을 받는 상황으로 구현하였다. 요청방식은 동기방식의 feign client를 사용한다.
아래와 같이 총 3개의 케이스로 테스트하였다.
- Case1. B서버는 A서버의 요청에 대해 확률상 5% 정도는 500번 에러를 응답한다.
- Case2. B서버는 A서버의 요청에 대해 실제 현실 시간상 0 ~ 10초 사이에 요청하면 10초 동안 Block 된다. 10초 이후에 503 에러를 반환한다.
- Case3. B서버는 A서버의 요청에 대해 실제 현실 시간상 0 ~ 10초 사이에 요청하면 그 즉시 500 에러를 응답한다.
테스트를 위한 feignClient를 아래와 같이 A서버에 구현하였다.
@FeignClient(name = "ResilienceNewsfeedFeignClient", url = "http://localhost:8081")
public interface FeignRequest {
@RequestMapping(method = RequestMethod.GET, value = "/errorful/case1", consumes = "application/json")
void case1();
@RequestMapping(method = RequestMethod.GET, value = "/errorful/case2", consumes = "application/json")
void case2();
@RequestMapping(method = RequestMethod.GET, value = "/errorful/case3", consumes = "application/json")
void case3();
}
💡 Case1. 테스트하기
A 서버는 아래와 같이 B서버에 case1 요청을 수행한다.
@GetMapping("/errorful/case1")
public ResponseEntity<String> case1() {
feignRequest.case1();
return ResponseEntity.ok("Case1 success response");
}
B 서버는 아래와 같이 5%의 확률로 500 응답을, 95 확률로 200 응답을 반환한다.
@Slf4j
@RestController
public class ResilienceController {
@GetMapping("/errorful/case1")
public ResponseEntity<String> case1() {
log.info("8081의 case1 호출");
if (new Random().nextInt(100) < 5) {
return ResponseEntity.status(500).body("Internal Server Error");
}
return ResponseEntity.ok("Normal response");
}
}
실제 위의 Case1()에 대해서 Artillery를 통해 1초에 5개씩 1분 동안 요청해 보았다. 아래는 테스트 요청을 위한 스크립트 파일이다.
config:
target: "http://localhost:8082"
phases:
- duration: 60
arrivalRate: 5 # 60초동안, 1초당 5개씩 보낸다. 처리량(Throughput : 5)
name: case1
scenarios: # 한명의 사용자가 요청하는 시나리오
- name: "request error case1"
flow:
- get:
url: "/errorful/case1"
테스트 결과 : 300개의 요청 중 278개의 200 응답, 22개의 500 응답
200 응답 | 278개 |
500 응답 | 22개 |
5% 확률로 500 응답이 반환될 것이라고 기대했던 것과 비슷한 결과가 도출되었다.
이제 CircuitBreaker와 Retry를 각각 적용시켜 보고, 두 개 모두 동시에 적용시켰을 때 테스트 결과를 확인해 보자.
회복탄력성 적용을 위해 A서버에 아래와 같이 코드를 구현하였다.(CircuitBreaker와 Retry 설정 필요)
@GetMapping("/errorful/case1")
public ResponseEntity<String> case1() {
feignRequest.case1();
return ResponseEntity.ok("Case1 success response");
// 1. CircuitBreaker 적용
// CircuitBreaker circuitBreaker = circuitBreakerFactory.create("circuitbreaker");
// String response = circuitBreaker.run(() -> feignRequest.case1(), throwable -> "대체문자열");
// 2. Retry 적용
// Retry retry = retryRegistry.retry("retry");
// String response = Retry.decorateFunction(retry, s -> feignRequest.case1()).apply(1);
// 3. CircuitBreaker + Retry 적용
CircuitBreaker circuitBreaker = circuitBreakerFactory.create("circuitbreaker");
Retry retry = retryRegistry.retry("retry");
String response = circuitBreaker.run(
() -> Retry.decorateFunction(retry, s -> feignRequest.case1()).apply(1), throwable -> "대체문자열");
return ResponseEntity.ok(response);
}
마찬가지로 아래는 1초에 5개씩 1분 동안 요청한 테스트 결과이다.
1. CircuitBreaker 적용 후 결과
200 응답 | 300개 |
2. Retry 적용 후 결과
- 재시도 횟수가 많아지면서 500 에러 응답 확률이 낮아지게 되고, 200 응답이 증가함을 확인하였다.
재시도 횟수/Case | Case1 |
2 | 200응답 : 296개 500응답 : 4개 |
10 | 200응답 : 300개 500응답 : 0개 |
3. Circuirt Breaker + Retry 적용 후 결과
- 2회의 Retry를 적용하였고, 2회 이후에도 500 응답이 발생하면 CircuitBreaker를 통해서 "대체문자열"로 변환되고 300개가 성공한다.
200 응답 | 300개 |
추가적인 Case2와 Case3에 대한 자세한 테스트 결과는 아래에서 확인할 수 있다.
https://topaz-raincoat-203.notion.site/de4027 df576 c4 b3385 b25 f991784 dc93? pvs=4
✏ CircuirtBreaker와 Retry 조금 더 알아보기!
실제로 회복탄력성을 적용 후에 잘 동작하는지 테스트하는 과정을 거쳤다.
사용법을 알았으니 동작방식을 알아두면 좋을 것 같다.
동작방식은 resilience4 j-circuitbreaker의 공식문서를 참고해서 알아보았다.
📖 CircuitBreaker 상태
CircuitBreaker는 아래 그림과 같이 3가지 상태가 존재한다.
Closed - 초기 상태로 아무 문제 없이 동작하는 경우에 Closed상태이다.
Open - 원하는 응답이 오지 않는 정도가 임계값 이상 도달 시(일정 횟수 혹은 일정 시간 이상) CircuitBreaker는 Open상태가 되어 접속을 차단한다.
Half Open - 일정 시간 이후에 Open에서 HalfOpen이 되는데, 이때 서버가 사용가능한 상태라면 Closed로 돌아가고, 아직도 사용불가능이라면 Open상태로 되돌아간다.
나는 CircuitBreaker를 사용할 때 OpenFeign에서 제공하는 resilience4 j-circuitbreaker를 사용하였다.(현재 자바 진영에서 활발하게 사용되는 라이브러리)
resilience4j에서는 CircuritBreaker는 2가지 상태가 더 존재한다.
DISABLED - 항상 요청을 허용하는 상태이다.
FORCED_OPEN - 항상 요청을 거부하는 상태이다.
📖 슬라이딩 윈도우
위에서 Closed상태에서 Open상태로 바뀔 때 개수 기반 슬라이딩 윈도우와, 시간 기반 슬라이딩 윈도우 방식으로 임계값을 측정한다.
즉, 마지막 N개의 요청이 임계값을 넘을 때 Open으로 바꾸도록 설정하거나, 마지막 N초동 안 호출결과가 임계값을 넘을 때 Open으로 바꾸도록 할 수 있다.
개수 기반 윈도우의 경우 원형 배열로 구현되며, O(N)의 공간복잡도가 소요되고, 임계값을 넘었는지에 대한 계산은 O(1)로 수행될 수 있다.(새로운 호출 결과가 들어오면 그때 집계해 버림)
시간 기반 윈도우도 마찬가지로 원형 배열로 구현되며, O(N)의 공간복잡도, O(1)의 시간복잡도로 구현할 수 있다.
마지막으로 이러한 개수 기반과 시간 기반의 상태 업데이트는 모두 원자적으로 이루어지기 때문에 상태 업데이트에 대해 동시성 문제가 발생하지 않는다.(하나의 스레드만 상태를 수 있음)
단, CircuitBreaker에서 메서드 호출 자체를 동기화하지는 않는다.(성능상 문제를 고려하여 메서드 밖에서 동기화하지 않고 메서드 안의 상태를 변경하는 코드만 동기화하는 방식을 사용한다. 이 경우 임계기준보다는 많은 상태가 업데이트될 수 도 있음)
📖 Retry의 설정값들
마지막으로 resilience4j-retry는 인메모리상에서 데이터를 관리할 수 있도록 설정을 제공한다.
- 최대 시도 횟수
- 대기시간
- 시도 횟수, 결과, 예외를 기반으로 실패 후 대기시간 등 사용자 정의
- 재시도 해볼 예외목록, 재시도하지 않을 예외목록
. . .
등등 이외에 여러가지 설정을 제공한다. 자세한 내용은 공식문서를 활용해보면 좋을 것 같다.
참고자료
https://engineering.linecorp.com/ko/blog/circuit-breakers-for-distributed-services
https://resilience4j.readme.io/docs/circuitbreaker
'프로젝트 > 예약상품' 카테고리의 다른 글
[프로젝트 그 이후...] 선착순 주문 요청 프로젝트 개선하기 (0) | 2024.07.13 |
---|---|
[회고] 예약 상품 프로젝트 (0) | 2024.03.04 |
[프로젝트] 상품 오픈시간에 같은 사용자의 여러 요청을 대비하자 (0) | 2024.02.26 |
[프로젝트] redis로 동시성 문제를 접근해보자 (0) | 2024.02.26 |
[프로젝트] 재고정보를 판매오픈전 캐싱할 때 고려사항 (0) | 2024.02.26 |