본문 바로가기
관리자

Programming-[Backend]/SpringBoot

CircuitBreaker: Resilience4j - 지연 및 실패 전파 방지하기

728x90
반응형

 

배경

여러 서비스들을 운영하는 환경에서 Gateway처럼 공통으로 쓰는 서버가 있다고 가정해보면, 이 공통으로 쓰는 서버에서 지연이 발생했을 때 해당 지연이 모든 서비들로 전파되는 문제가 발생할 수 있다.

 

 

이를 막기 위해 Resilience4j 라이브러리의 CircuitBreaker 기능이 있다. 일정 호출 수, 지연 시간 등의 기준을 두고 그 시간이 넘어가면 빠르게 각 서비스들에 Response를 주는 방식이다.

 

그럼 지연 대기를 위해 각 서비스에서 대기 중이던 스레드들이 Response를 빠르게 받고 자원 해제가 되어 부하 지연 전파가 없어지게 된다.

 

더 자세한 내용은 참고 자료들을 보면 된다.

ref.) https://wellbell.tistory.com/256

 

 

 

적용 내용 살펴보기

 

아래와 같이 설정들을 적용했다. 자세한 설정 인자값들은 성능 테스트 및 모니터링을 통해 조정해야할 것이다.

import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.SlidingWindowType;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import java.time.Duration;

import io.github.resilience4j.timelimiter.TimeLimiterConfig;
import io.github.resilience4j.timelimiter.TimeLimiterRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.client.ResourceAccessException;

@Configuration
@EnableAspectJAutoProxy
public class Resilience4jConfig {

  @Bean
  public TimeLimiterRegistry timeLimiterRegistry() {
    TimeLimiterConfig config = TimeLimiterConfig.custom()
            .timeoutDuration(Duration.ofSeconds(10)) // 10초 동안만 대기
            .build();
    //클라이언트에게 TimeoutException 또는 504 Gateway Timeout 에러 발생함
    return TimeLimiterRegistry.of(config);
  }

  @Bean
  public CircuitBreakerRegistry circuitBreakerRegistry() {
    // 기본 설정
    CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
            .slowCallDurationThreshold(Duration.ofSeconds(10)) // 10초 이상 지연되면 실패로 간주
            .slowCallRateThreshold(50) // 지연된 호출의 비율이 50%를 넘으면 서킷 열림
            .failureRateThreshold(100) // (선택) 실패율 계산을 비활성화
            .waitDurationInOpenState(Duration.ofSeconds(5)) // 열림 상태 유지 시간
            .permittedNumberOfCallsInHalfOpenState(3) // 반열림 상태에서 허용되는 호출 수
            .minimumNumberOfCalls(100) // 서킷 브레이커 작동을 위한 최소 호출 수
            .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
            .slidingWindowSize(100) // 슬라이딩 윈도우 크기
            .recordExceptions(ResourceAccessException.class) // 실패로 간주할 예외들
            //ResourceAccessException은 restTemplate의 타임아웃 시 발생
            .ignoreExceptions(
                    HttpRequestMethodNotSupportedException.class
            ) // 실패나 성공으로 간주하지 않고 무시할 예외들
            .writableStackTraceEnabled(false) // 예외 래핑 방지
            .build();

    CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(circuitBreakerConfig);
    registry.circuitBreaker("defaultCbService");

    // 기본 설정을 기반으로 CircuitBreakerRegistry 생성
    return registry;
  }

}

 

 

 

위 설정대로라면 100개의 호출 중, 지연시간 10초가 넘어 실패가 발생하는 비율이 50퍼센트가 되면 서킷브레이커가 작동한다(닫혀있던 스위치가 열린다).

 

ignoreExceptions에 실패로 간주하지 않을 예외들을 넣어주었다. 이렇게 하지 않으면 일반적으로 발생하는 exception들도 서킷브레이커의 작동 조건에 포함되기 때문이다.

 

TimeLimiter 설정을 추가하여 서버에서 10초 이상의 지연이 발생하면 실패로 간주하도록 ResourceAccessException을 recordExceptions에 추가해주었다.

 

그리고 각 Controller에서 작동하도록 아래와 같이 각 Controller에 어노테이션들을 추가해주었다. AOP 방식으로 작동하여 RequestMapping 전체에 적용할 수 있는 설정도 있으나, 일단 개별 Controller들에만 적용했다.

 

@Slf4j
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/...")
@Tag(...)
@CircuitBreaker(name = "defaultCbService")
public class ...Controller {

 

 

 

테스트는 다음 코드와 같이 작성했다. Controller를 테스트하는 것은 아니고, 서킷브레이커가 제대로 작동하는지 설정에 따른 테스트다. 바퀴 자체를 테스트하는 것일 수 있으나, 처음 적용하는 사람에게는 이해를 돕기 위해 이런 테스트도 필요할 수 있다.

public class CircuitBreakerTest {

    @Test
    public void testCircuitBreaker() throws InterruptedException {
        // 테스트용 CircuitBreaker 설정 생성
        CircuitBreakerConfig testConfig = CircuitBreakerConfig.custom()
                .slowCallDurationThreshold(Duration.ofMillis(200)) // 200ms 이상 지연되면 실패로 간주
                .slowCallRateThreshold(50) // 느린 호출 비율 50% 초과 시 서킷 열림
                .failureRateThreshold(100) // 실패율 100%일 때 서킷 열림
                .waitDurationInOpenState(Duration.ofSeconds(2)) // 서킷 열림 상태 유지 시간
                .minimumNumberOfCalls(5) // 최소 호출 수
                .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
                .slidingWindowSize(5) // 슬라이딩 윈도우 크기
                .build();

        // 테스트용 CircuitBreakerRegistry 생성
        CircuitBreakerRegistry testRegistry = CircuitBreakerRegistry.of(testConfig);
        CircuitBreaker circuitBreaker = testRegistry.circuitBreaker("testCbService");

        // 최소 호출 수(5) 충족 및 느린 호출 테스트
        for (int i = 0; i < 5; i++) {
            if (i < 3) {
                simulateSlowCall(circuitBreaker, 300); // 300ms 지연
            } else {
                simulateFastCall(circuitBreaker); // 빠른 호출
            }
        }

        // 서킷 브레이커 상태 확인 전 대기
        TimeUnit.SECONDS.sleep(1);

        // 서킷 브레이커 상태 확인
        assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN);
        System.out.println("#####CircuitBreaker 상태: " + circuitBreaker.getState());
    }

    private void simulateSlowCall(CircuitBreaker circuitBreaker, int delayMillis) {
        try {
            circuitBreaker.executeCallable(() -> {
                TimeUnit.MILLISECONDS.sleep(delayMillis);
                return "OK";
            });
        } catch (Exception e) {
            // 예외 처리
        }
    }

    private void simulateFastCall(CircuitBreaker circuitBreaker) {
        try {
            circuitBreaker.executeCallable(() -> "OK");
        } catch (Exception e) {
            // 예외 처리
        }
    }
}
728x90
반응형