마이크로서비스 환경에서 장애에 대비한 다양한 복구전략은 필수적인 요소입니다. Resilience4j는 Java 기반 애플리케이션에서 장애 회복과 서비스 안정성을 위한 패턴을 지원하는 라이브러리로, 다양한 resilience 패턴을 통해 장애 상황에 대비할 수 있습니다. 이번 포스트에서는 각 기능의 원리, 주요 사용 예제, 응용 방법을 자세히 살펴보겠습니다.
Circuit Breaker는 오류가 지속되면 회로를 열어 이후의 요청을 차단하는 방식으로, 과부하를 방지하고 빠르게 장애를 감지할 수 있습니다. 회로는 Closed
, Open
, Half-Open
의 세 가지 상태로 나뉘며, 자동으로 상태가 전환됩니다.
Circuit Breaker는 슬라이딩 윈도우 알고리즘을 통해 오류 비율을 계산합니다. 슬라이딩 윈도우는 최근의 호출을 일정 개수(또는 일정 시간) 단위로 그룹화하여 성공률과 실패율을 평가합니다.
CircuitBreaker
를 통해 외부 API 요청이 일정 횟수 이상 실패할 경우 이후 요청을 차단하는 코드입니다.
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import java.time.Duration;
import java.util.function.Supplier;
public class CircuitBreakerExample {
public static void main(String[] args) {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 실패율이 50%를 넘으면 회로를 엽니다.
.waitDurationInOpenState(Duration.ofSeconds(10)) // Open 상태를 10초 유지
.slidingWindowSize(5) // 5개의 호출을 슬라이딩 윈도우로 평가
.build();
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);
CircuitBreaker circuitBreaker = registry.circuitBreaker("exampleService");
// Circuit Breaker를 적용한 Supplier
Supplier<String> decoratedSupplier = CircuitBreaker.decorateSupplier(circuitBreaker, CircuitBreakerExample::simulateExternalService);
for (int i = 0; i < 10; i++) {
try {
System.out.println(decoratedSupplier.get());
} catch (Exception e) {
System.out.println("Request failed: " + e.getMessage());
}
}
}
private static String simulateExternalService() {
if (Math.random() > 0.6) {
throw new RuntimeException("Service failed");
}
return "Service call succeeded";
}
}
Rate Limiter는 일정 시간당 요청 수를 제한합니다. 예를 들어, 초당 5개의 요청만 허용하고 나머지는 차단하여 트래픽 폭주로 인한 과부하를 방지할 수 있습니다.
Rate Limiter는 주로 토큰 버킷과 누출 버킷 두 가지 알고리즘 중 하나를 사용합니다.
토큰 버킷(Token Bucket):
누출 버킷(Leaky Bucket):
Rate Limiter를 API Gateway가 아닌 내부 서비스의 요청 제어나 서버 보호 목적으로 사용할 수도 있습니다. 특히, 콘서트 예매 트래픽처럼 일정 시간 동안 요청이 집중될 가능성이 큰 서비스에서는 Rate Limiter를 서버 내부에 적용해 부하를 분산하거나, 특정 트래픽 폭주 상황을 제어하는 용도로 사용할 수 있습니다.
트래픽 급증을 방지하는 용도: 특정 이벤트나 기능에서 대규모의 요청이 몰리는 경우, 서버 내부에서 Rate Limiter를 적용해 동시에 처리되는 요청 수를 제한할 수 있습니다. 예를 들어, 이벤트 개시 직후 수많은 예매 요청이 순간적으로 몰리면 데이터베이스나 서버 자원에 큰 부담이 가해질 수 있습니다. 이때, 내부 Rate Limiter를 통해 단위 시간당 요청 수를 제한하면 서버가 과부하에 걸리는 상황을 방지할 수 있습니다.
데이터베이스 보호: 콘서트 예매와 같이 다수의 요청이 데이터베이스에 접근하는 경우 Rate Limiter로 트래픽을 제한하여 데이터베이스가 순간적으로 과부하에 걸리는 것을 막을 수 있습니다. 예를 들어, Rate Limiter를 설정하여 초당 일정 수의 요청만 데이터베이스에 전달되도록 하여, 데이터베이스 연결 풀이나 자원 사용을 일정하게 유지할 수 있습니다.
요청 처리 대기 및 거부: Rate Limiter를 서버 내부에서 설정하면 일정량 이상의 요청이 들어왔을 때 대기시키거나, 허용 범위를 초과하는 요청을 거부하여 응답 시간을 유지할 수 있습니다. 이를 통해 갑작스러운 트래픽 폭주 상황에서도 사용자 경험을 최대한 보호할 수 있습니다.
Rate Limiter를 서버 내부에서 사용할 경우, 단순히 요청을 제한하거나 대기시키는 것 외에 요청의 누락을 방지하거나 특정 중요 요청이 항상 처리될 수 있도록 설계해야 합니다. 예를 들어:
장점: 요청 수를 일정하게 유지함으로써 서버 리소스를 안정적으로 관리할 수 있습니다. 특히, API Gateway가 Rate Limiting을 처리하지 않거나, 서버 내부의 특정 서비스 또는 리소스에 대해 더 세밀한 Rate Limiting이 필요할 때 유용합니다.
단점: Rate Limiter는 단순히 요청을 제한하는 방식이므로, 모든 요청이 처리되는 것을 보장하지 않습니다. 중요한 이벤트에서는 Rate Limiter와 함께 Retry나 백오프 전략을 함께 적용해야 요청이 완전히 누락되는 상황을 방지할 수 있습니다.
결론적으로, Rate Limiter를 서버 내부에서 사용하는 것은 가능하며, 특히 이벤트성 트래픽이나 데이터베이스 보호 목적으로 적절합니다. 다만 요청 누락을 최소화하려면 추가적인 패턴이나 로직을 결합하여 사용해야 합니다.
아래 예제에서는 초당 5건의 요청을 허용하는 Rate Limiter를 설정하고, 초과 요청은 거부합니다.
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import java.time.Duration;
import java.util.function.Supplier;
public class RateLimiterExample {
public static void main(String[] args) {
RateLimiterConfig config = RateLimiterConfig.custom()
.timeoutDuration(Duration.ofMillis(500)) // 요청을 기다리는 최대 시간
.limitRefreshPeriod(Duration.ofSeconds(1)) // 새로 고침 주기
.limitForPeriod(5) // 주기마다 최대 5개의 요청 허용
.build();
RateLimiterRegistry registry = RateLimiterRegistry.of(config);
RateLimiter rateLimiter = registry.rateLimiter("exampleService");
Supplier<String> decoratedSupplier = RateLimiter.decorateSupplier(rateLimiter, RateLimiterExample::simulateService);
for (int i = 0; i < 10; i++) {
try {
System.out.println(decoratedSupplier.get());
} catch (Exception e) {
System.out.println("Rate limit exceeded: " + e.getMessage());
}
}
}
private static String simulateService() {
return "Service call succeeded";
}
}
Retry는 일시적인 장애가 발생했을 때 지정된 횟수만큼 재시도를 수행하여 장애 회복 가능성을 높입니다. 재시도 간격을 설정해, 시스템 부하를 줄이며 트래픽을 관리할 수 있습니다.
Retry는 주로 두 가지 방식으로 재시도 간격을 설정합니다.
고정 대기 시간 (Fixed Wait Time):
지수 백오프 (Exponential Backoff):
아래 예제는 3번까지 재시도를 설정하고, 재시도 간격을 500ms로 지정한 코드입니다.
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import io.github.resilience4j.retry.RetryRegistry;
import java.time.Duration;
import java.util.function.Supplier;
public class RetryExample {
public static void main(String[] args) {
RetryConfig config = RetryConfig.custom()
.maxAttempts(3) // 최대 3번 재시도
.waitDuration(Duration.ofMillis(500)) // 각 재시도 간격
.build();
RetryRegistry registry = RetryRegistry.of(config);
Retry retry = registry.retry("exampleService");
Supplier<String> decoratedSupplier = Retry.decorateSupplier(retry, RetryExample::simulateUnreliableService);
try {
System.out.println(decoratedSupplier.get());
} catch (Exception e) {
System.out.println("Request failed after retries: " + e.getMessage());
}
}
private static String simulateUnreliableService() {
if (Math.random() > 0.7) {
throw new RuntimeException("Service failed");
}
return "Service call succeeded";
}
}
Bulkhead는 리소스 고갈을 방지하기 위해 특정 작업의 동시 실행 개수를 제한합니다. 이를 통해 한 서비스가 과부하가 걸려도 다른 서비스는 영향을 받지 않습니다.
Bulkhead는 일반적으로 스레드 풀이나 세마포어를 사용하여 리소스를 격리합니다.
스레드 풀 (Thread Pool):
세마포어 (Semaphore):
아래 예제에서는 최대 2개의 동시 실행을 허용하는 Bulkhead를 설정합니다.
import io.github.resilience4j.bulkhead.Bulkhead;
import io.github.resilience4j.bulkhead.BulkheadConfig;
import io.github.resilience4j.bulkhead.BulkheadRegistry;
import java.util.concurrent.Callable;
public class BulkheadExample {
public static void main(String[] args) {
BulkheadConfig config = BulkheadConfig.custom()
.maxConcurrentCalls(2) // 최대 2개의 동시 요청 허용
.maxWaitDuration(Duration.ofMillis(500)) // 대기 시간
.build();
BulkheadRegistry registry = BulkheadRegistry.of(config);
Bulkhead bulkhead = registry.bulkhead("exampleService");
Callable<String> decoratedCallable = Bulkhead.decorateCallable(bulkhead, BulkheadExample::simulateService);
for (int i = 0; i < 5; i++) {
try {
System.out.println(decoratedCallable.call());
} catch (Exception e) {
System.out.println("Bulkhead limit exceeded: " + e.getMessage());
}
}
}
private static String simulateService() {
return "Service call succeeded";
}
}
Time Limiter는 비동기 작업이 지정된 시간 내에 완료되지 않으면 실패 처리하는 방식으로, 과도한 대기 시간을 방지합니다.
Time Limiter는 주로 타임아웃 설정과 Future 객체를 사용하여 시간 제한을 관리합니다.
타임아웃 설정:
Future 객체:
아래 예제는 비동기 작업을 1초 내에 완료하지 않으면 실패 처리하는 코드입니다.
import io.github.resilience4j.timelimiter.Time
Limiter;
import io.github.resilience4j.timelimiter.TimeLimiterConfig;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
public class TimeLimiterExample {
public static void main(String[] args) {
ExecutorService executorService = Executors.newSingleThreadExecutor();
TimeLimiterConfig config = TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofSeconds(1)) // 1초 내에 작업이 완료되지 않으면 실패 처리
.build();
TimeLimiter timeLimiter = TimeLimiter.of(config);
Supplier<CompletableFuture<String>> decoratedSupplier = TimeLimiter
.decorateFutureSupplier(timeLimiter, () -> CompletableFuture.supplyAsync(TimeLimiterExample::simulateSlowService, executorService));
try {
System.out.println(decoratedSupplier.get().get());
} catch (Exception e) {
System.out.println("Time limit exceeded: " + e.getMessage());
}
executorService.shutdown();
}
private static String simulateSlowService() {
try {
Thread.sleep(2000); // 의도적으로 2초 지연
} catch (InterruptedException e) {
e.printStackTrace();
}
return "Service call succeeded";
}
}
Resilience4j의 다양한 기능을 통해 애플리케이션의 회복성을 높이고, 장애가 발생해도 서비스가 안정적으로 유지될 수 있도록 합니다.