Circuit Breaker#
Circuit Breaker는 분산 시스템에서 장애가 발생하거나 과부하 상태일 때 서비스의 안정성을 유지하기 위한 디자인 패턴이다. 이는 전기 회로의 차단기에서 영감을 받아, 연속적인 실패 시 추가적인 장애 전파를 방지하고 시스템을 보호한다. 특히 마이그레이션 과정에서 서비스 간 의존성이 높은 환경에서 필수적으로 적용된다.
서킷 브레이커 패턴의 기본 개념#
서킷 브레이커 패턴은 전기 회로의 차단기에서 영감을 받은 소프트웨어 디자인 패턴으로, 가정용 전기 차단기가 과부하 시 전기를 차단하여 화재를 방지하는 것처럼, 소프트웨어 서킷 브레이커는 장애가 발생한 서비스에 대한 호출을 일시적으로 중단하여 시스템 전체의 안정성을 보호한다.
핵심 목적#
- 연쇄 장애(Cascading Failures) 방지: 한 서비스의 장애가 다른 서비스로 전파되는 것을 막는다.
- 빠른 실패(Fail Fast): 실패할 것이 명백한 요청을 즉시 거부하여 시스템 자원을 절약한다.
- 자가 회복(Self-Healing): 장애 상황에서 자동으로 복구될 수 있는 메커니즘을 제공한다.
- 우아한 성능 저하(Graceful Degradation): 전체 시스템이 중단되지 않고 부분적으로 기능을 유지할 수 있게 한다.
상태 전이 메커니즘#
서킷 브레이커는 일반적으로 세 가지 상태로 동작한다:
상태 | 설명 | 동작 방식 |
---|
Closed | 정상 작동 상태 | 모든 요청 허용, 실패 시 카운트 증가 |
Open | 장애 발생 상태 | 즉시 실패 반환, 일정 시간 대기 |
Half-Open | 복구 테스트 상태 | 제한된 요청 허용, 성공 시 Closed로 전환 |
닫힘 상태(Closed)#
- 정상 작동 상태.
- 모든 요청이 대상 서비스로 전달된다.
- 실패 횟수를 지속적으로 모니터링한다.
- 설정된 임계값(threshold)을 초과하면 열림 상태로 전환된다.
열림 상태(Open)#
- 장애 상태로 판단하여 모든 요청을 즉시 거부한다.
- 실제 서비스 호출을 시도하지 않고 예외나 대체 응답을 반환한다.
- 설정된 타임아웃 기간이 지나면 반열림 상태로 전환된다.
반열림 상태(Half-Open)#
- 제한된 수의 요청만 서비스로 전달하여 회복 여부를 테스트한다.
- 이 상태에서의 성공/실패율에 따라 닫힘 또는 열림 상태로 전환된다.
- 성공 비율이 높으면 닫힘 상태로, 실패 비율이 높으면 다시 열림 상태로 돌아간다.
작동 원리#
초기 상태(Closed):
- 모든 요청이 백엔드 서비스로 전달된다.
- 실패 횟수가 임계값(예: 5회)을 초과하면 Open 상태로 전환된다.
차단 상태(Open):
- 모든 요청이 즉시 차단되며, 사전 정의된 타임아웃(예: 30초) 동안 유지된다.
- 클라이언트에게 빠른 실패 응답(HTTP 503)을 반환하여 자원 낭비를 방지한다.
테스트 상태(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();
}
}
|
서킷 브레이커의 모니터링 및 관리#
효과적인 서킷 브레이커 구현을 위해서는 적절한 모니터링과 관리가 필수적이다.
핵심 메트릭#
- 호출 성공률/실패율: 서비스 호출의 성공 및 실패 비율
- 응답 시간: 서비스 호출의 평균, 중앙값, 최대 응답 시간
- 서킷 브레이커 상태: 현재 상태(닫힘, 열림, 반열림)와 상태 전환 횟수
- 요청 볼륨: 단위 시간당 요청 수
- 거부 요청 수: 열림 상태에서 거부된 요청 수
모니터링 도구#
Prometheus + Grafana: 메트릭 수집 및 시각화
1
2
3
4
5
| # Resilience4j Prometheus 설정 예시
resilience4j.prometheus:
enabled: true
metrics:
enabled: true
|
Actuator + Spring Boot Admin: 서킷 브레이커 상태 및 메트릭 확인
1
2
3
4
5
6
7
8
| management:
endpoints:
web:
exposure:
include: health,info,circuitbreakers
health:
circuitbreakers:
enabled: true
|
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
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
|
대규모 시스템에서의 서킷 브레이커 계층화#
대규모 시스템에서는 다양한 수준에서 서킷 브레이커를 계층화하여 더 정교한 장애 관리가 가능하다.
- API 게이트웨이 수준:
- 클라이언트에서 들어오는 요청 관리
- 전체 서비스 가용성 보호
- 서비스 간 통신 수준:
- 리소스 액세스 수준:
- 데이터베이스, 캐시, 메시지 큐 등 리소스 연결 보호
- 백엔드 리소스 과부하 방지
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
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());
}
}
|
레거시 시스템 마이그레이션 전략#
레거시 시스템에 서킷 브레이커를 도입할 때는 추가적인 고려사항이 있다:
- 프록시 계층 추가: 레거시 코드를 수정하지 않고 프록시 계층을 추가하여 서킷 브레이커 적용
- API 게이트웨이 활용: 레거시 시스템 앞에 API 게이트웨이를 배치하여 게이트웨이 수준에서 서킷 브레이커 구현
- 사이드카 패턴: 서비스 메시 아키텍처를 활용하여 사이드카 컨테이너로 서킷 브레이커 기능 제공
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
|
용어 정리#
참고 및 출처#