🧩 BE
home

OpenFeign에 Resilience4J 적용하기

Date
2025/04/18
Category
Web
Tag
Spring
Detail
목차
Java 진영 서킷브레이커 라이브러리인 Resilience4J를 OpenFeign에 적용하는 방법에 대해 알아보자.

Resilience4J 라이브러리와 구성 요소

Resilience4J는 함수형 프로그래밍으로 설계된 경량(light weight) 장애 허용(fault tolerance) 라이브러리로, 서킷브레이커 패턴을 위해 사용된다.
falut-tolerance란 하나 이성의 구성 요소에 문제가 생겨도 시스템이 중단없이 계속 작동할 수 있는 시스템을 의미한다. Resilience4J를 적용하면 외부 서비스에 장애가 발생해도 자신의 시스템은 계속 작동할 수 있는 것.
resilience4j
resilience4j
Resilience4J에는 여러 코어 모듈이 존재하고, 각각을 설정 가능하다.
CircuitBreaker
Bulkhead
RateLimiter
Retry
TimeLimiter

CircuitBreaker 모듈

CircuitBreaker는 일반적인 서킷 브레이커의 상태(CLOSED, OPEN, HALF OPEN)에 맞게 유한 상태 기계(Finite State Machine, FSM)를 구현한 모듈이다. 아래의 기본 상태에 더해 DISABLE과 FORCED_OPEN이라는 특수한 상태 2개를 추가했다.
CircuitBreaker는 호출 결과를 저장하고 집계하기 위해 슬라이딩 윈도우를 사용한다. 슬라이딩 윈도우는 마지막 N번의 호출 결과를 기반으로 하는 count-based sliding window(횟수 기반)와 마지막 N초의 결과를 기반으로 하는 time-based sliding window(시간 기반)가 있다.
느린 호출율과 호출 실패율이 CircuitBreaker에 설정된 임계값보다 크거나 같다면 CLOSED에서 OPEN으로 상태가 변경된다. 모든 예외 발생은 실패로 간주되므로, 특정 예외만 실패로 간주하고 싶다면 예외 목록을 정해주면 된다. 그러면 나머지 예외들은 성공으로 간주되며, 혹시나 예외 발생 부분은 결과에서 ignore 하고 싶다면 해당 설정 역시 가능하다. 참고로 이때 최소 호출 수가 있어서, 일정 호출 수가 기록된 후에 느린 호출율과 호출 실패율이 계산된다.
CircuitBreaker는 서킷이 OPEN 상태라면 CallNotPermittedException을 발생시킨다. 그리고 특정 시간이 지나서 HALF OPEN으로 바뀌면 설정된 수의 요청만을 허용하고 나머지는 동일하게 예외를 발생시킨다. 그리고 동일하게 느린 호출율과 호출 실패율에 따라 서킷의 상태를 OPEN 또는 CLOSED로 변경한다.
앞서 설명한 것 처럼 Resilience4J는 DISABLED와 FORCED_OPEN의 2가지 특별한 상태를 추가로 지원한다. DISABLED는 CircuitBreaker를 비활성화하여 항상 요청을 허용하는 상태이며, FORCED_OPEN은 강제로 서킷을 열어두어 항상 요청을 거부하는 상태이다.
CircuitBreaker는 다음과 같이 Thread-safe이다.
CircuitBreaker의 상태는 AtomicReference에 저장됨
CircuitBreaker는 atomic 기능을 사용하여 부작용없는 함수로 상태를 업데이트함
슬라이딩 윈도우에서 요청을 기록하고 스냅샷을 읽는 작업은 동기적으로 처리됨
즉, CircuitBreaker는 원자성이 보장되며 특정 시점에 하나의 스레드만이 CircuitBreaker의 상태나 슬라이딩 윈도우를 업데이트 할 수 있는 것이다. 그러나 CircuitBreaker는 함수 호출을 동기화하지 않는다. 만약 그렇게 하면 이는 엄청난 성능적 약점 및 병목이 될 것이다. 예시로 슬라이딩 윈도우의 크기가 15라고 할지라도, 20개의 스레드가 CLOSED 상태에서 호출 여부를 묻는다면 모든 스레드는 요청을 보낼 것이다. 슬라이딩 윈도우는 동시에 요청 가능한 수가 아니며, 해당 설정은 Bulkhead에서 지원하는 것이다.

Bulkhead 모듈

Resilience4J는 동시 실행의 수를 제어하기 위한 Bulkhead 패턴을 위해 2가지 구현을 제공한다.
SemaphoreBulkhead : 세마포어를 사용함
FixedThreadPoolBulkhead : 제한된 큐와 고정된 스레드를 사용함

RateLimiter 모듈

Rate Limiting은 API의 확장을 준비하고 서비스의 고가용성과 안정성을 확립하기 위한 필수 기술이다. 이 기술에는 감지된 한도 초과를 처리하는 방법이나 제한하려는 요청에 대한 다양한 옵션을 제공해준다.
세부적인 내용은 link iconresilience4jRateLimiter 참고.

Retry 모듈

Resilience4J는 Retry를 위한 인메모리 RetryRegistry를 제공해준다.
세부적인 내용은 link iconresilience4jRetry 참고.

TimeLimiter 모듈

CircuitBreaker 모듈처럼 시간 제한을 위한 인메모리 TimeLimiter 역시 제공된다. TimeLimiterConfig도 global 설정과 instance별 설정이 가능하며, 2가지 옵션을 제공한다.
timeoutDuration
cancelRunningFuture
Resilience4J는 함수형 기반의 라이브러리인만큼 내부적으로 Java의 Future로 요청을 실행한다. 위의 timeoutDuration은 Future의 timeout으로 설정되며, 주어진 시간이 지났을 때 해당 Future를 취소시킬지 여부를 설정한다.

OpenFeign에 Resilience 적용하기

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

1. 의존성 추가

먼저 Resilience4J 적용에 필요한 의존성을 추가해주어야 한다.
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
Groovy
복사

2. 설정 파일 추가

다음으로 Feign에 CircuitBreaker 적용을 활성화해야한다. feign.circuitbreaker.enabled 값으로 true를 설정해주면 된다. 해당 설정은 FeignAutoConfiguration.java#L175에 의해 적용되며, 참고로 Spring-Cloud-OpenFeign 4.0.0-SNAPSHOT 버전부터는 spring.cloud.openfeign.circuitbreaker.enabled 값으로 변경되었다.
feign: client: config: default: connectTimeout: 5000 readTimeout: 5000 circuitbreaker: enabled: true
YAML
복사
이후에는 관련 설정을 넣어야 하는데, CircuitBreaker 모듈은 다음의 설정을 제공해준다.
구분
설정
기본값
설명
Sliding Window
slidingWindowType
COUNT_BASED
외부 요청에 대한 결과를 기록할 sliding window 타입 - COUNT_BASED - TIME_BASED
Sliding Window
slidingWindowSize
100
sliding window 크기 - COUNT_BASED인 경우 개수 - TIME_BASED인 경우 크기
Sliding Window
minimumNumberOfCalls
100
error rate or slow call rate를 계산하기 위한 최소 call의 개수
Sliding Window
recordExceptions
empty
failure로 집계할 Exception → failure rate를 높임
Sliding Window
ignoreExceptions
empty
failure로 집계하지 않을 Exception
Sliding Window
recordFailurePredicate
throwable -> true
failure로 집계할 Exception인지를 평가
Sliding Window
ignoreExceptionPredicate
throwable -> false
failure로 집계하지 않을 Exception인지를 평가
CLOSED → OPEN
failureRateThreshold
50
실패율에 대한 임계값 설정 해당 임계값 이상이 되면 CLOSED → OPEN
CLOSED → OPEN
slowCallRateThreshold
100
Slow Call 비율의 임계값 설정 해당 임계값 이상이 되면 CLOSED → OPEN
CLOSED → OPEN
slowCallDurationThreshold
60000 [ms]
Slow Call 판단을 위한 임계값 설정 해당 임계값 이상이 되면 slow call로 판단
OPEN → HALF OPEN
waitDurationInOpenState
60000 [ms]
OPEN 상태에서 HALF OPEN 상태로 변경 대기 시간
OPEN → HALF OPEN
automaticTransitionFromOpenToHalfOpenEnabled
false
OPEN에서 HALF OPEN 상태로 자동 전환 여부 설정 - true : waitDurationInOpenState 시간동안 대기하지 않고 HALF OPEN으로 변경 - false : waitDurationInOpenState 체크를 위한 별도의 스레드를 생성
HALF OPEN
permittedNumberOfCallsInHalfOpenState
10
HALF OPEN 상태일 때 허용할 call의 개수
HALF OPEN → OPEN
maxWaitDurationInHalfOpenState
0 [ms]
HALF OPEN 상태에서 OPEN으로 넘어가는 최대 유지 시간 0일 경우 Circuit Breaker는 무한으로 대기하기에, 일부 허용된 call이 완료될 때까지 대기
여기서 circuitBreaker 인스턴스와 timeLimiter 인스턴스만 활용하며, 모두 default 인스턴스만 설정해두었다. timeLimiter 인스턴스가 하나인 이유는 모두 동일한 값을 사용하기 때문이며, circuitBreaker 인스턴스가 1개인 이유는 뒤에서 다룰 예정이다.
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
복사

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 false; } return t instanceof FeignException.FeignServerException; } }
Java
복사
만약 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: urdego.io.urdego_notification_service.common.config.circuit.DefaultExceptionRecordFailurePredicate instances: default: baseConfig: default timelimiter: configs: default: timeoutDuration: 6s # slowCallDurationThreshold보다는 크게 설정되어야 함 cancelRunningFuture: true
YAML
복사

4. circuitBreakerNameResolver 작성

CircuitBreaker 인스턴스는 여러 개로 관리될 수 있다. 예를 들어 배달앱이라면 “관리자 서버”, “주문 서버” 등이 있고, 각각을 FeignClient로 호출할 것이다. 서로 다른 서버들이 별도의 CircuitBreaker 인스턴스로 관리되지 않으면 “관리자 서버”만 문제있는 상황에서 “주문 서버”로의 요청도 막힐 수 있다. 그래서 이를 처리하기 위한 CircuitBreaker 인스턴스를 지정해주어야 하는데, OpenFeign은 해당 FeignClient가 어떤 인스턴스를 적용할지 식별할 수 있는 CircuitBreakerNameResolver.java 인터페이스를 제공해준다.
해당 인터페이스를 구현하지 않으면 기본적으로 FeignClient의 이름과 메소드를 조합하여 사용하는 DefaultCircuitBreakerNameResolver가 사용된다. 만약 숫자와 알파벳만으로 설정을 하고 싶다면 alphanumeric-ids 옵션을 주면 된다. 아래의 코드는 Host를 기준으로 적용하도록 직접 Resolver를 구현한 것이다.
@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 occured: {}", url); return "default"; } } }
Java
복사

5. CallNotPermittedException 예외 처리

서킷이 OPEN으로 바뀌면 더 이상 요청이 전달되지 않는다. 대신 요청을 차단하고 바로 CallNotPermittedException 예외를 발생시킨다. 따라서 각각의 예외처리 방법에 맞게 CallNotPermittedException 예외를 처리해줘야한다. 난 @ControllerAdviceGlobalExceptionHandler를 사용하고 있어서 이에 맞춰 해당 클래스에 아래의 코드를 적용하면 된다.
@ExceptionHandler(CallNotPermittedException.class) public ResponseEntity<?> handleCallNotPermittedException(CallNotPermittedException e) { return ResponseEntity.internalServerError() .body(Collections.singletonMap("code", "InternalServerError")); }
Java
복사

Reference