Micrometer 6편. CompositeMeterRegistry로 Prometheus와 Datadog에 동시 전송하는 구조, pull 모델(PrometheusMeterRegistry·scrape)과 push 모델(StepMeterRegistry·DatadogMeterRegistry)의 동작 원리, step 주기 불일치로 생기는 데이터 유실 함정, 배치·단명 잡을 위한 PushGateway 패턴, 테스트용 SimpleMeterRegistry 활용까지.
이 글은 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 값을 받아요.
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
MeterRegistry 는 AutoCloseable 을 구현해요. 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() 가 컨텍스트 종료 시 자동 호출돼요. PrometheusPushGatewayManager 의 shutdown-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 동작 원리를 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 1편 — 메트릭의 벤더 중립 facade·큰 그림
- 2편 — Meter 타입 깊이 (Counter·Gauge·Timer·DistributionSummary)
- 3편 — Timer·percentile·histogram·SLO 깊이
- 4편 — 태그·차원·카디널리티 깊이
- 5편 — Spring Boot Actuator 통합 깊이
다음 글: