Micrometer 입문 6편 — Registry·백엔드·push vs pull 깊이

2026-05-25Micrometer 입문에서 운영까지

Micrometer 6편. CompositeMeterRegistry로 Prometheus와 Datadog에 동시 전송하는 구조, pull 모델(PrometheusMeterRegistry·scrape)과 push 모델(StepMeterRegistry·DatadogMeterRegistry)의 동작 원리, step 주기 불일치로 생기는 데이터 유실 함정, 배치·단명 잡을 위한 PushGateway 패턴, 테스트용 SimpleMeterRegistry 활용까지.

📚 Micrometer 입문에서 운영까지 · 6편 — Registry·백엔드·push vs pull 깊이

이 글은 Micrometer 입문에서 운영까지 시리즈 6편이에요. 5편까지는 Spring Boot Actuator 가 자동으로 해주는 것들을 살펴봤어요 — JVM·HikariCP·HTTP 자동 계측, @Timed, MeterRegistryCustomizer, Observation API. 6편은 방향을 바꿔서 메트릭을 내보낼 곳을 직접 고르고, 그 내부 동작 원리를 이해하는 자리예요.

Prometheus 만 쓰는 팀은 5편 설정으로 대부분 충분해요. 그런데 Datadog 이나 CloudWatch 를 함께 쓰거나, 배치 잡의 메트릭을 따로 다뤄야 하거나, 마이그레이션 중에 두 백엔드를 동시에 운영해야 하는 순간이 오면 MeterRegistry 내부 구조를 모르면 막막해져요. pull 방식과 push 방식의 차이, step registry 가 데이터를 어떻게 전송하고 왜 주기 불일치가 생기는지 — 이걸 이해하고 나면 설정 파일이 훨씬 다르게 읽혀요.

📚 학습 노트

이 시리즈는 Micrometer 공식 문서, Spring Boot Actuator 공식 가이드, 여러 observability 학습 자료 등 공개 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.

Spring Boot 3.x + Micrometer 환경을 기준으로 작성했어요. 코드 예제는 Micrometer 공식 docs 를 함께 열어두고 읽으면 훨씬 잘 흡수돼요.

이번 글의 범위

이번 글은 MeterRegistry 구현체 계층 전체를 다뤄요.

자리 내용
MeterRegistry 다시 보기 1편의 '입구' 개념을 운영 관점으로 — registry = 백엔드별 구현체
CompositeMeterRegistry 한 계측으로 Prometheus + Datadog 동시 전송, 마이그레이션 패턴
pull 모델 PrometheusMeterRegistry · /actuator/prometheus scrape · 단조 누적 카운터
push 모델 DatadogMeterRegistry · StatsdMeterRegistry · CloudWatchMeterRegistry
StepMeterRegistry step 동안 누적 후 전송·리셋 — rate 개념
step 주기 불일치 함정 scrape/push 주기 어긋나면 데이터 유실
PushGateway 배치·단명 잡의 메트릭을 Prometheus 가 긁어가게
registry lifecycle · 설정 close() · 다중 환경 · 테스트용 SimpleMeterRegistry

MeterRegistry 다시 보기

1편에서 MeterRegistry 를 "모든 Meter 의 등록소"라고 소개했어요. 그 개념은 정확하지만, 운영 관점에서는 한 가지를 더 짚어야 해요. MeterRegistry 는 추상 클래스이고, 실제 동작하는 건 각 백엔드의 구현체예요.

의존성을 추가할 때 micrometer-registry-prometheus, micrometer-registry-datadog 처럼 레지스트리별로 모듈이 나뉘어 있는 게 이 구조를 그대로 반영해요. 각 모듈이 MeterRegistry 를 상속한 구현체를 하나씩 가지고 있어요.

MeterRegistry (추상)
  ├─ PrometheusMeterRegistry    — pull 방식, /actuator/prometheus 노출
  ├─ DatadogMeterRegistry       — push 방식, Datadog API 로 직접 전송
  ├─ StatsdMeterRegistry        — push 방식, UDP 로 StatsD 서버에 전송
  ├─ CloudWatchMeterRegistry    — push 방식, AWS CloudWatch PutMetricData
  ├─ InfluxMeterRegistry        — push 방식, InfluxDB HTTP API
  ├─ OtlpMeterRegistry          — push 방식, OTLP 프로토콜 (OTel Collector)
  ├─ SimpleMeterRegistry        — 인메모리, 백엔드 없음 (테스트·로컬 확인용)
  └─ CompositeMeterRegistry     — 여러 registry 를 묶어 동시에 전달

Spring Boot 환경에서 글로벌 MeterRegistry 빈이 주입될 때, 클래스패스에 Prometheus 의존성만 있으면 PrometheusMeterRegistry 가 주입돼요. Datadog 의존성만 있으면 DatadogMeterRegistry 가 주입되고요. 둘 다 있으면 Spring Boot 가 자동으로 CompositeMeterRegistry 로 묶어서 주입해요.

이 구조를 알면 @Autowired MeterRegistry registry 코드가 사실 "현재 설정된 백엔드 구현체를 받겠다"는 뜻임을 알 수 있어요. Metrics.globalRegistry 도 마찬가지로 실체는 CompositeMeterRegistry 예요.

CompositeMeterRegistry — 여러 곳 동시

CompositeMeterRegistry 는 여러 MeterRegistry 구현체를 묶어서, 하나의 계측 호출이 등록된 모든 registry 에 동시에 전달되도록 해요.

// 직접 Composite 를 만드는 경우 (Spring Bean 으로 등록)
@Bean
public MeterRegistry meterRegistry(
        PrometheusConfig prometheusConfig,
        DatadogConfig datadogConfig) {

    CompositeMeterRegistry composite = new CompositeMeterRegistry();
    composite.add(new PrometheusMeterRegistry(prometheusConfig));
    composite.add(new DatadogMeterRegistry(datadogConfig, Clock.SYSTEM));
    return composite;
}

이렇게 하면 registry.counter("orders.created").increment() 한 줄이 Prometheus 시계열과 Datadog 메트릭 둘 다로 나가요. 계측 코드는 백엔드가 몇 개든 건드릴 필요가 없어요.

Spring Boot 에서는 클래스패스에 여러 레지스트리 의존성이 있으면 자동으로 Composite 를 구성해줘요. 직접 Bean 을 선언하지 않아도 돼요.

# application.yml — Prometheus + Datadog 동시 사용
management:
  prometheus:
    metrics:
      export:
        enabled: true
  datadog:
    metrics:
      export:
        api-key: ${DATADOG_API_KEY}
        enabled: true

마이그레이션 시나리오에서 Composite 가 특히 유용해요. 온프레미스 Prometheus 에서 Datadog SaaS 로 이전할 때, 두 레지스트리를 잠시 동시에 활성화해서 같은 데이터가 양쪽에 흐르는지 검증하는 기간을 둘 수 있어요. 검증이 끝나면 Prometheus 쪽 의존성을 제거하고 Composite 가 자연스럽게 단일 레지스트리가 되는 거예요.

CompositeMeterRegistry 에 레지스트리를 추가하거나 제거할 때는 composite.add(registry) / composite.remove(registry) 를 사용해요. 동적으로 추가·제거할 수 있어서 A/B 테스트나 점진적 전환에도 쓸 수 있어요.

한 가지 주의할 점이 있어요. CompositeMeterRegistry 에 아무 child 가 없는 상태로 운영하면 Meter 가 등록되긴 하지만 아무 데도 전송되지 않아요. 설정 오류로 child 가 빠졌을 때 데이터가 조용히 사라지는 유형이에요. composite.getRegistries() 로 등록된 child 목록을 로그에 남기는 게 안전해요.

pull 모델 — Prometheus 가 긁어간다

pull 방식은 앱이 현재 메트릭 스냅샷을 HTTP 엔드포인트로 노출하고, 수집 서버(Prometheus) 가 주기적으로 긁어가는 구조예요.

앱 (PrometheusMeterRegistry)
  └─ /actuator/prometheus → 현재 스냅샷 노출
       ↑ scrape (15초 간격)
Prometheus 서버
  └─ TSDB 에 시계열 저장
       ↑
Grafana 대시보드

PrometheusMeterRegistry 는 내부적으로 Prometheus Java 클라이언트(io.prometheus.client)의 CollectorRegistry 를 래핑해요. 앱이 새 Meter 를 등록할 때마다 이 Collector 에 지표가 추가되고, /actuator/prometheus GET 요청이 들어오면 그 순간의 값 전체를 Prometheus text format 으로 직렬화해서 반환해요.

// PrometheusMeterRegistry 빈 직접 노출 (Spring 자동 설정이 없는 환경)
@Bean
public PrometheusMeterRegistry prometheusMeterRegistry() {
    return new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
}

// Prometheus scrape 엔드포인트 (Spring 자동 설정 없이 순수 HTTP 서버일 때)
@GetMapping(value = "/metrics", produces = TextFormat.CONTENT_TYPE_004)
public String metrics() {
    return prometheusMeterRegistry.scrape();
}

Spring Boot Actuator 를 쓴다면 /actuator/prometheus 가 자동으로 prometheusMeterRegistry.scrape() 결과를 반환하니까 위 코드는 필요 없어요.

카운터의 누적 특성이 pull 모델에서 중요해요. PrometheusMeterRegistry 의 Counter 는 단조 증가(monotonically increasing) 값이에요. 앱이 재시작하기 전까지 값이 초기화되지 않아요. Prometheus 서버는 이 누적값을 받아서 rate(), increase() 함수로 초당 증가율을 계산해요.

# /actuator/prometheus 응답 예시
# HELP orders_created_total Number of orders created
# TYPE orders_created_total counter
orders_created_total{region="us-east",type="online"} 18423.0

# Prometheus 에서 rate 계산 (PromQL)
rate(orders_created_total[5m])

Prometheus 에서 직접 rate 를 계산하기 때문에 앱 쪽은 "지금까지 총 몇 번 발생했냐"만 노출하면 돼요. 앱 재시작 시 값이 0 으로 리셋되면 Prometheus 가 rate() 계산 시 자동으로 counter reset 을 감지해서 처리해요.

# prometheus.yml — scrape 설정
scrape_configs:
  - job_name: 'spring-app'
    scrape_interval: 15s          # 15초마다 긁어감
    metrics_path: /actuator/prometheus
    static_configs:
      - targets: ['localhost:8080']

scrape 주기는 이 scrape_interval 로 결정돼요. 기본값은 global.scrape_interval (보통 1분) 을 따르고, job 별로 오버라이드할 수 있어요.

push 모델 — 내가 보낸다

push 방식은 방향이 반대예요. 앱이 주기적으로 메트릭을 백엔드 서버로 밀어 보내는 구조예요.

앱 (DatadogMeterRegistry)
  └─ step 주기(30초)마다 Datadog API 로 push →
Datadog 서버
  └─ 시계열 저장 + 대시보드

Datadog, StatsD, CloudWatch, InfluxDB 등 push 방식 백엔드는 모두 StepMeterRegistry 를 상속해요. StepMeterRegistry 는 Micrometer 에서 push 방식의 공통 추상 계층이에요.

// DatadogMeterRegistry 설정 예 (Spring Boot 자동 설정 없이)
DatadogConfig config = new DatadogConfig() {
    @Override
    public String apiKey() {
        return System.getenv("DATADOG_API_KEY");
    }

    @Override
    public Duration step() {
        return Duration.ofSeconds(30); // 30초마다 push
    }

    @Override
    public String get(String key) {
        return null; // 기본값 사용
    }
};

DatadogMeterRegistry registry = new DatadogMeterRegistry(config, Clock.SYSTEM);
# Spring Boot application.yml — Datadog push 설정
management:
  datadog:
    metrics:
      export:
        api-key: ${DATADOG_API_KEY}
        step: 30s              # push 주기
        host-tag: instance     # 호스트 태그 키 이름
        enabled: true

StatsD 는 UDP 로 전송해서 오버헤드가 거의 없는 push 백엔드예요. 레거시 메트릭 시스템이나 게임 서버처럼 고빈도 이벤트를 다루는 환경에서 자주 써요.

management:
  statsd:
    metrics:
      export:
        host: statsd-server.internal
        port: 8125             # UDP 기본 포트
        flavor: etsy           # Etsy/Telegraf/Datadog StatsD 프로토콜 선택
        step: 10s

CloudWatch 는 AWS 환경 네이티브예요. EC2·ECS·Lambda 에서 앱 메트릭을 CloudWatch 로 push 해 두면 알람·대시보드를 AWS 생태계 안에서 통일할 수 있어요.

management:
  cloudwatch:
    metrics:
      export:
        namespace: MyApp           # CloudWatch 네임스페이스
        step: 1m
        batch-size: 20             # 한 번 API 호출에 묶는 메트릭 수

세 백엔드의 공통점은 주기적으로 호출되는 push 스레드가 앱 프로세스 안에서 돌아간다는 점이에요. 이 스레드가 step 주기마다 깨어나 메트릭을 직렬화하고 HTTP(또는 UDP)로 전송해요.

step registry 의 함정

StepMeterRegistry 의 동작 방식이 pull(Prometheus) 과 근본적으로 달라요. 이 차이를 모르면 데이터가 조용히 사라지거나 이중 집계가 생겨요.

step 카운터는 매 step 마다 리셋된다

Prometheus 용 카운터는 누적값이에요. 반면 push 방식의 step 카운터는 step 기간 동안 발생한 증가량을 모았다가 step 끝에 백엔드로 전송하고 리셋해요. 즉, 백엔드에서 받는 값은 "지난 step 동안 얼마나 증가했는가" — rate 개념이에요.

step = 30초 예시:
  0~30초: 주문 15건 발생
  30초 시점: Datadog 에 count=15 전송, 카운터 리셋
  30~60초: 주문 23건 발생
  60초 시점: Datadog 에 count=23 전송, 카운터 리셋

Prometheus 쪽이라면 orders_created_total = 38 (누적) 으로 저장하고 rate 계산은 Prometheus 서버가 해요. Datadog 쪽은 처음부터 "이 step 동안 15건"이라는 delta 값을 받아요.

⚠️ step 주기와 scrape 주기 불일치 함정

Prometheus 를 pull로, Datadog 을 push로 동시 사용하는 경우: Prometheus 의 scrape 주기(예: 15초)와 StepMeterRegistry 의 step 주기(예: 30초)가 어긋나면 같은 카운터가 두 백엔드에서 다르게 보여요.

원칙: scrape 주기 ≤ step 주기 여야 데이터가 안전해요. step 사이에 앱이 죽으면 마지막 step 구간 데이터가 유실돼요. push 방식은 전송 실패 시 재시도 정책도 중요해요.

Gauge 는 step 과 무관하다

StepMeterRegistry 에서 Gauge 는 step 방식으로 동작하지 않아요. poll 시점의 현재 값을 그대로 전송해요. step 이 끝날 때 Gauge 값을 측정해서 보내는 거예요. 따라서 Gauge 는 push/pull 방식에 무관하게 "지금 이 순간의 값"을 보내는 데 적합해요.

Timer 와 DistributionSummary 는 step 동안 모인 데이터를 집계(count/sum/max 등)해서 보내요. Timer 의 경우 step 끝에 "이 step 동안 측정된 p50, p99, mean" 을 전송하게 되는데, step 사이에 앱이 죽으면 그 step 의 분포 데이터가 통째로 사라져요.

PushGateway — 배치·단명 작업

배치 잡이나 크론 잡은 메트릭 관점에서 특별한 문제가 있어요. Prometheus 가 scrape 하러 갔을 때 그 프로세스가 이미 종료돼 있으면 아무 데이터도 가져올 수 없어요.

크론 잡 (매 1시간):
  - 실행 → 데이터 처리 → 종료 (실행 시간: 5분)
Prometheus scrape (15초 간격):
  - 대부분의 scrape 시점에 잡이 없음 → 404 또는 connection refused
  - 잡이 실행 중인 5분 안에 scrape 가 들어오더라도 값이 불완전

이 문제를 해결하는 게 Prometheus PushGateway 예요. 잡이 실행되는 동안 메트릭을 PushGateway 에 push 해두고, Prometheus 가 나중에 PushGateway 를 scrape 하는 방식이에요.

배치 잡                     PushGateway              Prometheus
  │                            │                         │
  │ 처리 완료 직전             │                         │
  │─── push metrics ──────────>│                         │
  │                            │                         │
  │ 종료                       │                         │
  │                            │<── scrape ──────────────│
  │                            │── metrics ─────────────>│

Micrometer 는 PrometheusPushGatewayManager 를 통해 이 시나리오를 지원해요.

// Spring Boot 설정 (application.yml)
management:
  prometheus:
    metrics:
      export:
        pushgateway:
          enabled: true
          base-url: http://pushgateway.internal:9091
          job: batch-order-processor    # PushGateway 의 job 레이블
          push-rate: 10s                # 살아 있는 동안 push 주기
          shutdown-operation: push      # 종료 시 마지막 push 후 종료
// Java 코드에서 직접 사용하는 경우 (Spring 없이)
PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
PushGateway pushGateway = new PushGateway("pushgateway.internal:9091");

// ... 배치 처리 ...

// 잡 완료 시 push
pushGateway.pushAdd(registry.getPrometheusRegistry(), "batch-order-processor");

shutdown-operation: push 설정은 Spring Boot 앱이 정상 종료(graceful shutdown) 될 때 마지막으로 한 번 더 push 하고 종료하도록 해요. 이렇게 하면 step 사이에 잡이 끝나도 마지막 구간 데이터가 PushGateway 에 남아요.

PushGateway 에서 꼭 알아야 할 단점이 있어요. 오래 사는 서비스(long-running service)에 PushGateway 를 쓰면 안티패턴이에요. 서비스가 죽어도 PushGateway 에 마지막 push 값이 그대로 남아서 stale 메트릭이 계속 노출돼요. Prometheus 가 scrape 할 때 "이 서비스가 살아있다"고 오판할 수 있어요. PushGateway 는 배치·크론 잡처럼 실행 후 끝나는 작업 전용이에요.

# prometheus.yml — PushGateway scrape 설정
scrape_configs:
  - job_name: 'pushgateway'
    honor_labels: true          # PushGateway 가 붙인 job/instance 레이블 유지
    static_configs:
      - targets: ['pushgateway.internal:9091']

honor_labels: true 가 중요해요. 이 설정이 없으면 PushGateway 가 붙인 job, instance 레이블이 Prometheus scrape 설정의 레이블로 덮어써져서 여러 배치 잡의 메트릭이 섞여요.

registry lifecycle · 설정

close() 와 shutdown hook

MeterRegistryAutoCloseable 을 구현해요. push 방식 레지스트리는 내부에 push 스레드가 돌아가기 때문에, 앱 종료 시 close() 를 호출해야 스레드가 정상 종료되고 마지막 메트릭이 전송될 수 있어요.

// Spring Bean 으로 등록된 경우 Spring 이 자동으로 close() 를 호출해요
// 직접 만든 경우 shutdown hook 등록
DatadogMeterRegistry registry = new DatadogMeterRegistry(config, Clock.SYSTEM);
Runtime.getRuntime().addShutdownHook(new Thread(registry::close));

Spring Boot 에서는 MeterRegistry 빈의 close() 가 컨텍스트 종료 시 자동 호출돼요. PrometheusPushGatewayManagershutdown-operation 설정도 이 시점과 맞물려요.

SimpleMeterRegistry — 테스트·로컬 확인용

SimpleMeterRegistry 는 인메모리로만 동작하고 아무 백엔드로도 전송하지 않아요. 백엔드 설정 없이 Meter 동작을 검증할 때 쓰기 좋아요.

// 단위 테스트에서 메트릭 검증
@Test
void orderCreationIncrementsCounter() {
    SimpleMeterRegistry registry = new SimpleMeterRegistry();
    OrderService service = new OrderService(registry);

    service.createOrder(new CreateOrderRequest("us-east", "online"));
    service.createOrder(new CreateOrderRequest("us-east", "online"));

    Counter counter = registry.find("orders.created")
        .tags("region", "us-east", "type", "online")
        .counter();

    assertThat(counter).isNotNull();
    assertThat(counter.count()).isEqualTo(2.0);
}

registry.find("metric.name").counter() 로 등록된 Meter 를 찾아서 값을 검증할 수 있어요. push/pull 백엔드가 연결되지 않은 상태라도 Meter 의 등록·증가·조회가 모두 정상 동작해요.

다중 환경 레지스트리 설정

개발·스테이징·운영 환경에서 다른 백엔드를 쓰는 경우가 많아요. Spring Profile 로 분기하는 게 가장 깔끔해요.

# application.yml (공통)
management:
  prometheus:
    metrics:
      export:
        enabled: false    # 기본은 비활성

---
spring:
  config:
    activate:
      on-profile: local
management:
  prometheus:
    metrics:
      export:
        enabled: true     # 로컬은 Prometheus

---
spring:
  config:
    activate:
      on-profile: production
management:
  prometheus:
    metrics:
      export:
        enabled: true     # 운영은 Prometheus + Datadog 동시
  datadog:
    metrics:
      export:
        enabled: true
        api-key: ${DATADOG_API_KEY}
        step: 30s

환경별 설정 분기 시 주의할 점은 enabled: false 기본값이에요. Spring Boot 2.x 에서는 클래스패스에 레지스트리 의존성이 있으면 자동 활성화가 됐는데, 명시적으로 끄지 않으면 뜻하지 않게 Datadog API 키 없이 push 시도가 일어날 수 있어요. 의존성을 추가했으면 반드시 enabled 를 명시하는 습관이 안전해요.

함정 정리

사고 1: CompositeMeterRegistry 에 child 가 없는 줄 모름

클래스패스에 레지스트리 의존성을 추가했는데 enabled: false 로 꺼두거나 API 키 설정을 빠뜨린 경우, Spring Boot 가 child 없는 CompositeMeterRegistry 를 주입해요. 메트릭은 Meter 에 쌓이지만 아무 백엔드로도 나가지 않아요. 로그에도 에러가 없어서 발견하기 어려워요.

해결 — 앱 시작 시 composite.getRegistries() 를 로그로 남겨 child 목록을 확인해요. 또는 Actuator 의 /actuator/metrics 로 메트릭이 잡히는지 먼저 확인하고, /actuator/prometheus 가 데이터를 반환하는지도 체크해요.

사고 2: step 주기와 scrape 주기 불일치로 데이터 유실

Prometheus scrape 주기 15초, Datadog step 30초로 설정했을 때, 두 백엔드에서 같은 Counter 가 다르게 보여요. Prometheus 는 15초 resolution 의 누적 카운터를 보고, Datadog 은 30초 단위의 delta 값을 받아요. step 사이에 앱이 재시작되면 Datadog 쪽은 해당 step 데이터가 통째로 유실돼요.

해결 — Prometheus scrape 주기를 step 주기 이하로 맞추거나, Composite 환경에서 두 백엔드의 step/scrape 간격을 동일하게 설정해요. push 백엔드에는 retry 설정(max-attempts, retry-timeout)도 함께 구성해요.

사고 3: push 앱이 step 사이에 죽어서 마지막 구간 유실

앱이 비정상 종료되면 그 step 에 쌓인 delta 값이 아무 데도 전송되지 않아요. 특히 배치 잡이 처리 완료 직전에 죽으면 "처리는 됐는데 메트릭이 없다"는 상황이 생겨요.

해결 — Spring Boot 의 graceful shutdown 을 활성화하고, shutdown-operation: push 를 설정해서 정상 종료 시 마지막 push 가 이뤄지도록 해요. 비정상 종료는 막을 수 없지만, 운영 알람으로 탐지해서 누락 구간을 파악하는 게 현실적이에요.

사고 4: PushGateway 를 long-running 서비스에 사용

Kubernetes 에서 파드가 재시작됐는데 PushGateway 에 이전 파드의 메트릭이 남아서 stale 데이터가 계속 노출돼요. Prometheus 가 "서비스가 살아있다"고 오판해서 알람이 발동되지 않아요.

해결 — PushGateway 는 배치·크론 잡·단명 작업 전용으로만 써요. long-running 서비스는 Prometheus pull 방식이 정상이에요. Kubernetes 환경에서는 파드가 종료될 때 PushGateway 의 grouping key 로 등록된 메트릭을 삭제(delete 메서드)하는 훅을 추가하면 stale 문제를 줄일 수 있어요.

사고 5: prometheus.yml 에서 honor_labels 누락

PushGateway scrape 설정에서 honor_labels: true 를 빠뜨리면, 여러 배치 잡이 push 한 메트릭의 job, instance 레이블이 모두 pushgateway 로 덮어써져요. 어떤 잡에서 온 메트릭인지 구분이 불가능해져요.

해결 — PushGateway 를 scrape target 으로 등록할 때 항상 honor_labels: true 를 명시해요.

사고 6: SimpleMeterRegistry 를 운영에 남겨둠

테스트 코드에서 쓰던 SimpleMeterRegistry 를 Spring Bean 으로 잘못 등록해두거나, 개발 환경 설정이 운영에 그대로 배포되면 운영에서도 아무 데도 메트릭이 전송되지 않아요.

해결 — SimpleMeterRegistry 는 단위 테스트 내에서만 직접 생성해서 사용해요. Spring Bean 으로 등록할 때는 @Profile("test") 또는 @ConditionalOnMissingBean(MeterRegistry.class) 처럼 운영에서 활성화되지 않도록 방어해요.

사고 7: Datadog step 을 너무 짧게 설정해서 API 비용 급증

Prometheus 처럼 15초 resolution 을 Datadog 에서도 유지하려고 step 을 10초로 설정해요.

Datadog API 호출은 건수 기반으로 과금 구조가 달라지는 경우가 있어요. step 이 짧을수록 API 호출이 늘고 비용이 올라가요.

해결 — 비용 민감 환경에서는 Datadog step 을 60초 이상으로 유지하고, 고해상도가 필요한 메트릭만 별도 레지스트리로 분리해요.

사고 8: 동일한 Meter 가 두 레지스트리에 이중으로 집계된다고 오해

CompositeMeterRegistry 를 쓸 때 "메트릭이 두 배로 집계되는 거 아닌가?"라는 질문이 나와요.

CompositeMeterRegistry 는 하나의 계측 호출을 여러 레지스트리에 전달하는 것이지, 값을 중복 집계하는 게 아니에요. 각 백엔드가 독립적으로 받는 것이고, 앱 내부에서 중복이 일어나지 않아요.

사고 9: push 레지스트리에서 close() 없이 앱 강제 종료

앱을 SIGKILL 로 강제 종료하면 shutdown hook 이 실행되지 않아요. push 스레드가 미처 마지막 데이터를 전송하지 못하고 죽어요.

해결 — Kubernetes 환경이라면 terminationGracePeriodSeconds 를 step 주기보다 크게 설정해서 graceful shutdown 이 완료될 시간을 줘요. Spring Boot 의 server.shutdown=graceful 도 함께 활성화해요.

사고 10: CloudWatch batch-size 기본값 때문에 API 제한 초과

CloudWatch PutMetricData API 는 한 번 호출에 최대 20개의 MetricDatum 을 받아요. batch-size 기본값이 이보다 큰 경우가 있어서 API 오류가 발생해요.

해결 — management.cloudwatch.metrics.export.batch-size=20 으로 명시해요. 메트릭 수가 많으면 Micrometer 가 자동으로 배치를 나눠서 전송하지만, batch-size 가 API 제한보다 크면 첫 배치부터 오류가 나요.

운영 권장 패턴

Pattern 1: 환경별 레지스트리 명시 분기

# 공통 기본값 — 모든 레지스트리 비활성
management:
  prometheus:
    metrics:
      export:
        enabled: false
  datadog:
    metrics:
      export:
        enabled: false

---
spring:
  config:
    activate:
      on-profile: local, dev
management:
  prometheus:
    metrics:
      export:
        enabled: true

---
spring:
  config:
    activate:
      on-profile: production
management:
  prometheus:
    metrics:
      export:
        enabled: true
  datadog:
    metrics:
      export:
        enabled: true
        api-key: ${DATADOG_API_KEY}
        step: 30s

환경별 명시적 분기를 쓰면 운영에서 뜻밖의 백엔드가 활성화되는 사고를 막을 수 있어요.

Pattern 2: 마이그레이션 중 Composite 검증 기간

Prometheus 에서 Datadog 으로 이전할 때 두 레지스트리를 동시 활성화하는 기간을 두어요. 같은 메트릭이 양쪽 대시보드에 동일하게 보이는지 1~2주 검증한 뒤 Prometheus 를 끄는 게 안전해요.

# 마이그레이션 중간 단계
management:
  prometheus:
    metrics:
      export:
        enabled: true   # 기존 — 검증 기간 유지
  datadog:
    metrics:
      export:
        enabled: true   # 신규 — 동시 활성화
        api-key: ${DATADOG_API_KEY}

Pattern 3: 배치 잡 PushGateway 패턴

@Component
@ConditionalOnProperty("batch.metrics.pushgateway.enabled")
public class BatchMetricsPusher implements DisposableBean {

    private final PrometheusMeterRegistry registry;
    private final PushGateway pushGateway;
    private final String jobName;

    public BatchMetricsPusher(
            PrometheusMeterRegistry registry,
            @Value("${batch.metrics.pushgateway.url}") String gatewayUrl,
            @Value("${spring.application.name}") String jobName) {
        this.registry = registry;
        this.pushGateway = new PushGateway(gatewayUrl);
        this.jobName = jobName;
    }

    public void push() throws IOException {
        pushGateway.pushAdd(registry.getPrometheusRegistry(), jobName);
    }

    @Override
    public void destroy() throws Exception {
        push(); // 종료 시 마지막 push
    }
}

DisposableBean.destroy() 에서 마지막 push 를 보장하면 배치 잡의 마지막 구간 메트릭이 안전하게 기록돼요.

Pattern 4: SimpleMeterRegistry 테스트 패턴

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    private SimpleMeterRegistry meterRegistry;
    private OrderService orderService;

    @BeforeEach
    void setUp() {
        meterRegistry = new SimpleMeterRegistry();
        orderService = new OrderService(meterRegistry);
    }

    @Test
    void 주문_성공_시_카운터가_증가한다() {
        orderService.createOrder(validRequest());

        double count = meterRegistry.find("orders.created")
            .tags("status", "success")
            .counter()
            .count();

        assertThat(count).isEqualTo(1.0);
    }

    @Test
    void 주문_실패_시_에러_카운터가_증가한다() {
        assertThatThrownBy(() -> orderService.createOrder(invalidRequest()))
            .isInstanceOf(OrderException.class);

        double errorCount = meterRegistry.find("orders.failed")
            .tags("reason", "validation")
            .counter()
            .count();

        assertThat(errorCount).isEqualTo(1.0);
    }
}

SimpleMeterRegistry 를 테스트마다 새로 생성하면 테스트 간 메트릭이 오염되지 않아요. meterRegistry.find(...) 체이닝으로 특정 태그 조합의 Meter 를 정확히 찾을 수 있어요.

Pattern 5: registry lifecycle 로깅

@Component
@Slf4j
public class RegistryDiagnostics implements ApplicationListener<ApplicationReadyEvent> {

    private final MeterRegistry meterRegistry;

    public RegistryDiagnostics(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    @Override
    public void onApplicationEvent(ApplicationReadyEvent event) {
        if (meterRegistry instanceof CompositeMeterRegistry composite) {
            log.info("Active MeterRegistry children: {}",
                composite.getRegistries().stream()
                    .map(r -> r.getClass().getSimpleName())
                    .toList());
        } else {
            log.info("Active MeterRegistry: {}",
                meterRegistry.getClass().getSimpleName());
        }
    }
}

앱 시작 완료 시점에 활성 레지스트리 목록을 로그로 남겨두면, "메트릭이 어느 백엔드로 가고 있는지" 운영 중에 빠르게 확인할 수 있어요.

Pattern 6: push 레지스트리 graceful shutdown

# application.yml
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s   # graceful shutdown 최대 대기

server:
  shutdown: graceful

management:
  datadog:
    metrics:
      export:
        step: 15s   # shutdown timeout 보다 짧게

timeout-per-shutdown-phase 를 step 보다 크게 설정하면 종료 시점에 push 스레드가 마지막 데이터를 전송할 시간을 확보할 수 있어요.

시험 직전 한 번 더 — Registry/백엔드 압축 노트

MeterRegistry 구현체 계층

  • MeterRegistry (추상) → 백엔드별 구현체가 상속
  • PrometheusMeterRegistry — pull, /actuator/prometheus 노출
  • DatadogMeterRegistry · StatsdMeterRegistry · CloudWatchMeterRegistry — push, StepMeterRegistry 상속
  • SimpleMeterRegistry — 인메모리, 백엔드 없음, 테스트·로컬 전용
  • CompositeMeterRegistry — 여러 registry 를 묶어 동시 전달, Spring Boot 글로벌 빈도 사실상 Composite

CompositeMeterRegistry

  • composite.add(registry) / composite.remove(registry) 로 동적 추가·제거 가능
  • Spring Boot 에서 여러 레지스트리 의존성이 클래스패스에 있으면 자동으로 Composite 구성
  • Metrics.globalRegistry 도 Composite
  • child 없는 Composite 에서는 메트릭이 조용히 사라짐 → 시작 시 child 목록 로그 필수

pull 모델 (Prometheus)

  • PrometheusMeterRegistry/actuator/prometheus 에 스냅샷 노출
  • Prometheus 서버가 scrape_interval 마다 긁어감
  • Counter 는 단조 누적(monotonically increasing), rate 는 Prometheus 서버가 rate() 로 계산
  • 앱 재시작 시 Counter 리셋 → Prometheus 가 자동 감지

push 모델 (StepMeterRegistry)

  • StepMeterRegistry 를 상속: Datadog · StatsD · CloudWatch · InfluxDB · OTLP 등
  • step 기간 동안 delta 를 누적 → step 끝에 백엔드로 전송 → 리셋
  • step 카운터는 "지난 step 동안 얼마나 증가했는가" (rate 개념)
  • Gauge 는 step 과 무관 — poll 시점의 현재 값
  • close() 가 호출돼야 push 스레드 정상 종료 + 마지막 전송

step 주기 불일치 함정

  • Prometheus scrape 주기 ≤ step 주기여야 안전
  • step 사이에 앱 비정상 종료 → 해당 step 데이터 유실
  • graceful shutdown + shutdown-operation: push 로 정상 종료 시 마지막 push 보장

PushGateway

  • 배치·크론 잡·단명 작업 전용 — Prometheus 가 scrape 할 때 프로세스가 없는 문제 해결
  • 잡이 끝나기 전 PushGateway 에 push → Prometheus 가 PushGateway scrape
  • honor_labels: true 필수 — 없으면 job/instance 레이블이 덮어써짐
  • long-running 서비스에 쓰면 stale 메트릭 안티패턴

SimpleMeterRegistry

  • 인메모리, 백엔드 없음
  • 단위 테스트에서 meterRegistry.find("metric.name").counter().count() 로 값 검증
  • 테스트마다 새 인스턴스 생성 → 오염 방지

흔한 사고

  • Composite 에 child 없음 → 메트릭 조용히 사라짐
  • step ↔ scrape 주기 불일치 → 데이터 유실 / 이중 집계
  • PushGateway 를 long-running 서비스에 사용 → stale 메트릭
  • honor_labels: true 누락 → 잡 구분 불가
  • enabled 미명시 → 뜻밖의 백엔드 활성화
  • close() 없는 강제 종료 → 마지막 step 데이터 유실
  • CloudWatch batch-size 초과 → API 오류

공식 문서: Micrometer 공식 docs 에서 각 레지스트리별 설정 레퍼런스와 StepMeterRegistry 동작 원리를 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

답글 남기기

error: Content is protected !!