Circuit Breaker

Circuit Breaker는 분산 시스템에서 장애가 발생하거나 과부하 상태일 때 서비스의 안정성을 유지하기 위한 디자인 패턴이다. 이는 전기 회로의 차단기에서 영감을 받아, 연속적인 실패 시 추가적인 장애 전파를 방지하고 시스템을 보호한다. 특히 마이그레이션 과정에서 서비스 간 의존성이 높은 환경에서 필수적으로 적용된다.

서킷 브레이커 패턴의 기본 개념

서킷 브레이커 패턴은 전기 회로의 차단기에서 영감을 받은 소프트웨어 디자인 패턴으로, 가정용 전기 차단기가 과부하 시 전기를 차단하여 화재를 방지하는 것처럼, 소프트웨어 서킷 브레이커는 장애가 발생한 서비스에 대한 호출을 일시적으로 중단하여 시스템 전체의 안정성을 보호한다.

핵심 목적

  1. 연쇄 장애(Cascading Failures) 방지: 한 서비스의 장애가 다른 서비스로 전파되는 것을 막는다.
  2. 빠른 실패(Fail Fast): 실패할 것이 명백한 요청을 즉시 거부하여 시스템 자원을 절약한다.
  3. 자가 회복(Self-Healing): 장애 상황에서 자동으로 복구될 수 있는 메커니즘을 제공한다.
  4. 우아한 성능 저하(Graceful Degradation): 전체 시스템이 중단되지 않고 부분적으로 기능을 유지할 수 있게 한다.

상태 전이 메커니즘

서킷 브레이커는 일반적으로 세 가지 상태로 동작한다:

상태설명동작 방식
Closed정상 작동 상태모든 요청 허용, 실패 시 카운트 증가
Open장애 발생 상태즉시 실패 반환, 일정 시간 대기
Half-Open복구 테스트 상태제한된 요청 허용, 성공 시 Closed로 전환

닫힘 상태(Closed)

  • 정상 작동 상태.
  • 모든 요청이 대상 서비스로 전달된다.
  • 실패 횟수를 지속적으로 모니터링한다.
  • 설정된 임계값(threshold)을 초과하면 열림 상태로 전환된다.

열림 상태(Open)

  • 장애 상태로 판단하여 모든 요청을 즉시 거부한다.
  • 실제 서비스 호출을 시도하지 않고 예외나 대체 응답을 반환한다.
  • 설정된 타임아웃 기간이 지나면 반열림 상태로 전환된다.

반열림 상태(Half-Open)

  • 제한된 수의 요청만 서비스로 전달하여 회복 여부를 테스트한다.
  • 이 상태에서의 성공/실패율에 따라 닫힘 또는 열림 상태로 전환된다.
  • 성공 비율이 높으면 닫힘 상태로, 실패 비율이 높으면 다시 열림 상태로 돌아간다.

작동 원리

  1. 초기 상태(Closed):

    • 모든 요청이 백엔드 서비스로 전달된다.
    • 실패 횟수가 임계값(예: 5회)을 초과하면 Open 상태로 전환된다.
  2. 차단 상태(Open):

    • 모든 요청이 즉시 차단되며, 사전 정의된 타임아웃(예: 30초) 동안 유지된다.
    • 클라이언트에게 빠른 실패 응답(HTTP 503)을 반환하여 자원 낭비를 방지한다.
  3. 테스트 상태(Half-Open):

    • 타임아웃 종료 후 소량의 요청(예: 10%)을 허용하여 서비스 복구 여부를 확인한다.
    • 성공 시 Closed 상태로 복귀, 실패 시 Open 상태 재진입.

서킷 브레이커 패턴의 구현 방법

직접 구현 예시 (Java)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
public class CircuitBreaker {
    private enum State {
        CLOSED,
        OPEN,
        HALF_OPEN
    }

    private State state;
    private int failureThreshold;   // 실패 임계값
    private int successThreshold;   // 성공 임계값
    private long openTimeout;       // 열림 상태 유지 시간(ms)
    
    private int failureCount;       // 현재 실패 카운트
    private int successCount;       // 반열림 상태에서의 성공 카운트
    private long lastFailureTime;   // 마지막 실패 시간
    
    public CircuitBreaker(int failureThreshold, int successThreshold, long openTimeout) {
        this.state = State.CLOSED;
        this.failureThreshold = failureThreshold;
        this.successThreshold = successThreshold;
        this.openTimeout = openTimeout;
        this.failureCount = 0;
        this.successCount = 0;
    }
    
    public synchronized <T> T execute(Supplier<T> action, Supplier<T> fallback) {
        // 현재 회로 상태에 따라 처리
        if (state == State.OPEN) {
            // 타임아웃 체크 - 설정된 시간이 지났으면 반열림 상태로 전환
            if (System.currentTimeMillis() - lastFailureTime >= openTimeout) {
                state = State.HALF_OPEN;
                System.out.println("Circuit switched to HALF_OPEN");
            } else {
                // 아직 타임아웃이 지나지 않았으면 대체 로직 실행
                return fallback.get();
            }
        }
        
        try {
            // 실제 동작 실행
            T result = action.get();
            
            // 성공 처리
            handleSuccess();
            return result;
        } catch (Exception e) {
            // 실패 처리
            handleFailure();
            return fallback.get();
        }
    }
    
    private synchronized void handleSuccess() {
        if (state == State.HALF_OPEN) {
            successCount++;
            if (successCount >= successThreshold) {
                // 성공 임계값에 도달하면 닫힘 상태로 전환
                state = State.CLOSED;
                successCount = 0;
                failureCount = 0;
                System.out.println("Circuit switched to CLOSED");
            }
        } else if (state == State.CLOSED) {
            // 닫힘 상태에서는 실패 카운트 초기화
            failureCount = 0;
        }
    }
    
    private synchronized void handleFailure() {
        lastFailureTime = System.currentTimeMillis();
        
        if (state == State.HALF_OPEN) {
            // 반열림 상태에서 실패 시 즉시 열림 상태로 전환
            state = State.OPEN;
            successCount = 0;
            System.out.println("Circuit switched back to OPEN");
        } else if (state == State.CLOSED) {
            failureCount++;
            if (failureCount >= failureThreshold) {
                // 실패 임계값에 도달하면 열림 상태로 전환
                state = State.OPEN;
                System.out.println("Circuit switched to OPEN");
            }
        }
    }
    
    // 현재 상태 조회 메서드
    public State getState() {
        return state;
    }
}

Spring Cloud Circuit Breaker 사용 예시

Spring Cloud를 사용하는 환경에서는 Resilience4j, Hystrix 등의 라이브러리를 활용할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
public class ProductService {
    
    private final RestTemplate restTemplate;
    private final CircuitBreakerFactory circuitBreakerFactory;
    
    public ProductService(RestTemplate restTemplate, CircuitBreakerFactory circuitBreakerFactory) {
        this.restTemplate = restTemplate;
        this.circuitBreakerFactory = circuitBreakerFactory;
    }
    
    public Product getProductById(Long id) {
        CircuitBreaker circuitBreaker = circuitBreakerFactory.create("productService");
        
        return circuitBreaker.run(
            () -> restTemplate.getForObject("/products/{id}", Product.class, id),
            throwable -> getProductFallback(id)
        );
    }
    
    private Product getProductFallback(Long id) {
        // 대체 로직 - 캐시에서 조회, 기본 응답 생성 등
        return new Product(id, "Fallback Product", "This is a fallback response");
    }
}

Node.js Opossum 라이브러리 사용 예시

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const CircuitBreaker = require('opossum');

// 서킷 브레이커 설정
const options = {
  failureThreshold: 50,         // 50% 이상 실패시 열림
  resetTimeout: 10000,          // 10초 후 반열림 상태로 전환
  timeout: 3000,                // 3초 타임아웃
  errorThresholdPercentage: 50  // 50% 이상 에러 발생시 열림
};

// 대상 함수
function fetchUserData(userId) {
  return fetch(`https://api.example.com/users/${userId}`)
    .then(response => {
      if (!response.ok) throw new Error('API 요청 실패');
      return response.json();
    });
}

// 서킷 브레이커 생성
const breaker = new CircuitBreaker(fetchUserData, options);

// 이벤트 리스너 등록
breaker.on('open', () => console.log('서킷 브레이커가 열렸습니다'));
breaker.on('halfOpen', () => console.log('서킷 브레이커가 반열림 상태입니다'));
breaker.on('close', () => console.log('서킷 브레이커가 닫혔습니다'));
breaker.on('fallback', () => console.log('폴백 함수가 실행되었습니다'));

// 대체 응답 설정
breaker.fallback(() => ({ id: 'unknown', name: '사용할 수 없음' }));

// 실행
function getUserData(userId) {
  return breaker.fire(userId)
    .then(data => console.log(data))
    .catch(error => console.error('오류 발생:', error));
}

서킷 브레이커의 주요 설정 매개변수

서킷 브레이커의 효과적인 구현을 위해 다양한 매개변수를 조정할 수 있다:

기본 설정 매개변수

  • 실패 임계값(Failure Threshold): 열림 상태로 전환되기 위한 실패 수 또는 실패율
  • 성공 임계값(Success Threshold): 반열림 상태에서 닫힘 상태로 전환되기 위한 성공 수
  • 타임아웃(Timeout): 대상 서비스의 응답 제한 시간
  • 개방 지속 시간(Open Duration): 열림 상태에서 반열림 상태로 전환되기까지의 시간
  • 볼륨 임계값(Volume Threshold): 서킷 브레이커가 활성화되기 위한 최소 요청 수

고급 설정 매개변수

  • 슬라이딩 윈도우(Sliding Window): 실패율 계산에 사용되는 시간 또는 호출 수 기반 윈도우
  • 버킷 크기(Bucket Size): 슬라이딩 윈도우 내 버킷 수
  • 실패 예외 유형(Failure Exception Types): 실패로 간주할 예외 유형 목록
  • 무시 예외 유형(Ignored Exception Types): 실패로 간주하지 않을 예외 유형 목록

서킷 브레이커 패턴의 실제 적용 시나리오

API 게이트웨이

API 게이트웨이에서 서킷 브레이커를 적용하여 다운스트림 서비스의 장애가 전체 시스템에 영향을 미치지 않도록 보호한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Spring Cloud Gateway 설정 예시
spring:
  cloud:
    gateway:
      routes:
        - id: product-service
          uri: lb://product-service
          predicates:
            - Path=/products/**
          filters:
            - name: CircuitBreaker
              args:
                name: productServiceCircuitBreaker
                fallbackUri: forward:/fallback/products

마이크로서비스 간 통신

마이크로서비스 아키텍처에서 서비스 간 통신 시 서킷 브레이커를 적용하여 한 서비스의 장애가 다른 서비스로 전파되는 것을 방지한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Configuration
public class ResilienceConfig {
    
    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> defaultCustomizer() {
        // 서킷 브레이커 기본 설정
        return factory -> factory.configureDefault(id -> {
            return new Resilience4JConfigBuilder(id)
                .circuitBreakerConfig(CircuitBreakerConfig.custom()
                    .slidingWindowType(SlidingWindowType.COUNT_BASED)
                    .slidingWindowSize(10)
                    .failureRateThreshold(50.0f)
                    .waitDurationInOpenState(Duration.ofSeconds(10))
                    .permittedNumberOfCallsInHalfOpenState(5)
                    .automaticTransitionFromOpenToHalfOpenEnabled(true)
                    .build())
                .timeLimiterConfig(TimeLimiterConfig.custom()
                    .timeoutDuration(Duration.ofSeconds(3))
                    .build())
                .build();
        });
    }
    
    // 서비스별 커스텀 설정도 가능
    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> paymentServiceCustomizer() {
        return factory -> factory.configure(builder -> {
            return builder
                .circuitBreakerConfig(CircuitBreakerConfig.custom()
                    .failureRateThreshold(30.0f)  // 결제 서비스는 더 민감하게 설정
                    .waitDurationInOpenState(Duration.ofSeconds(20))
                    .build())
                .build();
        }, "paymentService");
    }
}

데이터베이스 액세스

데이터베이스 연결 풀과 쿼리 실행에 서킷 브레이커를 적용하여 데이터베이스 장애 시 시스템 보호:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Repository
public class ProductRepository {
    
    private final JdbcTemplate jdbcTemplate;
    private final CircuitBreaker circuitBreaker;
    
    public ProductRepository(JdbcTemplate jdbcTemplate, CircuitBreakerRegistry registry) {
        this.jdbcTemplate = jdbcTemplate;
        this.circuitBreaker = registry.circuitBreaker("databaseCircuitBreaker");
    }
    
    public Optional<Product> findById(Long id) {
        // Resilience4j 함수형 인터페이스 사용
        Supplier<Optional<Product>> databaseCall = () -> {
            String sql = "SELECT * FROM products WHERE id = ?";
            List<Product> products = jdbcTemplate.query(
                sql,
                new Object[]{id},
                (rs, rowNum) -> new Product(
                    rs.getLong("id"),
                    rs.getString("name"),
                    rs.getString("description")
                )
            );
            return products.isEmpty() ? Optional.empty() : Optional.of(products.get(0));
        };
        
        // 대체 로직 (캐시에서 조회 또는 기본값 반환)
        Function<Throwable, Optional<Product>> fallback = throwable -> {
            // 로깅
            log.error("데이터베이스 쿼리 실패", throwable);
            // 캐시에서 조회 시도
            return cacheService.getProductFromCache(id);
        };
        
        // 서킷 브레이커 실행
        return circuitBreaker.decorateSupplier(databaseCall).recover(fallback).get();
    }
}

서킷 브레이커의 모니터링 및 관리

효과적인 서킷 브레이커 구현을 위해서는 적절한 모니터링과 관리가 필수적이다.

핵심 메트릭

  • 호출 성공률/실패율: 서비스 호출의 성공 및 실패 비율
  • 응답 시간: 서비스 호출의 평균, 중앙값, 최대 응답 시간
  • 서킷 브레이커 상태: 현재 상태(닫힘, 열림, 반열림)와 상태 전환 횟수
  • 요청 볼륨: 단위 시간당 요청 수
  • 거부 요청 수: 열림 상태에서 거부된 요청 수

모니터링 도구

  1. Prometheus + Grafana: 메트릭 수집 및 시각화

    1
    2
    3
    4
    5
    
    # Resilience4j Prometheus 설정 예시
    resilience4j.prometheus:
      enabled: true
      metrics:
        enabled: true
    
  2. Actuator + Spring Boot Admin: 서킷 브레이커 상태 및 메트릭 확인

    1
    2
    3
    4
    5
    6
    7
    8
    
    management:
      endpoints:
        web:
          exposure:
            include: health,info,circuitbreakers
      health:
        circuitbreakers:
          enabled: true
    
  3. AWS CloudWatch: AWS 환경에서의 모니터링

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    // CloudWatch 메트릭 게시 예시
    CloudWatchAsyncClient cloudWatchClient = CloudWatchAsyncClient.create();
    
    MetricDatum datum = MetricDatum.builder()
        .metricName("CircuitBreakerStateChange")
        .dimensions(Dimension.builder()
            .name("ServiceName")
            .value("ProductService")
            .build())
        .value(state.ordinal())  // 0: 닫힘, 1: 열림, 2: 반열림
        .timestamp(Instant.now())
        .unit(StandardUnit.COUNT)
        .build();
    
    cloudWatchClient.putMetricData(PutMetricDataRequest.builder()
        .namespace("MyApplication/CircuitBreakers")
        .metricData(datum)
        .build());
    

서킷 브레이커 패턴의 발전과 활용

벌크헤드 패턴(Bulkhead Pattern)과의 결합

벌크헤드 패턴은 시스템을 격리된 구획으로 나누어 한 부분의 장애가 다른 부분에 영향을 미치지 않도록 하는 패턴이다. 서킷 브레이커와 함께 사용하면 시스템 안정성을 더욱 강화할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Resilience4j의 벌크헤드 패턴 구현 예시
@Service
public class UserService {
    
    private final RestTemplate restTemplate;
    private final CircuitBreaker circuitBreaker;
    private final Bulkhead bulkhead;
    
    public UserService(RestTemplate restTemplate, 
                       CircuitBreakerRegistry circuitBreakerRegistry,
                       BulkheadRegistry bulkheadRegistry) {
        this.restTemplate = restTemplate;
        this.circuitBreaker = circuitBreakerRegistry.circuitBreaker("userService");
        this.bulkhead = bulkheadRegistry.bulkhead("userService");
    }
    
    public User getUserById(Long id) {
        // 벌크헤드와 서킷 브레이커 조합
        Supplier<User> decoratedSupplier = Bulkhead.decorateSupplier(
            bulkhead, 
            CircuitBreaker.decorateSupplier(
                circuitBreaker, 
                () -> restTemplate.getForObject("/users/{id}", User.class, id)
            )
        );
        
        // 폴백 추가
        return Try.ofSupplier(decoratedSupplier)
            .recover(throwable -> getFallbackUser(id))
            .get();
    }
    
    private User getFallbackUser(Long id) {
        return new User(id, "Unknown", "unknown@example.com");
    }
}

레이트 리미터(Rate Limiter)와의 결합

레이트 리미터는 특정 시간 내에 수행할 수 있는 요청 수를 제한하는 패턴이다. 서킷 브레이커와 함께 사용하면 시스템 과부하를 효과적으로 방지할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// Resilience4j의 레이트 리미터 구현 예시
@Configuration
public class ResilienceConfig {
    
    @Bean
    public RateLimiterRegistry rateLimiterRegistry() {
        RateLimiterConfig config = RateLimiterConfig.custom()
            .limitRefreshPeriod(Duration.ofSeconds(1))  // 1초마다 갱신
            .limitForPeriod(100)                        // 초당 100개 요청 허용
            .timeoutDuration(Duration.ofMillis(500))    // 500ms 대기 후 거부
            .build();
        
        return RateLimiterRegistry.of(config);
    }
    
    @Bean
    public RateLimiter apiRateLimiter(RateLimiterRegistry registry) {
        return registry.rateLimiter("apiRateLimiter");
    }
}

@Service
public class ApiService {
    
    private final RestTemplate restTemplate;
    private final CircuitBreaker circuitBreaker;
    private final RateLimiter rateLimiter;
    
    public ApiService(RestTemplate restTemplate, 
                      CircuitBreakerRegistry circuitBreakerRegistry,
                      RateLimiter rateLimiter) {
        this.restTemplate = restTemplate;
        this.circuitBreaker = circuitBreakerRegistry.circuitBreaker("apiService");
        this.rateLimiter = rateLimiter;
    }
    
    public ApiResponse callExternalApi(String request) {
        // 레이트 리미터 + 서킷 브레이커 조합
        Supplier<ApiResponse> decoratedSupplier = RateLimiter.decorateSupplier(
            rateLimiter,
            CircuitBreaker.decorateSupplier(
                circuitBreaker,
                () -> restTemplate.postForObject("/api", request, ApiResponse.class)
            )
        );
        
        return Try.ofSupplier(decoratedSupplier)
            .recover(RequestNotPermitted.class, e -> new ApiResponse("Rate limited"))
            .recover(CallNotPermittedException.class, e -> new ApiResponse("Circuit open"))
            .recover(throwable -> new ApiResponse("Generic error: " + throwable.getMessage()))
            .get();
    }
}

타임아웃(Timeout) 패턴과의 결합

타임아웃 패턴은 응답이 지연될 경우 일정 시간 후 요청을 취소하는 패턴이다. 서킷 브레이커와 함께 사용하면 시스템 리소스를 효율적으로 관리할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Resilience4j의 타임리미터 구현 예시
@Service
public class OrderService {
    
    private final RestTemplate restTemplate;
    private final CircuitBreaker circuitBreaker;
    private final TimeLimiter timeLimiter;
    
    public OrderService(RestTemplate restTemplate, 
                        CircuitBreakerRegistry circuitBreakerRegistry,
                        TimeLimiterRegistry timeLimiterRegistry) {
        this.restTemplate = restTemplate;
        this.circuitBreaker = circuitBreakerRegistry.circuitBreaker("orderService");
        this.timeLimiter = timeLimiterRegistry.timeLimiter("orderService");
    }
    
    public CompletableFuture<Order> createOrder(OrderRequest request) {
        // 타임리미터 + 서킷 브레이커 조합
        Supplier<CompletableFuture<Order>> futureSupplier = () -> {
            return CompletableFuture.supplyAsync(() -> 
                restTemplate.postForObject("/orders", request, Order.class)
            );
        };
        
        Supplier<CompletableFuture<Order>> decoratedSupplier = 
            TimeLimiter.decorateFutureSupplier(
                timeLimiter,
                CircuitBreaker.decorateSupplier(circuitBreaker, futureSupplier)
            );
        
        return Try.ofSupplier(decoratedSupplier)
            .recover(TimeoutException.class, e -> CompletableFuture.completedFuture(
                new Order(null, "TIMEOUT", "주문 처리 시간 초과")
            ))
            .recover(CallNotPermittedException.class, e -> CompletableFuture.completedFuture(
                new Order(null, "CIRCUIT_OPEN", "서비스 일시적으로 사용 불가")
            ))
            .recover(throwable -> CompletableFuture.completedFuture(
                new Order(null, "ERROR", "주문 처리 중 오류 발생")
            ))
            .get();
    }
}

서킷 브레이커 패턴의 장단점

장점

  1. 시스템 안정성 향상: 연쇄 장애 방지 및 시스템 복원력 증가
  2. 리소스 최적화: 실패할 것이 명백한 요청 차단으로 리소스 절약
  3. 사용자 경험 개선: 빠른 실패 응답으로 사용자 대기 시간 감소
  4. 장애 격리: 문제가 있는 서비스를 격리하여 전체 시스템 보호
  5. 자동 복구: 시스템 자가 회복 메커니즘 제공

단점

  1. 구현 복잡성: 적절한 임계값과 타임아웃 설정이 어려울 수 있음
  2. 오버헤드: 각 요청에 대한 추가 처리 로직 필요
  3. 폴백 설계 난이도: 적절한 대체 응답 설계가 어려울 수 있음
  4. 테스트 어려움: 다양한 장애 상황 시뮬레이션 필요
  5. 임계값 설정 문제: 너무 민감하거나 둔감한 설정이 시스템에 영향을 줄 수 있음

서킷 브레이커 패턴 구현 시 모범 사례

  • 설계 모범 사례

    1. 서비스별 맞춤 설정: 각 서비스의 특성에 맞게 임계값과 타임아웃 설정
    2. 점진적 도입: 중요도가 낮은 서비스부터 시작하여 점진적으로 확대
    3. 폴백 전략 구체화: 서비스 특성에 맞는 의미 있는 대체 응답 설계
    4. 로깅 및 알림 통합: 서킷 브레이커 상태 변화를 로깅하고 알림 설정
  • 구현 모범 사례

    1. 처리 시간 제한: 타임아웃 설정으로 응답 지연 방지
    2. 점진적 복구: 반열림 상태에서 요청 수를 점진적으로 증가시켜 안정성 확보
    3. 상태 저장소 분리: 서킷 브레이커 상태를 분산 저장소에 저장하여 다중 인스턴스 환경 지원
    4. 실패 유형 구분: 모든 오류를 동일하게 취급하지 않고 일시적/영구적 오류 구분
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
    // 실패 유형 구분 예시
    CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
        .failureRateThreshold(50)
        .waitDurationInOpenState(Duration.ofMillis(1000))
        .permittedNumberOfCallsInHalfOpenState(2)
        // 일시적 오류로 간주할 예외 유형 지정
        .recordExceptions(
            IOException.class,
            TimeoutException.class,
            HttpServerErrorException.class
        )
        // 영구적 오류로 간주하여 카운트하지 않을 예외 유형 지정
        .ignoreExceptions(
            HttpClientErrorException.BadRequest.class,
            IllegalArgumentException.class
        )
        .build();
    

실제 환경에서의 서킷 브레이커 적용 전략

서킷 브레이커는 이론적으로는 단순하지만, 실제 환경에서 효과적으로 구현하려면 세심한 계획과 전략이 필요하다.

마이크로서비스 아키텍처에서의 적용

마이크로서비스 아키텍처에서는 서비스 간 통신이 빈번하게 발생하므로 서킷 브레이커가 특히 중요하다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 마이크로서비스 환경에서의 서킷 브레이커 구성 예시
@Configuration
public class MicroserviceResilienceConfig {
    
    @Bean
    public CircuitBreakerRegistry circuitBreakerRegistry() {
        // 기본 설정
        CircuitBreakerConfig defaultConfig = CircuitBreakerConfig.custom()
            .failureRateThreshold(50)
            .slowCallRateThreshold(50)
            .slowCallDurationThreshold(Duration.ofSeconds(2))
            .permittedNumberOfCallsInHalfOpenState(10)
            .slidingWindowSize(100)
            .minimumNumberOfCalls(10)
            .waitDurationInOpenState(Duration.ofSeconds(60))
            .build();
            
        // 서비스별 맞춤 설정 지도
        Map<String, CircuitBreakerConfig> configs = new HashMap<>();
        
        // 결제 서비스 - 매우 중요한 서비스이므로 더 관대한 설정
        configs.put("paymentService", CircuitBreakerConfig.custom()
            .failureRateThreshold(70)  // 70% 실패율에서만 열림
            .waitDurationInOpenState(Duration.ofSeconds(30))
            .build());
            
        // 알림 서비스 - 덜 중요한 서비스이므로 더 엄격한 설정
        configs.put("notificationService", CircuitBreakerConfig.custom()
            .failureRateThreshold(30)  // 30% 실패율에서 열림
            .waitDurationInOpenState(Duration.ofSeconds(120))
            .build());
            
        // 레지스트리 생성 및 반환
        return CircuitBreakerRegistry.of(defaultConfig, configs);
    }
    
    // 각 서비스별 CircuitBreaker 빈 생성
    @Bean
    public CircuitBreaker paymentCircuitBreaker(CircuitBreakerRegistry registry) {
        return registry.circuitBreaker("paymentService");
    }
    
    @Bean
    public CircuitBreaker notificationCircuitBreaker(CircuitBreakerRegistry registry) {
        return registry.circuitBreaker("notificationService");
    }
    
    @Bean
    public CircuitBreaker inventoryCircuitBreaker(CircuitBreakerRegistry registry) {
        return registry.circuitBreaker("inventoryService");
    }
}

클라우드 네이티브 환경에서의 구현

쿠버네티스와 같은 클라우드 네이티브 환경에서는 서킷 브레이커를 효과적으로 관리하기 위한 추가 전략이 필요하다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Istio 서비스 메시를 사용한 서킷 브레이커 구현 예시
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: order-service
spec:
  host: order-service
  trafficPolicy:
    connectionPool:
      tcp:
        maxConnections: 100
        connectTimeout: 30ms
      http:
        http1MaxPendingRequests: 1
        maxRequestsPerConnection: 1
    outlierDetection:
      consecutiveErrors: 5
      interval: 30s
      baseEjectionTime: 60s
      maxEjectionPercent: 100

대규모 시스템에서의 서킷 브레이커 계층화

대규모 시스템에서는 다양한 수준에서 서킷 브레이커를 계층화하여 더 정교한 장애 관리가 가능하다.

  1. API 게이트웨이 수준:
    • 클라이언트에서 들어오는 요청 관리
    • 전체 서비스 가용성 보호
  2. 서비스 간 통신 수준:
    • 마이크로서비스 간 통신 보호
    • 장애 격리
  3. 리소스 액세스 수준:
    • 데이터베이스, 캐시, 메시지 큐 등 리소스 연결 보호
    • 백엔드 리소스 과부하 방지
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 계층화된 서킷 브레이커 예시 (데이터베이스 액세스 레벨)
@Repository
public class OrderRepository {
    
    private final JdbcTemplate jdbcTemplate;
    private final CircuitBreaker readCircuitBreaker;   // 읽기 작업용
    private final CircuitBreaker writeCircuitBreaker;  // 쓰기 작업용
    
    public OrderRepository(JdbcTemplate jdbcTemplate, CircuitBreakerRegistry registry) {
        this.jdbcTemplate = jdbcTemplate;
        // 읽기 작업은 더 관대한 설정(더 많은 실패 허용)
        this.readCircuitBreaker = registry.circuitBreaker("orderDbRead");
        // 쓰기 작업은 더 엄격한 설정(적은 실패만 허용)
        this.writeCircuitBreaker = registry.circuitBreaker("orderDbWrite");
    }
    
    public Optional<Order> findById(Long id) {
        return readCircuitBreaker.executeSupplier(() -> {
            // 읽기 작업 실행
            String sql = "SELECT * FROM orders WHERE id = ?";
            return jdbcTemplate.query(sql, new Object[]{id}, (rs, rowNum) -> {
                // 결과 매핑
                return new Order(
                    rs.getLong("id"),
                    rs.getString("status"),
                    rs.getBigDecimal("total_amount")
                );
            }).stream().findFirst();
        });
    }
    
    public void save(Order order) {
        writeCircuitBreaker.executeRunnable(() -> {
            // 쓰기 작업 실행
            if (order.getId() == null) {
                // 신규 주문 생성
                String sql = "INSERT INTO orders (status, total_amount) VALUES (?, ?)";
                jdbcTemplate.update(sql, order.getStatus(), order.getTotalAmount());
            } else {
                // 기존 주문 업데이트
                String sql = "UPDATE orders SET status = ?, total_amount = ? WHERE id = ?";
                jdbcTemplate.update(sql, order.getStatus(), order.getTotalAmount(), order.getId());
            }
        });
    }
}

서킷 브레이커와 관련된 다른 안정성 패턴

서킷 브레이커는 다른 안정성 패턴들과 함께 사용될 때 더 효과적이다. 이러한 패턴들을 조합하여 종합적인 안정성 전략을 구축할 수 있다.

재시도(Retry) 패턴

일시적인 오류가 발생할 경우 즉시 실패하지 않고 몇 번의 재시도를 통해 성공할 기회를 제공한다. 서킷 브레이커와 함께 사용할 경우, 재시도는 서킷이 열리기 전에 적용되어야 한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Resilience4j를 사용한 재시도 + 서킷 브레이커 패턴
@Service
public class PaymentService {
    
    private final RestTemplate restTemplate;
    private final CircuitBreaker circuitBreaker;
    private final Retry retry;
    
    public PaymentService(RestTemplate restTemplate, 
                         CircuitBreakerRegistry circuitBreakerRegistry,
                         RetryRegistry retryRegistry) {
        this.restTemplate = restTemplate;
        this.circuitBreaker = circuitBreakerRegistry.circuitBreaker("paymentService");
        this.retry = retryRegistry.retry("paymentRetry");
    }
    
    public PaymentResponse processPayment(PaymentRequest request) {
        // 재시도 + 서킷 브레이커 조합 (주의: 순서가 중요)
        Supplier<PaymentResponse> paymentSupplier = () ->
            restTemplate.postForObject("/process", request, PaymentResponse.class);
            
        // 재시도는 내부에, 서킷 브레이커는 외부에 위치
        // 이렇게 하면 재시도 후에도 실패하면 서킷 브레이커에 기록됨
        return circuitBreaker.executeSupplier(
            Retry.decorateSupplier(retry, paymentSupplier)
        );
    }
}

폴백(Fallback) 패턴

서비스 호출이 실패할 경우 대체 응답을 제공하는 패턴이다. 서킷 브레이커와 함께 사용하여 서킷이 열렸을 때 의미 있는 대체 응답을 제공할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 다양한 폴백 전략 구현 예시
@Service
public class ProductCatalogService {
    
    private final RestTemplate restTemplate;
    private final CircuitBreaker circuitBreaker;
    private final Cache<Long, Product> productCache;  // 로컬 캐시
    
    public ProductCatalogService(RestTemplate restTemplate, 
                                CircuitBreakerRegistry registry,
                                Cache<Long, Product> productCache) {
        this.restTemplate = restTemplate;
        this.circuitBreaker = registry.circuitBreaker("productService");
        this.productCache = productCache;
    }
    
    public Product getProductDetails(Long productId) {
        // 폴백 체인 구현
        return Try.ofSupplier(() -> 
            // 1. 기본 경로: 서킷 브레이커를 통한 서비스 호출
            circuitBreaker.executeSupplier(() -> 
                restTemplate.getForObject("/products/{id}", Product.class, productId)
            )
        ).recover(CallNotPermittedException.class, e -> {
            // 2. 서킷이 열렸을 때: 캐시에서 조회
            log.warn("서킷 열림, 캐시에서 상품 정보 조회: {}", productId);
            Product cachedProduct = productCache.getIfPresent(productId);
            if (cachedProduct != null) {
                // 캐시 데이터가 있으면 반환하되 캐시 데이터임을 표시
                cachedProduct.setSource("cache");
                return cachedProduct;
            }
            // 캐시에도 없으면 다음 폴백으로
            throw new RuntimeException("캐시에서 상품을 찾을 수 없음", e);
        }).recover(Exception.class, e -> {
            // 3. 최종 폴백: 기본 상품 정보 반환
            log.error("상품 정보 조회 실패, 기본 정보 반환: {}", productId, e);
            Product defaultProduct = new Product();
            defaultProduct.setId(productId);
            defaultProduct.setName("상품 정보 일시적으로 이용 불가");
            defaultProduct.setPrice(BigDecimal.ZERO);
            defaultProduct.setSource("fallback");
            return defaultProduct;
        }).get();
    }
}

캐시(Cache) 패턴

자주 사용되는 데이터를 캐싱하여 서비스 호출을 줄이는 패턴이다. 서킷 브레이커와 함께 사용하면 서킷이 열렸을 때 캐시된 데이터를 폴백으로 사용할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// 캐시와 서킷 브레이커 통합 예시 (Caffeine 캐시 사용)
@Configuration
public class CacheConfig {
    
    @Bean
    public Cache<Long, Product> productCache() {
        return Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(1, TimeUnit.HOURS)
            .build();
    }
}

@Service
public class CachingProductService {
    
    private final RestTemplate restTemplate;
    private final CircuitBreaker circuitBreaker;
    private final Cache<Long, Product> productCache;
    
    public CachingProductService(RestTemplate restTemplate, 
                               CircuitBreakerRegistry registry,
                               Cache<Long, Product> productCache) {
        this.restTemplate = restTemplate;
        this.circuitBreaker = registry.circuitBreaker("productService");
        this.productCache = productCache;
    }
    
    public Product getProduct(Long id) {
        // 1. 먼저 캐시에서 조회
        Product cachedProduct = productCache.getIfPresent(id);
        if (cachedProduct != null) {
            log.debug("캐시에서 상품 정보 조회: {}", id);
            return cachedProduct;
        }
        
        // 2. 캐시에 없으면 서킷 브레이커를 통해 서비스 호출
        Product product = circuitBreaker.executeSupplier(() -> {
            log.debug("상품 서비스에서 상품 정보 조회: {}", id);
            return restTemplate.getForObject("/products/{id}", Product.class, id);
        });
        
        // 3. 성공적으로 조회했으면 캐시에 저장
        if (product != null) {
            productCache.put(id, product);
        }
        
        return product;
    }
    
    // 캐시 사용 폴백 전략
    public Function<Throwable, Product> createCacheFallback(Long id) {
        return throwable -> {
            Product cachedProduct = productCache.getIfPresent(id);
            if (cachedProduct != null) {
                log.warn("서비스 호출 실패, 캐시에서 상품 정보 반환: {}", id);
                return cachedProduct;
            }
            
            log.error("서비스 호출 실패, 캐시에도 없음: {}", id);
            throw new RuntimeException("상품 정보를 가져올 수 없습니다.", throwable);
        };
    }
}

마이그레이션 관점에서의 서킷 브레이커 도입 전략

기존 시스템에 서킷 브레이커 패턴을 도입하는 것은 중요한 마이그레이션 작업이다.

단계적 도입 프로세스

서킷 브레이커를 도입할 때는 다음과 같은 단계적 접근이 효과적:

  1. 현황 분석: 장애 발생 빈도가 높은 서비스와 의존성을 파악한다.
  2. 우선순위 설정: 중요도가 낮지만 장애 가능성이 높은 서비스부터 적용한다.
  3. 테스트 환경 구현: 프로덕션 환경과 유사한 테스트 환경에서 먼저 검증한다.
  4. 모니터링 체계 구축: 서킷 브레이커 동작을 모니터링할 수 있는 체계를 구축한다.
  5. 점진적 롤아웃: 한 번에 모든 서비스에 적용하지 않고 점진적으로 확대한다.
  6. 피드백 및 최적화: 실제 운영 데이터를 기반으로 설정을 지속적으로 최적화한다.

기존 코드에 서킷 브레이커 추가 전략

기존 코드에 서킷 브레이커를 추가하는 방법은 여러 가지가 있다:

데코레이터 패턴 사용

기존 코드를 수정하지 않고 서킷 브레이커를 적용할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 데코레이터 패턴을 사용한 서킷 브레이커 적용
public class CircuitBreakerDecorator implements PaymentGateway {
    
    private final PaymentGateway originalGateway;
    private final CircuitBreaker circuitBreaker;
    
    public CircuitBreakerDecorator(PaymentGateway originalGateway, CircuitBreaker circuitBreaker) {
        this.originalGateway = originalGateway;
        this.circuitBreaker = circuitBreaker;
    }
    
    @Override
    public PaymentResult processPayment(PaymentRequest request) {
        return circuitBreaker.executeSupplier(() -> originalGateway.processPayment(request));
    }
    
    @Override
    public PaymentStatus checkStatus(String paymentId) {
        return circuitBreaker.executeSupplier(() -> originalGateway.checkStatus(paymentId));
    }
    
    @Override
    public RefundResult refund(RefundRequest request) {
        return circuitBreaker.executeSupplier(() -> originalGateway.refund(request));
    }
}

// 사용 예시
@Configuration
public class PaymentConfig {
    
    @Bean
    public PaymentGateway paymentGateway(PaymentGateway originalGateway, CircuitBreakerRegistry registry) {
        CircuitBreaker circuitBreaker = registry.circuitBreaker("paymentGateway");
        return new CircuitBreakerDecorator(originalGateway, circuitBreaker);
    }
}
AOP(Aspect-Oriented Programming) 활용

공통 관심사로 서킷 브레이커를 적용하여 코드 중복을 줄일 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// AOP를 사용한 서킷 브레이커 적용
@Aspect
@Component
public class CircuitBreakerAspect {
    
    private final CircuitBreakerRegistry registry;
    private final Map<String, CircuitBreaker> circuitBreakers = new ConcurrentHashMap<>();
    
    public CircuitBreakerAspect(CircuitBreakerRegistry registry) {
        this.registry = registry;
    }
    
    @Around("@annotation(circuitBreaker)")
    public Object applyCircuitBreaker(ProceedingJoinPoint joinPoint, 
                                     CircuitBreakerAnnotation circuitBreaker) throws Throwable {
        
        String name = circuitBreaker.name();
        CircuitBreaker breaker = circuitBreakers.computeIfAbsent(
            name, k -> registry.circuitBreaker(name)
        );
        
        try {
            return breaker.executeSupplier(() -> {
                try {
                    return joinPoint.proceed();
                } catch (Throwable t) {
                    if (t instanceof RuntimeException) {
                        throw (RuntimeException) t;
                    }
                    throw new RuntimeException(t);
                }
            });
        } catch (CallNotPermittedException e) {
            // 서킷 브레이커가 열린 상태
            return handleFallback(joinPoint, circuitBreaker, e);
        }
    }
    
    private Object handleFallback(ProceedingJoinPoint joinPoint, 
                                CircuitBreakerAnnotation circuitBreaker,
                                Exception e) throws Throwable {
        String fallbackMethod = circuitBreaker.fallbackMethod();
        if (fallbackMethod.isEmpty()) {
            throw e;
        }
        
        // 폴백 메서드 호출
        Method method = findFallbackMethod(joinPoint, fallbackMethod);
        return method.invoke(joinPoint.getTarget(), joinPoint.getArgs());
    }
    
    private Method findFallbackMethod(ProceedingJoinPoint joinPoint, String fallbackMethod) 
            throws NoSuchMethodException {
        // 폴백 메서드 찾기 로직
        Class<?> targetClass = joinPoint.getTarget().getClass();
        Method method = joinPoint.getSignature() instanceof MethodSignature ?
                        ((MethodSignature) joinPoint.getSignature()).getMethod() : null;
                        
        if (method == null) {
            throw new IllegalStateException("메서드 시그니처를 가져올 수 없습니다");
        }
        
        return targetClass.getMethod(fallbackMethod, method.getParameterTypes());
    }
}

// 애노테이션 정의
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CircuitBreakerAnnotation {
    String name();
    String fallbackMethod() default "";
}

// 사용 예시
@Service
public class ExternalServiceClient {
    
    private final RestTemplate restTemplate;
    
    public ExternalServiceClient(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }
    
    @CircuitBreakerAnnotation(name = "externalService", fallbackMethod = "getDataFallback")
    public ExternalData getData(String id) {
        return restTemplate.getForObject("/api/data/{id}", ExternalData.class, id);
    }
    
    public ExternalData getDataFallback(String id) {
        // 폴백 로직
        return new ExternalData(id, "일시적으로 이용 불가", Collections.emptyList());
    }
}

레거시 시스템 마이그레이션 전략

레거시 시스템에 서킷 브레이커를 도입할 때는 추가적인 고려사항이 있다:

  1. 프록시 계층 추가: 레거시 코드를 수정하지 않고 프록시 계층을 추가하여 서킷 브레이커 적용
  2. API 게이트웨이 활용: 레거시 시스템 앞에 API 게이트웨이를 배치하여 게이트웨이 수준에서 서킷 브레이커 구현
  3. 사이드카 패턴: 서비스 메시 아키텍처를 활용하여 사이드카 컨테이너로 서킷 브레이커 기능 제공
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# Istio 서비스 메시를 사용한 레거시 시스템 보호 예시
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: legacy-service-route
spec:
  hosts:
  - legacy-service
  http:
  - route:
    - destination:
        host: legacy-service
    timeout: 5s
    retries:
      attempts: 3
      perTryTimeout: 2s

---
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: legacy-service-circuit-breaker
spec:
  host: legacy-service
  trafficPolicy:
    connectionPool:
      tcp:
        maxConnections: 100
      http:
        http1MaxPendingRequests: 10
        maxRequestsPerConnection: 10
    outlierDetection:
      consecutiveErrors: 5
      interval: 30s
      baseEjectionTime: 30s

용어 정리

용어설명

참고 및 출처