Resilience4j 운영하기 - Actuator + Prometheus + Grafana

2025년 12월 22일

devops

# Resilience4j# Monitoring# Prometheus# Grafana# Actuator# Observability

들어가며

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: true

3. 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: prometheus

3. 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.0

4. 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 -d

Prometheus UI: http://localhost:9090

쿼리 예시:

resilience4j_circuitbreaker_state{name="paymentService"}

Grafana 대시보드

1. Datasource 연결

  1. Grafana 접속: http://localhost:3000 (admin/admin)
  2. Configuration > Data Sources > Add data source
  3. Prometheus 선택
  4. URL: http://prometheus:9090
  5. Save & Test

2. 대시보드 패널 구성

패널 1: Circuit Breaker 상태

Query:

resilience4j_circuitbreaker_state

Visualization: Stat

Value mappings:

  • 0 → CLOSED (Green)
  • 1 → OPEN (Red)
  • 2 → HALF_OPEN (Yellow)

패널 2: 실패율 그래프

Query:

resilience4j_circuitbreaker_failure_rate

Visualization: Time series

Thresholds:

  • 50% → Yellow
  • 80% → Red

패널 3: 호출 성공/실패 추이

Query 1 (성공):

rate(resilience4j_circuitbreaker_calls_total{kind="successful"}[1m]) * 60

Query 2 (실패):

rate(resilience4j_circuitbreaker_calls_total{kind="failed"}[1m]) * 60

Query 3 (차단):

rate(resilience4j_circuitbreaker_calls_total{kind="not_permitted"}[1m]) * 60

Visualization: Time series

패널 4: Retry 횟수 추이

Query 1 (Retry 없이 성공):

rate(resilience4j_retry_calls_total{kind="successful_without_retry"}[1m]) * 60

Query 2 (Retry 후 성공):

rate(resilience4j_retry_calls_total{kind="successful_with_retry"}[1m]) * 60

Query 3 (Retry 후에도 실패):

rate(resilience4j_retry_calls_total{kind="failed_with_retry"}[1m]) * 60

Visualization: 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 연동

  1. Grafana > Alerting > Contact points
  2. New contact point
  3. Integration: Slack
  4. Webhook URL 입력
  5. 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/)
© 2025, 미나리와 함께 만들었음