
Resilience4j의 여러 패턴들에 대해서 알아보도록 하겠습니다.
이전에는 CircuitBreaker 패턴에 대해 알아보았지요!
이번엔 Retry부터 TimeLimiter, RateLimiter 등 다양한 회복 탄력성과 장애 전파 방지 패턴에 대해 학습해봅니다!
Retry의 경우 일시적인 오류를 복구하는 방법으로 재시도 횟수와 재시도 타임을 설정하여 몇 번의 재시도를 추가적으로 진행하는 패턴을 말합니다.
즉, 이미 시스템의 장애가 발생한 상황임에도 불구하고 추가적인 호출이 발생할 수도 있다는 것이죠.
이 패턴의 경우 사용시 유의사항이 필요합니다. 그 부분까지 함께 살펴보겠습니다.

여기에서 핵심 설정은 아래와 같습니다.
maxAttempts : 첫 시도를 포함해 전체 시도할 횟수를 설정합니다.maxAttempts = 3이면 처음 + 재시도 2번waitDuration : 재시도 사이의 대기시간 설정enableExponentialBackoff / exponentialBackoffMultiplier : 재시도 간격을 점점 늘려 과부하 상황을 악화시키지 않도록 처리하는 설정입니다.retryExceptions / ignoreExceptions : 무엇을 재시도할지 명확하게 구분하도록 설정합니다.일반적으로 Retry의 경우 사용은 매우 유의해야 합니다. 멱등성 없는 요청에 대해선 Retry 패턴을 적용하면 중복처리 위험이 있습니다. 다운스트림이 이미 과부하인데 Retry로 추가적인 요청을 진행하면 상황이 더욱 악화될 수 있습니다.
그래서 GET의 경우 멱등 호출에 우선적으로 적용되어야하며 POST의 경우 Idempotency Key가 있을때만 신중히 적용해야 합니다.
resilience4j:
retry:
configs:
default:
maxAttempts: 3
waitDuration: 200ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
retryExceptions:
- java.io.IOException
- org.springframework.web.client.ResourceAccessException
ignoreExceptions:
- com.example.demo.exception.BadRequestException
instances:
catalogRetry:
baseConfig: default
이후 애노테이션 적용 시에는 다음과 같이 활용합니다.
@Service
public class CatalogClient {
private final RestTemplate restTemplate;
public CatalogClient(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@Retry(name = "catalogRetry", fallbackMethod = "fallback")
public CatalogItem getItem(String itemId) {
return restTemplate.getForObject(
"http://catalog-service/items/{id}",
CatalogItem.class,
itemId
);
}
private CatalogItem fallback(String itemId, Throwable t) {
return CatalogItem.placeholder(itemId);
}
}
TimeLimiter의 경우 시간으로 Limit을 거는 패턴입니다. 즉, 일정 시간 대기 이후 만약 응답이 되지 않는다면 API 요청을 차단하는 패턴입니다.
일반적으로 TimeLimiter의 경우 CompletableFuture와 Reactive인 비동기 형태와 궁합이 매우 좋습니다.
동기호출의 경우 HTTP Client Timeout을 반드시 함께 설정해줘야합니다. cacelRunningFuture는 Future 취소를 시도하지만 네트워크 호출이 즉시 중단된다는 보장은 없습니다.
그래서 클라이언트 타임아웃이 1차 방어이고 TimeLimiter의 경우 2차 방어 패턴으로 생각하면 됩니다.

TimeLimiter 패턴은 아래와 같이 설정하면 됩니다.
resilience4j:
timelimiter:
instances:
paymentTimeLimiter:
timeoutDuration: 1s
cancelRunningFuture: true
그리고 애노테이션으로 활용할 땐 다음처럼 활용하면 됩니다.
@Service
public class AsyncPaymentService {
private final PaymentClient paymentClient;
public AsyncPaymentService(PaymentClient paymentClient) {
this.paymentClient = paymentClient;
}
@TimeLimiter(
name = "paymentTimeLimiter",
fallbackMethod = "fallback"
)
public CompletableFuture<PaymentResponse> requestAsync(PaymentRequest req) {
return CompletableFuture.supplyAsync(() -> paymentClient.requestPayment(req));
}
private CompletableFuture<PaymentResponse> fallback(PaymentRequest req, Throwable t) {
return CompletableFuture.completedFuture(PaymentResponse.fail("TIMEOUT"));
}
}
RateLimiter의 경우 간단히 말해 초당/분당 요청 수의 제한을 두는 패턴입니다.
예를 들어 로그인 API는 쉽게 공격의 대상이 될 수 있으니 반복적인 트래픽에 대한 제한을 두는 식으로 패턴을 적용할 수 있습니다.

RateLimiter의 경우 아래와 같이 설정하면 됩니다.
resilience4j:
ratelimiter:
instances:
loginRateLimiter:
limitForPeriod: 20
limitRefreshPeriod: 1s
timeoutDuration: 0
간단하게 각 요소를 정리해보겠습니다.
limitForPeriod: 한 주기당 허용 개수(예: 1초에 20건)limitRefreshPeriod: 주기의 길이(예: 1초, 1분)timeoutDuration: 토큰이 없을 때 기다릴 시간. 0이면 즉시 거부, 값이 크면 요청 스레드가 대기할 수 있습니다.이 설정을 애노테이션으로 사용하면 다음와 같이 사용하면 됩니다.
@RestController
@RequestMapping("/auth")
public class AuthController {
@RateLimiter(
name = "loginRateLimiter",
fallbackMethod = "rateLimitFallback"
)
@PostMapping("/login")
public String login(@RequestBody LoginRequest req) {
return "OK";
}
private String rateLimitFallback(LoginRequest req, Throwable t) {
return "TOO_MANY_REQUESTS";
}
}
Bulkhead 패턴은 쉽게 말하면 칸막이와 같은 역할을 합니다. 하나의 서비스에 어떠한 이유로 Latency가 걸리게되면서 요청 응답의 지연이 발생하게되면서 그 요청들이 스레드와 커넥션풀을 가득채우며 그 외 다른 서비스들까지 영향을 주게 됩니다.
이럴때 Bulkhead 패턴을 적용하면 특정 기능이 쓸 수 있는 리소스인 동시 실행수나 스레드풀을 제한하고, 그 한도가 넘으면 빠르게 취소하거나 대기에 제한을 두게 됩니다.
Resilience4j에서는 Bulkhead 패턴이 두 종류가 있습니다.
하나는 SemaphoreBulkhead 패턴으로 가장 기본이며 동시 실행을 제한하는 방식입니다. 다른 하나는 TheadPoolBulkhead 방식으로 스레드풀을 격리하는 방식입니다.
하나씩 살펴보겠습니다.

여기에선 동시 실행 허용 개수를 설정하며 그를 넘어갈 경우 차단하게 됩니다.
그 차단의 범주는 maxWaitDuration을 통해 설정하며 이를 0으로 설정할 경우 즉시 차단하고 값을 올리면 그만큼 대기를 하지만 응답이 느려진다는 단점이 있습니다.
resilience4j:
bulkhead:
instances:
paymentBulkhead:
maxConcurrentCalls: 30
maxWaitDuration: 0
위 설정을 사용하는 애노테이션은 아래와 같습니다.
@Service
public class PaymentFacade {
@Bulkhead(
name = "paymentBulkhead",
fallbackMethod = "fallback"
)
public String pay(String orderId) {
return "PAID:" + orderId;
}
private String fallback(String orderId, Throwable t) {
return "PAYMENT_BUSY";
}
}
해당 패턴은 느린 작업에 대해서는 별도의 스레드풀과 큐로 분리하여 다른 스레드 요청을 잠식하지 않도록 격리시키는 방식입니다.

이는 웹 요청 스레드가 외부 호출 때문에 묶이는 문제에 특히 강합니다.
resilience4j:
thread-pool-bulkhead:
instances:
catalogPool:
coreThreadPoolSize: 10
maxThreadPoolSize: 20
queueCapacity: 50
keepAliveDuration: 10s
실제로 사용하는 방식은 아래와 같습니다.
@Service
public class AsyncCatalogService {
@ThreadPoolBulkhead(
name = "catalogPool",
fallbackMethod = "fallback"
)
public CompletableFuture<String> fetchCatalogAsync(String id) {
return CompletableFuture.supplyAsync(() -> "CATALOG:" + id);
}
private CompletableFuture<String> fallback(String id, Throwable t) {
return CompletableFuture.completedFuture("CATALOG_FALLBACK");
}
}
패턴을 적용하다보면 Fallback method를 잘 만들어야 한다는 것을 알 수 있게 됩니다.
Fallback에는 네 가지 유형이 있습니다.

그리고 Fallback을 설계할 때는 다음 원칙을 따라 설계합니다.
Resilience4j를 한 번 전체적으로 훑어보며 어떤식으로 회복탄력성을 적용할 수 있는지 검토해보았습니다.
CircuitBreaker 패턴 뿐만 아니라 다양한 패턴의 조합으로 장애로부터 우리의 서비스를 안전하게 지킬 수 있도록 설계를 해야겠다는 생각이 들었던 포스팅이었습니다.
긴 글 읽어주셔서 감사합니다! 🫡