Spring Cloud 를 이용한 MSA #4. 장애처리(Resilience4j)

Bobby·2023년 2월 18일
1

spring cloud

목록 보기
4/6
post-thumbnail

💊 Resilience4j

코어모듈은 다음과 같다.

  • resilience4j-circuitbreaker: Circuit breaking
  • resilience4j-ratelimiter: Rate limiting
  • resilience4j-bulkhead: Bulkheading
  • resilience4j-retry: Automatic retrying (sync and async)
  • resilience4j-cache: Result caching
  • resilience4j-timelimiter: Timeout handling

circuitbreaker 와 retry를 사용해 본다.


💊 설정

  • springBoot version = '2.7.8'
  • resilience4j vertion = '1.7.0'

의존성

implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'


💊 CircuitBreaker

상태

  • CircuitBreaker는 CLOSED, OPEN 및 HALF_OPEN의 세 가지 상태가 있다.
상태설명
CLOSED정상 상태
OPEN오류 상태
HALF_OPENOPEN 상태에서 일정 시간 후 장애여부를 판단하여 상태가 변경되기 위해 대기중인 상태

기본 시나리오

  1. 다수의 API 요청
  2. API 호출 실패율이 설정해놓은 값을 넘으면 OPEN 상태로 변경
  3. OPEN 상태가 되면 다시 요청이 들어왔을 때 더이상 요청을 수행하지 않고 설정해놓은 응답 값을 빠르게 리턴한다.
  4. OPEN 상태에서 일정 시간이 지나면 HALF_OPEN 상태로 변경
  5. HALF_OPEN 상태에서 다시 외부 API를 호출
  6. 정상 응답할 경우 CLOSED 상태로 변경 / 장애 발생시 다시 OPEN 상태로 변경

옵션

옵션기본값설명
failureRateThreshold50실패율이 설정값보다 크거나 같으면 서킷브레이커가 OPEN 상태로 전환되고 호출 차단
slowCallDurationThreshold60000[ms]호출시간이 설정값보다 길면 느린호출로 판단
slowCallRateThreshold100느린호출 비율이 설정값보다 크거나 같으면 서킷브레이커가 OPEN 상태로 전환되고 호출 차단
permittedNumberOfCallsInHalfOpenState10HALF_OPEN 상태일 때 허용되는 호출 수
maxWaitDurationInHalfOpenState0[ms]HALF_OPEN 상태에서 대기하는 최대 시간
slidingWindowTypeCOUNT_BASED서킷브레이커가 닫힐 때 호출 결과를 기록하는데 사용되는 슬라이딩 윈도우의 유형(COUNT_BASED 또는 TIME_BASED)
slidingWindowSize100슬라이딩 윈도우의 크기
minimumNumberOfCalls100느린 호출율을 계산하기위해 필요한 최소 호출 수
waitDurationInOpenState60000[ms]OPEN -> HALF_OPEN 으로 변경 되기까지 대기 시간
automaticTransitionFromOpenToHalfOpenEnabledfalseOPEN -> HALF_OPEN 으로 변경 될 때 자동 여부
recordExceptionsempty실패로 측정할 예외 리스트
ignoreExceptionsempty성공/실패 포함 무시할 예외 리스트
recordFailurePredicatethrowable -> true특정 예외가 실패로 측정되도록 하는 커스텀예외: 기본값으로 모든 예외는 실패로 기록
ignoreExceptionPredicatethrowable -> false특정 예외가 측정되지 않도록 하는 커스텀예외: : 기본값으로 모든 예외는 무시되지 않는다.
  • 기본 옵션으로 설정 생성

옵션 예시

  • slidingWindowType = 5
  • minimumNumberOfCalls = 3
  • failureRateThreshold = 50
  • slowCallDurationThreshold = 3000(ms)

COUNT_BASED

호출 순번123456...
정상여부successfailsuccesssuccessfailfail...
실패율(%)01/2=501/3=331/4=252/5=403/5=60...
상태CLOSEDCLOSEDCLOSEDCLOSEDCLOSEDOPEN...
  • 예외가 발생하거나 호출이 slowCallDurationThreshold 이상 걸리면 fail로 카운트
  • 2번 호출에서 실패율이 50%가 되었지만 아직 minimumNumberOfCalls를 채우지 못했으므로 CLOSED 상태 유지
  • 윈도우 사이즈가 5인데 6번 호출이 들어오면 가장 오래된 1번 호출을 제외한다.

TIME_BASED

시간 흐름(초)123456...
호출 횟수103242...
실패 횟수001122...
실패율(%)--1/4=252/6=334/10=406/11=55...
상태CLOSEDCLOSEDCLOSEDCLOSEDCLOSEDOPEN...
  • 초 마다 결과를 저장
  • 3초 이 후 부터 초 마다 5초 이내의 실패율 계산

설정 등록

  • 설정하지 않으면 기본값으로 동작한다.
CircuitBreakerRegistry circuitBreakerRegistry = CircuitBreakerRegistry.ofDefaults();
  
CircuitBreaker circuitBreakerWithDefaultConfig = circuitBreakerRegistry.circuitBreaker("test");
  • 커스텀 옵션
CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
  .failureRateThreshold(50)
  .slowCallRateThreshold(50)
  .waitDurationInOpenState(Duration.ofMillis(1000))
  .slowCallDurationThreshold(Duration.ofSeconds(2))
  .permittedNumberOfCallsInHalfOpenState(3)
  .minimumNumberOfCalls(10)
  .slidingWindowType(SlidingWindowType.TIME_BASED)
  .slidingWindowSize(5)
  .recordException(e -> INTERNAL_SERVER_ERROR
                 .equals(getResponse().getStatus()))
  .recordExceptions(IOException.class, TimeoutException.class)
  .ignoreExceptions(BusinessException.class, OtherBusinessException.class)
  .build();
  • 생성한 설정 공유
CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
  .failureRateThreshold(70)
  .build();

circuitBreakerRegistry.addConfiguration("someSharedConfig", config);

CircuitBreaker circuitBreaker = circuitBreakerRegistry
  .circuitBreaker("name", "someSharedConfig");

bean 으로 설정 관리

  • 빈으로 등록
@Configuration
public class Resilience4jConfig {
    
    @Bean
    public CircuitBreaker circuitBreaker(CircuitBreakerRegistry circuitBreakerRegistry) {
        return circuitBreakerRegistry.circuitBreaker(
                "customCircuitBreaker",
                CircuitBreakerConfig.custom()
                        .failureRateThreshold(50)
                        .slowCallRateThreshold(50)
                        .slowCallDurationThreshold(Duration.ofSeconds(3))
                        .permittedNumberOfCallsInHalfOpenState(3)
                        .maxWaitDurationInHalfOpenState(Duration.ofSeconds(3))
                        .slidingWindowType(COUNT_BASED)
                        .slidingWindowSize(10)
                        .minimumNumberOfCalls(5)
                        .waitDurationInOpenState(Duration.ofSeconds(1))
                        .build()
        );
    }
}

yml 파일로 설정 관리

resilience4j:
  circuitbreaker:
    configs:
      default:
        failureRateThreshold: 50
        slowCallRateThreshold: 50
        slowCallDurationThreshold: 3000
        permittedNumberOfCallsInHalfOpenState: 3
        maxWaitDurationInHalfOpenState: 3000
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 10
        minimumNumberOfCalls: 5
        waitDurationInOpenState: 1000
    instances:
      customCircuitBreaker:
        baseConfig: default

메소드에 적용

  • @CircuitBreaker 어노테이션으로 쉽게 적용할 수 있다.
옵션설명
name등록한 서킷 브레이커의 이름
fallbackMethod요청이 실패할 경우 실행 메소드

요청메소드

  • 응답시간이 5초 이상 걸리는 요청이라고 가정
  • callFallback 메소드는 call 메소드에 대한 Fallback 메소드 이므로 리턴 타입이 같아야 한다.
@Slf4j
@Service
public class TestService {

    @CircuitBreaker(name = "customCircuitBreaker", fallbackMethod = "callFallback")
    public String call() throws InterruptedException {

        long before = System.currentTimeMillis();
        Thread.sleep(5000L);
        long after = System.currentTimeMillis();
        log.info("[TestService] call => {}ms", after - before);

        return "success";
    }

    private String callFallback(Exception e) {
        log.info("[TestService] callFallback");
        return "fallback";
    }
}

실행

@Test
void circuit_breaker_test() {
    for (int i = 0; i < 20; i++) {
        testService.call();
    }
}

⚙️ 조건은 다음과 같다.
failureRateThreshold: 50
slowCallRateThreshold: 50
slowCallDurationThreshold: 3000
slidingWindowSize: 10
minimumNumberOfCalls: 5

  • 처음 5번의 요청이 5초이상 걸려 상태가 OPEN이 되어 6번째 요청부터는 fallback 메소드가 실행되었다.

Open Feign 인터페이스에 적용

  • 해당 외부 서버 장애로 인해 요청이 5초이상 걸린다고 가정

auth-server

@GetMapping("/test/timeout")
public String timeout() throws InterruptedException {
    Thread.sleep(5000L);
    return "timeout";
}

api-server

⚙️ 조건은 다음과 같다.
failureRateThreshold: 50
slowCallRateThreshold: 50
slowCallDurationThreshold: 3000
slidingWindowSize: 10
minimumNumberOfCalls: 5

@FeignClient(name = "auth-service")
public interface AuthFeignClient {

    @CircuitBreaker(name = "customCircuitBreaker", fallbackMethod = "timeoutFallback")
    @GetMapping("/test/timeout")
    String timeout();

    default String timeoutFallback(Throwable e) {
        return "[AuthFeignClient] timeoutFallback";
    }
}

실행

@Test
void timeout() {
    for (int i = 0; i < 20; i++) {
        String result = authFeignClient.timeout();
        System.out.println("result = " + result);
    }
}

  • 처음 5번의 요청이 5초이상 걸려 상태가 OPEN이 되어 6번째 요청부터는 fallback 메소드가 실행되었다.

💊 Retry

기존의 open feign의 retry대신 resilience4j의 retry를 사용한다.

설정 등록

  • 설정하지 않으면 기본값으로 동작한다.
RetryRegistry retryRegistry = RetryRegistry.ofDefaults();
  • 커스텀 옵션
RetryConfig config = RetryConfig.custom()
  .maxAttempts(2)
  .waitDuration(Duration.ofMillis(1000))
  .retryOnResult(response -> response.getStatus() == 500)
  .retryOnException(e -> e instanceof WebServiceException)
  .retryExceptions(IOException.class, TimeoutException.class)
  .ignoreExceptions(BusinessException.class, OtherBusinessException.class)
  .failAfterMaxAttempts(true)
  .build();

// Retry 생성
RetryRegistry registry = RetryRegistry.of(config);

Retry retryWithDefaultConfig = registry.retry("name1");

RetryConfig custom = RetryConfig.custom()
    .waitDuration(Duration.ofMillis(100))
    .build();

Retry retryWithCustomConfig = registry.retry("name2", custom);

옵션

옵션기본값설명
maxAttempts3최대 시도 횟수(첫 번째 호출도 포함)
waitDuration500[ms]재시도 사이 대기 시간
intervalFunctionnumOfAttempts -> waitDuration실패 후 대기 간격을 수정하는 기능. 기본적으로 고정
intervalBiFunction(numOfAttempts, Either<throwable, result>) -> waitDuration시도 횟수와 결과 또는 예외에 따라 실패 후 대기 간격을 수정. intervalFunction과 함께 사용하면 IllegalStateException이 발생
retryOnResultPredicateresult -> false결과를 재시도해야 하는지 여부를 평가. true를 반환하면 재시도
retryExceptionPredicatethrowable -> true예외를 재시도해야 하는지 여부를 평가. true를 반환하면 재시도
retryExceptionsempty실패로 기록되어 재시도되는 Throwable 클래스 목록. Checked Exceptions를 사용하는 경우 CheckedSupplier를 사용
ignoreExceptionsempty무시되어 재시도되지 않는 Throwable 클래스 목록
failAfterMaxAttemptsfalse재시도가 구성된 maxAttempts에 도달했고 결과가 여전히 retryOnResultPredicate를 전달하지 않는 경우 MaxRetriesExceededException 발생을 활성화 또는 비활성화

bean 으로 설정 관리

  • 빈으로 등록
@Configuration
@RequiredArgsConstructor
public class Resilience4jConfig {

    private final RetryRegistry retryRegistry;

    @Bean
    public Retry retry() {
        return retryRegistry.retry(
                "customRetry",
                RetryConfig.custom()
                        .maxAttempts(3)
                        .waitDuration(Duration.ofMillis(300))
                        .build()
        );
    }
}

yml 파일로 설정 관리

resilience4j:
  retry:
    configs:
      default:
        max-attempts: 3
        waitDuration: 300
    instances:
      customRetry:
        base-config: default

메소드에 적용

  • @Retry 어노테이션으로 쉽게 적용할 수 있다.
옵션설명
name등록한 Retry의 이름
fallbackMethod요청이 실패할 경우 실행 메소드

요청메소드

  • 요청 에러 발생
  • retryFallback 메소드는 retry 메소드에 대한 Fallback 메소드 이므로 리턴 타입이 같아야 한다.
@Slf4j
@Service
public class TestService {

    @Retry(name = "customRetry", fallbackMethod = "retryFallback")
    public String retry() throws InterruptedException {
        log.info("[TestService] retry");
        throw new RuntimeException();
    }

    private String retryFallback(Exception e) {
        log.info("[TestService] retryFallback");
        return "retryFallback";
    }
}

실행

@Test
void retry_test() throws InterruptedException {
    testService.retry();
}

⚙️ 조건은 다음과 같다.
max-attempts: 3 // 최대 3번 시도
waitDuration: 1000 // 1초 간격 시도

  • 1초 간격으로 3번 시도 후 retryFallback 호출

Open Feign 인터페이스에 적용

  • 요청 에러 발생
  • retryFallback 메소드는 retry 메소드에 대한 Fallback 메소드 이므로 리턴 타입이 같아야 한다.

auth-server

@GetMapping("/test/exception")
public void exception() {
    throw new RuntimeException("error");
}

api-server

⚙️ 조건은 다음과 같다.
max-attempts: 3 // 최대 3번 시도
waitDuration: 1000 // 1초 간격 시도

@FeignClient(name = "auth-service")
public interface AuthFeignClient {

    @Retry(name = "customRetry", fallbackMethod = "retryFallback")
    @GetMapping("/test/exception")
    String exception();

    default String retryFallback(Exception e) {
        return "retryFallback";
    }
}

실행

@Test
void exception() {
    String result = authFeignClient.exception();
    System.out.println("result = " + result);
}

  • 1초 간격으로 3번 시도 후 retryFallback 호출

💊 CircuitBreaker, Retry 동시 사용

🧨 Resilience4j 의 코어 모듈들은 어노테이션으로 특정 요청에 모두 한번에 적용 가능.
Resilience4j 의 동작 순서는 다음과 같다.
Bulkhead -> TimeLimiter -> RateLimiter -> CircuitBreaker -> Retry

  • 만약 Retry 시도 후 CircuitBreaker 를 동작하게 하고 싶으면 순서를 바꿔 주어야 한다.
    (Retry 설정한 횟수 시도 후의 결과가 CircuitBreaker 1회 기록된다.)
  • 숫자가 높을수록 우선순위가 높다.

application.yml

resilience4j:
  circuitbreaker:
    
    ...
    
    circuit-breaker-aspect-order: 1
  retry:
    
    ...
    
    retry-aspect-order: 2

적용

@Slf4j
@Service
public class TestService {

    @Retry(name = "customRetry", fallbackMethod = "retryFallback")
    @CircuitBreaker(name = "customCircuitBreaker", fallbackMethod = "circuitBreakerFallback")
    public String retryCall() throws InterruptedException {
        long before = System.currentTimeMillis();
        Thread.sleep(5000L);
        long after = System.currentTimeMillis();
        log.info("[TestService] retryCall => {}ms", after - before);
        throw new RuntimeException();
    }

    private String retryFallback(Exception e) {
        log.info("[TestService] retryFallback");
        return "retryFallback";
    }

    private String circuitBreakerFallback(Exception e) {
        log.info("[TestService] circuitBreakerFallback");
        return "circuitBreakerFallback";
    }

}

결과

  • 1번의 호출에 3번의 재시도를 한 후 retryFallback 실행
  • 5번의 실패 후에 서킷브레이커 OPEN
  • 이후의 요청은 circuitBreakerFallback 실행

코드

profile
물흐르듯 개발하다 대박나기

0개의 댓글