Resilience4j 주요기능 분석

Hyunni·2024년 11월 10일

마이크로서비스 환경에서 장애에 대비한 다양한 복구전략은 필수적인 요소입니다. Resilience4j는 Java 기반 애플리케이션에서 장애 회복과 서비스 안정성을 위한 패턴을 지원하는 라이브러리로, 다양한 resilience 패턴을 통해 장애 상황에 대비할 수 있습니다. 이번 포스트에서는 각 기능의 원리, 주요 사용 예제, 응용 방법을 자세히 살펴보겠습니다.


1. Circuit Breaker: 장애 탐지 및 차단

1.1 동작 원리

Circuit Breaker는 오류가 지속되면 회로를 열어 이후의 요청을 차단하는 방식으로, 과부하를 방지하고 빠르게 장애를 감지할 수 있습니다. 회로는 Closed, Open, Half-Open의 세 가지 상태로 나뉘며, 자동으로 상태가 전환됩니다.

알고리즘: 슬라이딩 윈도우(Sliding Window)

Circuit Breaker는 슬라이딩 윈도우 알고리즘을 통해 오류 비율을 계산합니다. 슬라이딩 윈도우는 최근의 호출을 일정 개수(또는 일정 시간) 단위로 그룹화하여 성공률과 실패율을 평가합니다.

  • Closed 상태에서는 모든 요청을 허용하며 오류 비율을 지속적으로 계산합니다.
  • Open 상태에서는 오류 비율이 임계치를 넘을 경우 회로가 열려 모든 요청을 차단합니다. Open 상태가 유지되는 동안 주기적으로 상태를 확인하여 회로를 Half-Open으로 전환합니다.
  • Half-Open 상태에서 일부 요청을 허용하여 장애가 해결되었는지 테스트하고, 성공적인 요청이 일정 임계치를 넘으면 다시 Closed 상태로 돌아갑니다.

주요 개념

  • 임계치 기반 상태 전환: 오류 비율이 특정 임계치를 넘으면 Open 상태로 전환됩니다.
  • 시간 기반 윈도우: 일정 시간 동안 발생한 요청의 성공률과 실패율을 기록해 오류를 감지합니다.

1.2 예제 코드: 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";
    }
}

1.3 사용 사례

  • 결제 시스템: 외부 결제 API에서 지속적인 오류가 발생할 경우 회로를 열어, 불필요한 연결 시도를 막고 빠르게 실패 처리를 통해 사용자 경험을 보호할 수 있습니다.

2. Rate Limiter: 트래픽 제어

2.1 동작 원리

Rate Limiter는 일정 시간당 요청 수를 제한합니다. 예를 들어, 초당 5개의 요청만 허용하고 나머지는 차단하여 트래픽 폭주로 인한 과부하를 방지할 수 있습니다.

알고리즘: 토큰 버킷(Token Bucket)과 누출 버킷(Leaky Bucket)

Rate Limiter는 주로 토큰 버킷누출 버킷 두 가지 알고리즘 중 하나를 사용합니다.

  1. 토큰 버킷(Token Bucket):

    • 일정 시간마다 버킷에 토큰을 추가하며, 요청이 들어올 때마다 토큰을 소모하여 요청을 허용합니다.
    • 한 번에 많은 요청을 순간적으로 허용할 수 있어 burst 트래픽을 관리하는 데 유리합니다.
    • 예를 들어, 버킷에 10개의 토큰이 있고, 초당 1개의 토큰을 추가하는 경우, 한 번에 최대 10개의 burst 요청이 가능하고, 이후 초당 1개씩 요청이 허용됩니다.
  2. 누출 버킷(Leaky Bucket):

    • 일정 속도로만 요청을 허용하여 요청을 천천히 처리합니다.
    • 요청이 버킷을 초과하면 거부되며, 안정적인 속도로 트래픽을 처리할 수 있습니다.

    Rate Limiter를 API Gateway가 아닌 내부 서비스의 요청 제어서버 보호 목적으로 사용할 수도 있습니다. 특히, 콘서트 예매 트래픽처럼 일정 시간 동안 요청이 집중될 가능성이 큰 서비스에서는 Rate Limiter를 서버 내부에 적용해 부하를 분산하거나, 특정 트래픽 폭주 상황을 제어하는 용도로 사용할 수 있습니다.

Rate Limiter를 서버 내부에서 사용하는 사례

  1. 트래픽 급증을 방지하는 용도: 특정 이벤트나 기능에서 대규모의 요청이 몰리는 경우, 서버 내부에서 Rate Limiter를 적용해 동시에 처리되는 요청 수를 제한할 수 있습니다. 예를 들어, 이벤트 개시 직후 수많은 예매 요청이 순간적으로 몰리면 데이터베이스나 서버 자원에 큰 부담이 가해질 수 있습니다. 이때, 내부 Rate Limiter를 통해 단위 시간당 요청 수를 제한하면 서버가 과부하에 걸리는 상황을 방지할 수 있습니다.

  2. 데이터베이스 보호: 콘서트 예매와 같이 다수의 요청이 데이터베이스에 접근하는 경우 Rate Limiter로 트래픽을 제한하여 데이터베이스가 순간적으로 과부하에 걸리는 것을 막을 수 있습니다. 예를 들어, Rate Limiter를 설정하여 초당 일정 수의 요청만 데이터베이스에 전달되도록 하여, 데이터베이스 연결 풀이나 자원 사용을 일정하게 유지할 수 있습니다.

  3. 요청 처리 대기 및 거부: Rate Limiter를 서버 내부에서 설정하면 일정량 이상의 요청이 들어왔을 때 대기시키거나, 허용 범위를 초과하는 요청을 거부하여 응답 시간을 유지할 수 있습니다. 이를 통해 갑작스러운 트래픽 폭주 상황에서도 사용자 경험을 최대한 보호할 수 있습니다.

고려할 점

Rate Limiter를 서버 내부에서 사용할 경우, 단순히 요청을 제한하거나 대기시키는 것 외에 요청의 누락을 방지하거나 특정 중요 요청이 항상 처리될 수 있도록 설계해야 합니다. 예를 들어:

  • Retry와 결합하여 거부된 요청을 재시도하거나,
  • 우선순위 큐를 활용해 우선 순위가 높은 요청이 제한 없이 처리될 수 있도록 하는 전략을 추가하는 것이 좋습니다.

Rate Limiter를 서버 내부에서 사용할 때의 장단점

  • 장점: 요청 수를 일정하게 유지함으로써 서버 리소스를 안정적으로 관리할 수 있습니다. 특히, API Gateway가 Rate Limiting을 처리하지 않거나, 서버 내부의 특정 서비스 또는 리소스에 대해 더 세밀한 Rate Limiting이 필요할 때 유용합니다.

  • 단점: Rate Limiter는 단순히 요청을 제한하는 방식이므로, 모든 요청이 처리되는 것을 보장하지 않습니다. 중요한 이벤트에서는 Rate Limiter와 함께 Retry백오프 전략을 함께 적용해야 요청이 완전히 누락되는 상황을 방지할 수 있습니다.

결론적으로, Rate Limiter를 서버 내부에서 사용하는 것은 가능하며, 특히 이벤트성 트래픽이나 데이터베이스 보호 목적으로 적절합니다. 다만 요청 누락을 최소화하려면 추가적인 패턴이나 로직을 결합하여 사용해야 합니다.

2.2 예제 코드: 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";
    }
}

2.3 사용 사례

  • API 트래픽 제한: 외부 API와의 통신에 Rate Limiter를 적용하여 불필요한 트래픽을 줄이고, API 사용량을 제한할 수 있습니다.

3. Retry: 재시도

3.1 동작 원리

Retry는 일시적인 장애가 발생했을 때 지정된 횟수만큼 재시도를 수행하여 장애 회복 가능성을 높입니다. 재시도 간격을 설정해, 시스템 부하를 줄이며 트래픽을 관리할 수 있습니다.

알고리즘: 고정 대기 시간(Fixed Wait Time)과 지수 백오프(Exponential Backoff)

Retry는 주로 두 가지 방식으로 재시도 간격을 설정합니다.

  1. 고정 대기 시간 (Fixed Wait Time):

    • 재시도 간격을 일정하게 설정하여, 설정된 간격마다 실패한 요청을 다시 시도합니다.
    • 설정이 간단하고 일관성이 있으나, 트래픽이 집중될 경우 서비스에 부담을 줄 수 있습니다.
  2. 지수 백오프 (Exponential Backoff):

    • 재시도 간격이 점진적으로 늘어나는 방식으로, 요청이 실패할 때마다 대기 시간이 지수적으로 증가합니다.
    • 예를 들어, 첫 번째 실패 후 1초, 두 번째 실패 후 2초, 세 번째 실패 후 4초 대기하는 방식입니다.
    • 초기 트래픽 부담을 줄이면서도 요청의 성공 가능성을 높이는 데 유리합니다.

3.2 예제 코드: Retry 적용

아래 예제는 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";
    }
}

3.3 사용 사례

  • 네트워크 오류 처리: 일시적인 네트워크 문제로 외부 서비스 호출에 실패하는 경우 재시도를 통해 요청을 성공할 수 있습니다.

4. Bulkhead: 격리

4.1 동작 원리

Bulkhead는 리소스 고갈을 방지하기 위해 특정 작업의 동시 실행 개수를 제한합니다. 이를 통해 한 서비스가 과부하가 걸려도 다른 서비스는 영향을 받지 않습니다.

알고리즘: 스레드 풀(Thread Pool)과 세마포어(Semaphore)

Bulkhead는 일반적으로 스레드 풀이나 세마포어를 사용하여 리소스를 격리합니다.

  1. 스레드 풀 (Thread Pool):

    • 요청에 대해 고정된 스레드 풀을 할당하여, 일정 개수 이상의 요청이 들어오면 대기 상태에 두거나 거부합니다.
    • 각 스레드 풀은 서비스별로 독립적으로 운영될 수 있어, 특정 서비스의 오버로드가 다른 서비스에 영향을 미치지 않습니다.
  2. 세마포어 (Semaphore):

    • 특정 서비스에 대해 접근할 수 있는 최대 동시 요청 수를 제한하여, 리소스를 고갈하지 않고 일정량만 사용하도록 합니다.
    • 세마포어는 스레드 풀보다 경량화된 리소스 제어 방식이며, 요청 수만 제한하여 좀 더 빠른 처리가 가능합니다.

4.2 예제 코드: Bulkhead 적용

아래 예제에서는 최대 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";
    }
}

4.3 사용 사례

  • 데이터베이스 연결 보호: 특정 서비스가 데이터베이스 연결을 과도하게 사용하지 않도록 동시 실행 개수를 제한할 수 있습니다.

5. Time Limiter: 시간 제한

5.1 동작 원리

Time Limiter는 비동기 작업이 지정된 시간 내에 완료되지 않으면 실패 처리하는 방식으로, 과도한 대기 시간을 방지합니다.

알고리즘: 타임아웃과 Future

Time Limiter는 주로 타임아웃 설정과 Future 객체를 사용하여 시간 제한을 관리합니다.

  1. 타임아웃 설정:

    • 요청에 타임아웃을 설정하여, 일정 시간 내에 완료되지 않으면 요청을 실패로 간주합니다.
    • 응답 시간이 너무 길어질 경우 빠르게 대체 처리를 할 수 있습니다.
  2. Future 객체:

    • 비동기 작업을 관리할 때 Future 객체를 사용하여, 일정 시간 동안만 요청을 대기하고, 시간 초과 시 실패로 간주합니다.
    • 타임아웃이 발생하면 이후 로직이 즉시 종료되어 리소스가 낭비되지 않도록 합니다.

5.2 예제 코드: Time Limiter 적용

아래 예제는 비동기 작업을 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";
    }
}

5.3 사용 사례

  • 비동기 API 호출 제한: 비동기 호출이 오래 걸릴 경우 Time Limiter를 통해 일정 시간 초과 시 실패 처리할 수 있습니다.

Resilience4j의 다양한 기능을 통해 애플리케이션의 회복성을 높이고, 장애가 발생해도 서비스가 안정적으로 유지될 수 있도록 합니다.

profile
9년차 Fullstack Developer로 고민하고 구현하는 것들에 대해 정리하는 공간입니다.

0개의 댓글