티스토리 뷰

Spring

[Spring] RestTemplate에 Resilence4J 서킷 브레이커 적용하는 방법과 예시

망나니개발자 2023. 3. 28. 10:00
반응형

이번에는 Java 진영의 서킷브레이커 라이브러리인 Resilence4J를 RestTemplate에 적용하는 방법에 대해 알아보도록 하겠습니다.

 

 

 

 

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. RestTemplate에 Resilience4J 적용하기


[ RestTemplate에 Resilience4J 적용하기 ]

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

 

 

1. 의존성 추가

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

implementation 'io.github.resilience4j:resilience4j-spring-boot2'
// implementation 'io.github.resilience4j:resilience4j-spring-boot3'

 

 

 

 

2. 설정 파일 추가

그 다음으로 관련된 설정을 넣어야 하는데, 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 상태로 변경되게 된다. 아래의 예시는 RestTemplate을 사용하여 400번대 클라이언트 외의 에러가 발생한 경우에는 모두 fail로 기록하도록 작성되었다. 해당 로직은 상황에 따라 커스터마이징해주면 된다.

public class RestTemplateCircuitRecordFailurePredicate implements Predicate<Throwable> {
    //true 를 리턴하면 Fail 로 기록됨.
	
    @Override
    public boolean test(Throwable throwable) {
        // 4XX 클라이언트 에러는 fail로 기록하지 않음
        if (throwable instanceof HttpClientErrorException) {
            return false;
        }

        // 그 외에 에러는 모두 failure로 기록함(HttpServerErrorException, connection, timeout, IOException 등)
        return 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.resttemplate.circuit.RestTemplateCircuitRecordFailurePredicate
    instances:
      default:
        baseConfig: default
  timelimiter:
    configs:
      default:
        timeoutDuration: 6s # slowCallDurationThreshold보다는 크게 설정되어야 함
        cancelRunningFuture: true

 

 

 

 

4. 서킷브레이커 적용

그 다음으로는 서킷브레이커를 적용해야 하는데, 서킷브레이커를 적용하는 방법에는 크게 2가지가 있다.

  • 코드 방식
  • 어노테이션 방식

 

 

코드 방식은 다음과 같이(수도 코드) 서킷브레이커를 직접 주입받고 적용해주는 것이다. 기본적으로 executeSupplier를 사용하면 되고, fallback 처리가 필요하다면 decorateSupplier를 사용해주면 된다.

@Component
@RequiredArgsConstructor
public class GetExchangeRateTemplate {

    private final RestTemplate restTemplate;
    private final CircuitBreakerRegistry registry;

    public void call(String circuitName) {
        CircuitBreaker circuitBreaker = registry.find(name)
            .orElseThrow(() -> new IllegalArgumentException("invalid circuitBreaker name - name:" + name));

        return circuitBreaker.executeSupplier(() -> {
            return restTemplate.getForEntity(RestTemplateExchangeRateResponse.class);
        });
    }

}

 

 

다음은 어노테이션으로 서킷브레이커 인스턴스를 지정하여 적용하는 것이다. 필요하다면 fallback 속성도 지정할 수 있다.

@Component
@RequiredArgsConstructor
public class GetExchangeRateTemplate {

    private final RestTemplate restTemplate;

    @CircuitBreaker(name="exchange")
    public void call() {
        return restTemplate.getForEntity(RestTemplateExchangeRateResponse.class);
    }

}

 

 

자세한 코드는 여기서 볼 수 있으며, 각각의 장단점이 있다. 먼저 코드 방식은 작업이 번거로우며 중복이 상당히 많아진다. 반면에 어노테이션 방식을 적용하면 작업이 상당히 간결해지지만 매번 어노테이션을 붙여주어야 하며, 서킷브레이커 인스턴스(name값) 관리가 필요해진다. 새롭게 연동해야 하는 서버가 생긴다면 번거로우며, 해당 값을 잘못 지정했을 경우에 문제가 생길 수도 있다.

그래서 이러한 문제를 완전히 해결하고자 코드 방식에 AOP를 적용하여 해결하였다.

@Aspect
@Component
@RequiredArgsConstructor
public class RestTemplateCircuitBreakerAspect {

	private final CircuitBreakerRegistry registry;

	@Around("execution(* org.springframework.web.client.RestTemplate.*(..)) && args(url,..)")
	public Object aspect(ProceedingJoinPoint pjp, String url) throws Throwable {
		return aspect(pjp, new URI(url));
	}

	@Around("execution(* org.springframework.web.client.RestTemplate.*(..)) && args(uri,..)")
	public Object aspect(ProceedingJoinPoint pjp, URI uri) throws Throwable {
		return registry.circuitBreaker(findHost(uri))
			.executeCheckedSupplier(pjp::proceed);
	}

	private String findHost(URI uri) {
		return Optional.ofNullable(uri)
				.map(URI::getHost)
				.orElse("default");
	}
}

 

 

이렇게 하면 중복 코드도 제거되고, 자동으로 서킷 브레이커도 적용되며, 서킷 브레이커 인스턴스도 host 기반으로 자동 식별할 수 있다.

CircuitBreaker 인스턴스는 여러 개로 관리될 수 있다. 예를 들어 배달앱이라면 “관리자 서버”, “주문 서버” 등이 있고, 각각을 호출할 것이다. 서로 다른 서버들이 별도의 CircuitBreaker 인스턴스로 관리되지않으면 “관리자 서버”만 문제있는 상황에서 “주문 서버”로의 요청도 막힐 수 있다. 그래서 CircuitBreaker 인스턴스를 지정해주어야 하는데, 이때 CircuitBreaker 인스턴스를 찾지 못한다면 인메모리에 새로운 인스턴스를 생성하게 된다. 따라서 앞에서 했던 설정에서 default 인스턴스 외에는 별도로 미리 생성해두지 않은 것이다. 물론 host 별로 세밀한 설정이 필요하다면 위와 같은 방식으로 해결하지 못할 수 있으므로 상황에 맞게 적용하는 것이 필요하다.

@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();
}

 

 

 

 

 

 

 

관련 포스팅

  1. RestTemplate 타임아웃(Timeout), 재시도(Retry), 로깅(Logging) 등 설정하기
  2. RestTemplate에 Resilence4J 서킷 브레이커 적용하는 방법과 예시

 

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2024/04   »
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
글 보관함