Resilience4j 주요기능 분석

Hyunni·2024년 11월 10일
0

마이크로서비스 환경에서 장애에 대비한 다양한 복구전략은 필수적인 요소입니다. 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개의 댓글