티스토리 뷰

Spring

[Spring] OpenFeign에 Resilence4J 서킷 브레이커 적용하는 방법과 예시 및 주의사항

망나니개발자 2023. 3. 21. 10:02
반응형

이번에는 Java 진영의 서킷브레이커 라이브러리인 Resilence4J를 OpenFeign에 적용하는 방법에 대해 알아보도록 하겠습니다. 아래의 내용은 공식 문서와 직접 구현 및 테스트한 부분을 바탕으로 작성되었습니다.

 

 

 

 

1. Resilence4J 라이브러리와 구성 요소


[ Resilience4J란? ]

Resilience4J함수형 프로그래밍으로 설계된 경량(lightweight) 장애 허용(fault tolerance) 라이브러리로, 서킷브레이커 패턴을 위해 사용된다. 서킷 브레이커 패턴에 대해서는 앞선 포스팅을 참고하도록 하자.

fault-tolerance란 하나 이상의 구성 요소에 문제가 생겨도 시스템이 중단없이 계속 작동할 수 있는 시스템을 의미한다. Resilience4J를 적용하면 외부 서비스에 장애가 발생하여도 자신의 시스템은 계속 작동할 수 있는 것이다.

참고로 자바 진영의 서킷 브레이커 라이브러리로는 Hystrix도 있다. Hystrix는 넷플릭스에서 만든 오픈소스인데, deprecated되었으므로 Resilience4J를 사용하면 된다. Hystrix에서도 Resilience4J 사용을 권장하고 있다.

 

 

 

 

[ Resilience4J의 구성 요소 ]

Resilience4J에는 여러 가지 코어 모듈이 존재하는데, 설정 가능한 부분들이므로 살펴보도록 하자.

  • CircuitBreaker
  • Bulkhead
  • RateLimiter
  • Retry
  • TimeLimiter

 

 

CircuitBreaker 모듈

CircuitBreaker는 일반적인 서킷 브레이커의 상태(CLOSED, OPEN, HALF_OPEN)에 맞게 유한 상태 기계(Finite state machine, FSM)를 구현한 모듈로, 아래의 기본 상태에 더해 DISABLED와 FORCED_OPEN 이라는 특수한 상태 2개를 추가하였다.

 

 

 

CircuitBreaker는 호출 결과를 저장하고 집계하기 위해 슬라이딩 윈도우를 사용한다. 슬라이딩 윈도우는 마지막 N번의 호출 결과를 기반으로 하는 count-based sliding window(횟수 기반 슬라이딩 윈도우)와 마지막 N초의 결과를 기반으로 하는 time-based sliding window(시간 기반 슬라이딩 윈도우)가 있다.

느린 호출율과 호출 실패율이 서킷브레이커에 설정된 임계값보다 크거나 같다면 CLOSED에서 OPEN으로 상태가 변경된다. 모든 예외 발생은 실패로 간주되므로, 특정 예외만 실패로 간주하고 싶다면 예외 목록을 정의해주면 된다. 그러면 나머지 예외들은 성공으로 간주되며, 혹시나 예외 발생 부분은 결과에서 ignore 하고 싶다면 해당 설정 역시 가능하다. 참고로 이때 최소 호출 수가 있어서, 일정 호출 수가 기록된 후에 느린 호출율과 호출 실패율이 계산된다.

CircuitBreaker는 서킷이 OPEN 상태라면 CallNotPermittedException을 발생시킨다. 그리고 특정 시간이 지나 HALF_OPEN 상태로 바뀌고 설정된 수의 요청만을 허용하고 나머지는 동일하게 예외를 발생시킨다. 그리고 동일하게 느린 호출율과 호출 실패율에 따라 서킷의 상태를 OPEN 또는 CLOSED로 변경한다.

그리고 앞서 설명하였듯 Resilience4J는 DISABLED와 FORCED_OPEN이라는 2가지 특별한 상태를 지원한다. DISABLED는 서킷브레이커를 비활성화하여 항상 요청을 허용하는 상태이며, FORCED_OPEN는 강제로 서킷을 열어두어 항상 요청을 거부하는 상태이다. 해당 상태에서는 상태 전환을 트리거하거나 서킷브레이커를 리셋하는 것이다.

참고로 서킷브레이커는 다음과 같이 Thread-safe 하다.

  • 서빗브레이커의 상태는 AtomicReference에 저장됨
  • 서킷브레이커는 atomic 기능을 사용하여 부작용없는 함수로 상태를 업데이트함
  • 슬라이딩 윈도우에서 요청을 기록하고 스냅샷을 읽는 작업은 동기적으로 처리됨

 

즉, 서킷브레이커는 원자성이 보장되며 특정 시점에 하나의 쓰레드만이 서킷브레이커의 상태나 슬라이딩 윈도우를 업데이트할 수 있는 것이다. 그러나 서킷브레이커는 함수 호출을 동기화하지 않는다. 만약 그렇게 하면 이는 엄청난 성능적인 약점과 병목이 될 것이다. 예를 들어 슬라이딩 윈도우의 크기가 15라고 할지라도, 20개의 쓰레드가 CLOSED 상태에서 호출 여부를 묻는다면 모든 쓰레드는 요청을 보낼 것이다. 슬라이딩 윈도우는 동시에 요청가능한 수가 아니며, 해당 설정은 Bulkhead에서 지원하는 것이다.

 

 

 

Bulkhead 모듈

Resilience4J는 동시 실행의 수를 제어하기 위한 Bulkhead 패턴을 위해 2가지 구현을 제공한다. 그 외에 자세한 설정이나 내용은 공식 문서를 참고하도록 하자.

  • SemaphoreBulkhead: 세마포어를 사용함
  • FixedThreadPoolBulkhead: 제한된 큐와 고정된 쓰레드 풀을 사용함

 

 

 

RateLimiter 모듈

Rate limiting은 API의 확장을 준비하고 서비스의 고가용성과 안정성을 확립하고 위한 필수 기술이다. 이 기술에는 감지된 한도 초과를 처리하는 방법이나 제한하려는 요청에 대한 다양한 옵션을 제공해준다. 그 외에 자세한 설정이나 내용은 공식 문서를 참고하도록 하자.

 

 

 

 

RateLimiter 모듈

Resilience4j는 Retry를 위한 인메모리 RetryRegistry를 제공해준다. 그 외에 자세한 설정이나 내용은 공식 문서를 참고하도록 하자.

 

 

 

TimeLimiter 모듈

CircuitBreaker 모듈처럼 시간 제한을 위한 인메모리 TimeLimiter 역시 제공된다. TimeLimiter 역시 global 설정과 instance별 설정이 가능하며, 2가지 옵션을 제공해준다.

  • timeoutDuration
  • cancelRunningFuture

Resilience4j는 함수형 기반의 라이브러리인만큼 내부적으로 Java의 Future로 요청을 실행한다. 위의 timeoutDuration은 Future의 timemout으로 설정되며, 주어진 시간이 지났을 때 해당 Future를 취소시킬지 여부를 설정한다.

 

 

 

 

 

 

2. OpenFeign에 Resilience4J 적용하기


[ OpenFeign에 Resilience4J 적용하기 ]

  1. 의존성 추가
  2. 설정 파일 추가
  3. recordFailurePredicate 작성
  4. CircuitBreakerNameResolver 작성
  5. CallNotPermittedException 예외 처리
  6. Fallback 처리

 

 

1. 의존성 추가

가장 먼저 Resilience4J 적용에 필요한 의존성을 추가해주어야 한다.

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

 

 

 

2. 설정 파일 추가

그 다음으로 Feign에 서킷브레이커 적용을 활성화해야 하는데, feign.circuitbreak.enabled 값으로 해당 값을 true로 설정해주면 된다. 해당 설정은 FeignAutoConfiguration에 의해 적용되며, 참고로 Spring-Cloud-OpenFeign 4.0.0-SNAPSHOT 버전부터는 spring.cloud.openfeign.circuitbreaker.enabled 값으로 변경되었다.

feign:
  client:
    config:
      default:
        connectTimeout: 5000
        readTimeout: 5000

  # Updated to spring.cloud.openfeign.circuitbreaker.enabled in 4.0.0-SNAPSHOT
  circuitbreaker:
    enabled: true

 

 

 

이후에는 서킷브레이커와 관련된 설정을 넣어야 하는데, circuitBreaker 모듈은 다음의 설정들을 제공해준다.

 

 

여기서 circuitBreaker 인스턴스와 timeLimiter 인스턴스만 활용하며, 모두 default 인스턴스만 설정해두었다. timeLimiter 인스턴스가 하나인 이유는 모두 동일한 값을 사용하기 때문이며, circuitBreaker 인스턴스가 1개인 이유는 뒤에서 다룰 예정이다. 위의 설정값은 각자의 환경마다 달라질 수 있으므로 직접 커스터마이징하면 된다. TimeLimiter는 아래에서 다시 살펴보도록 예정이다.

resilience4j:
  circuitbreaker:
    configs:
      default:
        waitDurationInOpenState: 30s # HALF_OPEN 상태로 빨리 전환되어 장애가 복구 될 수 있도록 기본값(60s)보다 작게 설정
        slowCallRateThreshold: 80 # slowCall 발생 시 서버 스레드 점유로 인해 장애가 생길 수 있으므로 기본값(100)보다 조금 작게 설정
        slowCallDurationThreshold: 5s # 위와 같은 이유로 5초를 slowCall로 판단함. 해당 값은 TimeLimiter의 timeoutDuration보다 작아야 함
        registerHealthIndicator: true
    instances:
      default:
        baseConfig: default
  timelimiter:
    configs:
      default:
        timeoutDuration: 6s # slowCallDurationThreshold보다는 크게 설정되어야 함
        cancelRunningFuture: true

 

 

yaml 파일을 이용하면 설정값을 바탕으로 자동 설정(AutoConfig)이 되는데, 공통으로 사용할 값들은 configs에 정의하고 개별 인스턴스 설정은 instances에 작성해주면 된다. Resilience4J는 Thread-safe와 원자성 보장을 제공하는 ConcurrentHashMap 기반의 인메모리 CircuitBreakerRegistry를 제공해준다. 해당 객체에서 설정 내용이 관리되며, CircuitBreaker 객체를 얻어올 수 있다.

 

 

 

3. recordFailurePredicate 작성

recordFailurePredicate는 어떤 예외를 Fail로 기록할 것인지를 결정하기 위한 Predicate 설정이다. 해당 클래스에서 true를 반환하면 요청 실패로 기록되며, 실패가 쌓이면 서킷이 OPEN 상태로 변경되게 된다. OpenFeign과 연동하는 상황에서는 기본적으로 아래와 같이 작성할 수 있으며, 상황에 따라 커스터마이징해주면 된다.

public class DefaultExceptionRecordFailurePredicate implements Predicate<Throwable> {

    // 반환값이 True면 Fail로 기록됨
    @Override
    public boolean test(Throwable t) {
        // occurs in @CircuitBreaker TimeLimiter
        if (t instanceof TimeoutException) {
            return true;
        }

        // occurs in @OpenFeign
        if (t instanceof RetryableException) {
            return true;
        }

        return t instanceof FeignException.FeignServerException;
    }

}

 

 

만약 timeLimiter에 설정한 연결 시간을 초과하거나 커넥션에 실패했다면 TimeoutException이 발생하는데, 해당 경우에는 서킷을 열어서 요청을 차단해야 하므로 true를 반환하도록 하였다. 또한 RetryableException은 Feign에서 던지는 Retry 가능한 예외인데, 해당 예외도 true로 반환하도록 하였다. 이는 상황에 따라 달라질 수 있으므로 false로 반환이 필요하다면 수정해주도록 하자. 그리고 그 외에 FeignException 중에서 FeignServerException이라면 true를 반환하도록 되어있다.

해당 Predicate 클래스를 적용하려면 yaml 설정 파일에 recordFailurePredicate 내용을 추가해주어야 한다.

resilience4j:
  circuitbreaker:
    configs:
      default:
        waitDurationInOpenState: 30s # HALF_OPEN 상태로 빨리 전환되어 장애가 복구 될 수 있도록 기본값(60s)보다 작게 설정
        slowCallRateThreshold: 80 # slowCall 발생 시 서버 스레드 점유로 인해 장애가 생길 수 있으므로 기본값(100)보다 조금 작게 설정
        slowCallDurationThreshold: 5s # 위와 같은 이유로 5초를 slowCall로 판단함. 해당 값은 TimeLimiter의 timeoutDuration보다 작아야 함
        registerHealthIndicator: true
        recordFailurePredicate: com.mangkyu.openfeign.app.openfeign.circuit.DefaultExceptionRecordFailurePredicate
    instances:
      default:
        baseConfig: default
  timelimiter:
    configs:
      default:
        timeoutDuration: 6s # slowCallDurationThreshold보다는 크게 설정되어야 함
        cancelRunningFuture: true

 

 

 

4. CircuitBreakerNameResolver 작성

CircuitBreaker 인스턴스는 여러 개로 관리될 수 있다. 예를 들어 배달앱이라면 “관리자 서버”, “주문 서버” 등이 있고, 각각을 FeignClient로 호출할 것이다. 서로 다른 서버들이 별도의 CircuitBreaker 인스턴스로 관리되지않으면 “관리자 서버”만 문제있는 상황에서 “주문 서버”로의 요청도 막힐 수 있다. 그래서 이를 처리하기 위한 CircuitBreaker 인스턴스를 지정해주어야 하는데, OpenFeign은 해당 FeignClient가 어떤 인스턴스를 적용할지 식별할 수 있는 CircuitBreakerNameResolver 인터페이스를 제공해준다.

해당 인터페이스를 구현하지 않으면 기본적으로 FeignClient의 이름과 메소드를 조합하여 사용하는 DefaultCircuitBreakerNameResolver가 사용된다. 만약 숫자와 알파벳만으로 설정을 하고 싶다면 alphanumeric-ids 옵션을 주면 된다. 하지만 이번에는 Host를 기준으로 적용하도록 직접 Resolver를 구현하였고, 다음과 같다.

참고로 이때 CircuitBreaker 인스턴스를 찾지 못한다면 인메모리에 새로운 인스턴스를 생성하게 된다. 따라서 앞에서 했던 설정에서 default 인스턴스 외에는 별도로 미리 생성해두지 않은 것이다.

@Component
@Slf4j
public class HostNameCircuitBreakerNameResolver implements CircuitBreakerNameResolver {

    @Override
    public String resolveCircuitBreakerName(String feignClientName, Target<?> target, Method method) {
        String url = target.url();
        try {
            return new URL(url).getHost();
        } catch (MalformedURLException e) {
            log.error("MalformedURLException is ouccered: {}", url);
            return "default";
        }
    }

}

 

 

 

5. CallNotPermittedException 예외 처리

서킷이 OPEN 상태로 바뀌면 더 이상 요청이 전달되지 않는다. 대신 요청을 차단하고 바로 CallNotPermittedException 예외를 발생시킨다. 그러므로 각각의 예외 처리 방법에 맞게 CallNotPermittedException 예외를 처리해주어야 한다.

일반적으로 ControllerAdvice를 사용하고 있을 것인데, 그렇다면 해당 클래스에 아래의 내용을 추가 및 구현하면 된다.

@ExceptionHandler(CallNotPermittedException.class)
    public ResponseEntity<?> handleCallNotPermittedException(CallNotPermittedException e) {
        return ResponseEntity.internalServerError()
                .body(Collections.singletonMap("code", "InternalServerError"));
}

 

 

이후에는 테스트를 해주면 되는데, CircuitBreakerRegistry를 주입받고 다음과 같은 API를 작성해서 사용하면 된다.

@GetMapping("/circuit/close")
public ResponseEntity<Void> close(@RequestParam String name) {
    circuitBreakerRegistry.circuitBreaker(name)
            .transitionToClosedState();
    return ResponseEntity.ok().build();
}

@GetMapping("/circuit/open")
public ResponseEntity<Void> open(@RequestParam String name) {
    circuitBreakerRegistry.circuitBreaker(name)
            .transitionToOpenState();
    return ResponseEntity.ok().build();
}

@GetMapping("/circuit/status")
public ResponseEntity<CircuitBreaker.State> status(@RequestParam String name) {
    CircuitBreaker.State state = circuitBreakerRegistry.circuitBreaker(name)
            .getState();
    return ResponseEntity.ok(state);
}

@GetMapping("/circuit/all")
public ResponseEntity<Void> all() {
    Seq<CircuitBreaker> circuitBreakers = circuitBreakerRegistry.getAllCircuitBreakers();
    for (CircuitBreaker circuitBreaker : circuitBreakers) {
        log.error("circuitName={}, state={}", circuitBreaker.getName(), circuitBreaker.getState());
    }
    return ResponseEntity.ok().build();
}

 

 

 

 

6. Fallback 처리

요청이 실패하였을때 기본값 반환 등으로 Fallback 처리가 필요할 수 있다. 그런 경우에는 Open Feign이 제공하는 Fallback 기능을 사용하면 된다. Fallback은 메소드 구현 또는 팩토리 구현 등으로 처리할 수 있는데, 관련 내용은 공식 문서에 담겨져 있다. 직접 코드로 구현한 부분을 보고 싶다면 깃허브를 참고하도록 하자.

 

 

 

 

 

 

 

 

 

2. OpenFeign에 Resilience4J 적용 시에 주의 사항


  • Fallback이 없는 경우의 에러 처리
  • TimeLimiter의 Timeout 설정

 

 

 

[ Fallback이 없는 경우의 에러 처리 ]

Open Feign은 Spring Cloud가 제공하는 라이브러리이며, Open Feign이 서킷 브레이커를 적용하는 방법 역시 Spring Cloud가 제공하는 Spring Cloud CircuitBreaker를 기반으로 한다.

Spring Cloud CircuitBreaker는 일관된 서킷 브레이커 적용을 위한 인터페이스를 제공하는데, 해당 인터페이스에서 Fallback이 없는 경우라면 예외를 반드시 NoFallbackAvailableException으로 감싸서 반환하도록 하고 있다. 물론 일반적으로 사용되는 예외 처리기인 @ExceptionHandler는 Exception의 cause가 있을 경우에 cause로 에러 처리를 해주고 있어서, 큰 문제가 없을 수 있다. 하지만 그럼에도 불구하고 다음과 같이 실제 cause에 해당하는 에러를 출력해주는 것이 좋다.

@ExceptionHandler(NoFallbackAvailableException.class)
public ResponseEntity<Object> noFallbackAvailableException(HttpServletRequest request, NoFallbackAvailableException e) {
    log.warn("uri: {}, exception : ", request.getRequestURI(), e.getCause());
    return handleExceptionInternal(CommonErrorCode.INTERNAL_SERVER_ERROR);
}

 

 

 

 

[ TimeLimiter의 Timeout 설정 ]

위의 yaml 설정에서 작성했던 TimeLimiter의 timeout은 클라이언트로 작업을 위임하는 시간이라고 보면 되는데, 그래서 해당 설정 값은 상당히 중요하다. Resilience4J는 함수형 기반의 라이브러리인만큼 내부적으로 java.concurrenct 패키지의 도구들을 이용하는데, 해당 값은 Java Future 객체의 get에 전달된다.

예를 들어 timeout 값이 3초로 되어 있고, API 응답이 4초가 걸린다고 하자. 그러면 아래와 같은 상황이 발생할 수 있다.

  1. 작업 처리 시간을 3초로 설정함(TimeLimiter)
  2. 해당 요청 처리가 일시적으로 4초로 지연됨
  3. 작업 처리 시간이 만료되어 Fallback 처리 또는 예외 발생
  4. 작업 처리 시간이 만료되었으므로 Retry 등도 처리하지 않음

 

 

TimeLimiter의 timeout은 전체 작업 처리 시간의 timeout에 해당한다. 그러므로 위와 같은 경우라면 작업 처리 시간이 만료되었으므로 Retry 등도 시도하지 않을 것이다. 이러한 상황이 생기지 않도록 timeout 값은 신중히 설정되어야 한다.

기본적으로 TimeLimiter의 timeout 값은 CircuitBreaker의 slowCallDurationThreshold와 OpenFeign의 connectionTimeout, readTimeout 보다는 크게 설정되어야 한다. 그래야 응답이 조금 오래걸리는 상황에서도 정상적으로 처리가 가능하다. 그 외에 slowCall에 대해서도 재시도를 고려한다면 조금 더 큰 값으로 설정해줄 수도 있을 것이다.

 

 

 

 

 

실제로 OpenFeign에 Resilience4J를 적용한 부분은 깃허브를 통해 참고하도록 하자.

 

 

 

 

 

 

관련 포스팅

  1. OpenFeign이란? OpenFeign 소개 및 사용법
  2. OpenFeign 타임아웃(Timeout), 재시도(Retry), 로깅(Logging) 등 설정하기
  3. OpenFeign에 Resilence4J 서킷 브레이커 적용하는 방법과 예시 및 주의사항

 

 

 

 

 

참고 내용

 

 

 

 

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함