들어가며
Circuit Breaker를 적용했습니다. 하지만 Circuit이 Open됐는데 모르고 있다면? 실패율이 급증하는데 알림이 없다면?
프로덕션에서 안정적으로 운영하려면 **관찰 가능성(Observability)**이 필수입니다. 이 글에서는 Resilience4j 메트릭을 수집하고, 시각화하고, 알림을 설정하는 방법을 다룹니다.
모니터링이 필요한 이유
실제 장애 시나리오
10:00 - 결제 서비스 장애 발생
10:01 - Circuit Breaker Open (하지만 아무도 모름)
10:05 - 고객 문의 폭주 "결제가 안 돼요!"
10:10 - 개발팀 파악 시작
10:20 - 결제 서비스 복구
10:25 - Circuit Breaker 수동으로 Half-Open 전환
10:30 - 정상화문제점:
- Circuit Open을 10분 뒤에 알게 됨
- 고객이 먼저 발견
- 수동 개입 필요
모니터링 구축 후
10:00 - 결제 서비스 장애 발생
10:01 - Circuit Breaker Open
10:01 - Slack 알림 "🚨 paymentService Circuit OPEN"
10:02 - 개발팀 즉시 파악, 결제팀에 전파
10:05 - 결제 서비스 복구
10:05 - Circuit Breaker 자동 Half-Open → Closed
10:05 - Slack 알림 "✅ paymentService Circuit CLOSED"개선 효과:
- 장애를 즉시 인지
- 자동 복구
- 고객 영향 최소화
Actuator 통합
1. 의존성 추가
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.github.resilience4j:resilience4j-micrometer:2.2.0")
}2. application.yml 설정
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus,circuitbreakers,circuitbreakerevents
endpoint:
health:
show-details: always
health:
circuitbreakers:
enabled: true
metrics:
tags:
application: ${spring.application.name}
distribution:
percentiles-histogram:
resilience4j.circuitbreaker.calls: true3. Health Endpoint 확인
curl http://localhost:8080/actuator/health | jq응답:
{
"status": "UP",
"components": {
"circuitBreakers": {
"status": "UP",
"details": {
"paymentService": {
"status": "UP",
"details": {
"state": "CLOSED",
"failureRate": "0.0%",
"slowCallRate": "0.0%",
"bufferedCalls": 10,
"failedCalls": 0,
"slowCalls": 0,
"notPermittedCalls": 0
}
},
"inventoryService": {
"status": "CIRCUIT_OPEN",
"details": {
"state": "OPEN",
"failureRate": "100.0%",
"bufferedCalls": 10,
"failedCalls": 10,
"notPermittedCalls": 5
}
}
}
}
}
}4. Metrics Endpoint
curl http://localhost:8080/actuator/metrics/resilience4j.circuitbreaker.calls | jq응답:
{
"name": "resilience4j.circuitbreaker.calls",
"measurements": [
{
"statistic": "COUNT",
"value": 150.0
}
],
"availableTags": [
{
"tag": "name",
"values": ["paymentService", "inventoryService"]
},
{
"tag": "kind",
"values": ["successful", "failed", "not_permitted"]
}
]
}5. 주요 메트릭 종류
| 메트릭 | 설명 | 값 예시 |
|---|---|---|
resilience4j.circuitbreaker.state |
Circuit Breaker 상태 | 0=CLOSED, 1=OPEN, 2=HALF_OPEN |
resilience4j.circuitbreaker.calls |
호출 횟수 (kind별) | successful, failed, not_permitted |
resilience4j.circuitbreaker.failure.rate |
실패율 | 0.0 ~ 100.0 |
resilience4j.circuitbreaker.buffered.calls |
버퍼된 호출 수 | 10 (slidingWindowSize) |
resilience4j.retry.calls |
Retry 호출 횟수 | successful_without_retry, successful_with_retry, failed_with_retry |
Prometheus 통합
1. 의존성 추가
dependencies {
implementation("io.micrometer:micrometer-registry-prometheus")
}2. Prometheus Endpoint 활성화
# application.yml
management:
endpoints:
web:
exposure:
include: prometheus3. Prometheus Endpoint 확인
curl http://localhost:8080/actuator/prometheus응답 (일부):
# HELP resilience4j_circuitbreaker_calls_total Total number of calls by kind
# TYPE resilience4j_circuitbreaker_calls_total counter
resilience4j_circuitbreaker_calls_total{application="order-service",kind="successful",name="paymentService",} 145.0
resilience4j_circuitbreaker_calls_total{application="order-service",kind="failed",name="paymentService",} 5.0
resilience4j_circuitbreaker_calls_total{application="order-service",kind="not_permitted",name="paymentService",} 0.0
# HELP resilience4j_circuitbreaker_state Circuit Breaker State (0=CLOSED, 1=OPEN, 2=HALF_OPEN)
# TYPE resilience4j_circuitbreaker_state gauge
resilience4j_circuitbreaker_state{application="order-service",name="paymentService",} 0.0
resilience4j_circuitbreaker_state{application="order-service",name="inventoryService",} 1.0
# HELP resilience4j_circuitbreaker_failure_rate Failure rate
# TYPE resilience4j_circuitbreaker_failure_rate gauge
resilience4j_circuitbreaker_failure_rate{application="order-service",name="paymentService",} 3.33
# HELP resilience4j_retry_calls_total Total number of retry calls
# TYPE resilience4j_retry_calls_total counter
resilience4j_retry_calls_total{application="order-service",kind="successful_without_retry",name="paymentService",} 100.0
resilience4j_retry_calls_total{application="order-service",kind="successful_with_retry",name="paymentService",} 30.0
resilience4j_retry_calls_total{application="order-service",kind="failed_with_retry",name="paymentService",} 5.04. Prometheus 설정 (docker-compose)
# docker-compose.yml
version: '3.8'
services:
prometheus:
image: prom/prometheus:v2.48.0
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
grafana:
image: grafana/grafana:10.2.2
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana_data:/var/lib/grafana
volumes:
prometheus_data:
grafana_data:# prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'spring-boot-app'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['host.docker.internal:8080']
labels:
application: 'order-service'5. Prometheus 실행
docker-compose up -dPrometheus UI: http://localhost:9090
쿼리 예시:
resilience4j_circuitbreaker_state{name="paymentService"}Grafana 대시보드
1. Datasource 연결
- Grafana 접속: http://localhost:3000 (admin/admin)
- Configuration > Data Sources > Add data source
- Prometheus 선택
- URL:
http://prometheus:9090 - Save & Test
2. 대시보드 패널 구성
패널 1: Circuit Breaker 상태
Query:
resilience4j_circuitbreaker_stateVisualization: Stat
Value mappings:
- 0 → CLOSED (Green)
- 1 → OPEN (Red)
- 2 → HALF_OPEN (Yellow)
패널 2: 실패율 그래프
Query:
resilience4j_circuitbreaker_failure_rateVisualization: Time series
Thresholds:
- 50% → Yellow
- 80% → Red
패널 3: 호출 성공/실패 추이
Query 1 (성공):
rate(resilience4j_circuitbreaker_calls_total{kind="successful"}[1m]) * 60Query 2 (실패):
rate(resilience4j_circuitbreaker_calls_total{kind="failed"}[1m]) * 60Query 3 (차단):
rate(resilience4j_circuitbreaker_calls_total{kind="not_permitted"}[1m]) * 60Visualization: Time series
패널 4: Retry 횟수 추이
Query 1 (Retry 없이 성공):
rate(resilience4j_retry_calls_total{kind="successful_without_retry"}[1m]) * 60Query 2 (Retry 후 성공):
rate(resilience4j_retry_calls_total{kind="successful_with_retry"}[1m]) * 60Query 3 (Retry 후에도 실패):
rate(resilience4j_retry_calls_total{kind="failed_with_retry"}[1m]) * 60Visualization: Bar gauge
패널 5: 평균 응답 시간
Query:
rate(http_server_requests_seconds_sum{uri=~"/api/.*"}[1m])
/
rate(http_server_requests_seconds_count{uri=~"/api/.*"}[1m])Visualization: Time series
알림 설정
1. Grafana Alert Rules
Alert 1: Circuit Breaker Open
# Alert Rule
name: Circuit Breaker Open
condition: resilience4j_circuitbreaker_state == 1
for: 1m
annotations:
summary: "Circuit Breaker {{ $labels.name }} is OPEN"
description: "Circuit Breaker {{ $labels.name }}이(가) OPEN 상태입니다."Alert 2: 실패율 임계값 초과
name: High Failure Rate
condition: resilience4j_circuitbreaker_failure_rate > 50
for: 3m
annotations:
summary: "High failure rate on {{ $labels.name }}"
description: "{{ $labels.name }}의 실패율이 {{ $value }}%입니다."2. Slack 연동
- Grafana > Alerting > Contact points
- New contact point
- Integration: Slack
- Webhook URL 입력
- Test 후 Save
Notification 예시:
🚨 Alert: Circuit Breaker Open
Circuit Breaker paymentService is OPEN
- Application: order-service
- State: OPEN (1.0)
- Time: 2025-12-22 10:01:00
Dashboard: [View](http://localhost:3000/d/resilience4j)3. Alert 정책
| Alert | 조건 | 지속 시간 | 심각도 | 채널 |
|---|---|---|---|---|
| Circuit Open | state == 1 | 1분 | Critical | Slack |
| 실패율 50% 초과 | failure_rate > 50 | 3분 | Warning | Slack |
| 실패율 80% 초과 | failure_rate > 80 | 1분 | Critical | Slack + PagerDuty |
| 차단 요청 급증 | not_permitted > 100/min | 2분 | Warning | Slack |
실시간 이벤트 스트림
EventConsumerRegistry로 이벤트 수집
@Configuration
class CircuitBreakerEventListener(
private val circuitBreakerRegistry: CircuitBreakerRegistry,
private val slackNotifier: SlackNotifier
) {
private val logger = KotlinLogging.logger {}
@PostConstruct
fun registerEventListeners() {
circuitBreakerRegistry.allCircuitBreakers.forEach { cb ->
cb.eventPublisher
.onStateTransition { event ->
handleStateTransition(event)
}
.onFailureRateExceeded { event ->
handleFailureRateExceeded(event)
}
.onCallNotPermitted { event ->
logger.warn { "Call not permitted on ${cb.name}" }
}
}
}
private fun handleStateTransition(event: CircuitBreakerOnStateTransitionEvent) {
val from = event.stateTransition.fromState
val to = event.stateTransition.toState
val name = event.circuitBreakerName
logger.warn { "Circuit Breaker $name: $from → $to" }
when (to) {
CircuitBreaker.State.OPEN -> {
slackNotifier.sendAlert(
title = "🚨 Circuit Breaker OPEN",
message = "Circuit Breaker `$name`이(가) OPEN 상태로 전환되었습니다.",
color = "danger"
)
}
CircuitBreaker.State.HALF_OPEN -> {
slackNotifier.sendInfo(
title = "🔄 Circuit Breaker HALF_OPEN",
message = "Circuit Breaker `$name`이(가) 복구를 시도합니다.",
color = "warning"
)
}
CircuitBreaker.State.CLOSED -> {
if (from == CircuitBreaker.State.HALF_OPEN) {
slackNotifier.sendInfo(
title = "✅ Circuit Breaker CLOSED",
message = "Circuit Breaker `$name`이(가) 정상 복구되었습니다.",
color = "good"
)
}
}
}
}
private fun handleFailureRateExceeded(event: CircuitBreakerOnFailureRateExceededEvent) {
val name = event.circuitBreakerName
val rate = event.failureRate
logger.error { "Circuit Breaker $name: Failure rate $rate% exceeded" }
slackNotifier.sendAlert(
title = "⚠️ High Failure Rate",
message = "Circuit Breaker `$name`의 실패율이 ${rate.format(2)}%입니다.",
color = "warning"
)
}
}
// Slack Notifier
@Component
class SlackNotifier {
private val webhookUrl = System.getenv("SLACK_WEBHOOK_URL")
private val webClient = WebClient.create()
fun sendAlert(title: String, message: String, color: String) {
val payload = """
{
"attachments": [{
"title": "$title",
"text": "$message",
"color": "$color",
"footer": "order-service",
"ts": ${System.currentTimeMillis() / 1000}
}]
}
""".trimIndent()
webClient.post()
.uri(webhookUrl)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(payload)
.retrieve()
.bodyToMono(String::class.java)
.subscribe()
}
fun sendInfo(title: String, message: String, color: String) = sendAlert(title, message, color)
}
private fun Double.format(digits: Int) = "%.${digits}f".format(this)Retry 이벤트 리스닝
@PostConstruct
fun registerRetryEventListener() {
retryRegistry.allRetries.forEach { retry ->
retry.eventPublisher
.onRetry { event ->
logger.info {
"Retry ${retry.name}: Attempt ${event.numberOfRetryAttempts} after ${event.waitInterval}"
}
}
.onSuccess { event ->
if (event.numberOfRetryAttempts > 0) {
logger.info {
"Retry ${retry.name}: Success after ${event.numberOfRetryAttempts} retries"
}
}
}
}
}프로덕션 체크리스트
배포 전
- Actuator health endpoint 응답 확인
- Prometheus metrics 수집 확인
- Grafana 대시보드 정상 표시
- Slack 알림 테스트 완료
- Circuit Breaker 설정값 리뷰
- Timeout, Retry 설정값 리뷰
알림 설정
- Circuit Open 알림 → Slack
- 실패율 임계값 알림 → Slack
- Critical 알림 → PagerDuty (선택)
- 알림 수신자 지정
- 알림 테스트 완료
Runbook 작성
# Runbook: Circuit Breaker Open
## 증상
- Slack 알림: "Circuit Breaker paymentService is OPEN"
- Grafana 대시보드에 빨간불
## 즉시 확인 사항
1. 하위 서비스(Payment Service) 상태 확인
2. 최근 배포 이력 확인
3. 로그에서 에러 패턴 확인
## 대응 방법
1. **하위 서비스 장애**: 해당 팀에 에스컬레이션
2. **일시적 장애**: 자동 복구 대기 (Half-Open → Closed)
3. **설정 오류**: Circuit Breaker 설정 조정 후 재배포
## 수동 복구 (긴급 시)
```bash
# Circuit Breaker 강제 CLOSED
curl -X POST http://localhost:8080/actuator/circuitbreakers/paymentService \
-H "Content-Type: application/json" \
-d '{"state":"CLOSED"}'장기 대응
- 실패율 임계값 조정 검토
- Timeout 값 조정 검토
- Fallback 로직 개선
## 마치며
### 이 시리즈에서 배운 것
1. **개념편**: MSA에서 장애 전파를 막는 Circuit Breaker와 Retry 패턴
2. **실전 적용편**: Kotlin + Spring Boot로 Resilience4j 구현
3. **모니터링편**: Actuator + Prometheus + Grafana로 안정적 운영
### 다음 단계
Resilience4j는 Circuit Breaker와 Retry 외에도 다양한 패턴을 제공합니다.
- **Bulkhead**: 리소스(스레드 풀) 격리
- **RateLimiter**: API 요청 제한
- **TimeLimiter**: Timeout 관리 (비동기)
추가 학습 추천:
- Distributed Tracing (OpenTelemetry, Jaeger)
- Service Mesh (Istio, Linkerd)
- Chaos Engineering (Chaos Monkey)
안정적인 MSA 운영하세요! 🚀
---
## 참고 자료
- [Resilience4j Metrics](https://resilience4j.readme.io/docs/micrometer)
- [Spring Boot Actuator Guide](https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html)
- [Prometheus Query Examples](https://prometheus.io/docs/prometheus/latest/querying/examples/)
- [Grafana Dashboard Best Practices](https://grafana.com/docs/grafana/latest/best-practices/)
- [LINE Engineering - 분산 서비스 환경에서 사용하는 Circuit Breaker](https://engineering.linecorp.com/ko/blog/circuit-breakers-for-distributed-services)
- [올리브영 기술 블로그 - Circuit Breaker 실전 가이드](https://oliveyoung.tech/2023-08-31/circuitbreaker-inventory-squad/)