Micrometer 입문 8편 — 운영 함정 + 사고 케이스 깊이

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

Micrometer 입문 8편 — 운영 1년이 쌓은 10가지 사고 케이스 깊이. 카디널리티 폭발로 Prometheus OOM, Gauge 강한참조 메모리 누수, percentile 합산 오판으로 SLO 붕괴, step registry 카운터 유실, @Timed + 수동 Timer double-counting, 메트릭 이름·태그셋 충돌, /actuator/prometheus 인증 없이 노출, 테스트 SimpleMeterRegistry 혼선, reactor/async context 태그 손실, 고빈도 메트릭 성능 오버헤드까지. 예방·회복 운영 패턴 포함.

📚 Micrometer 입문에서 운영까지 · 8편 — 운영 함정 + 사고 케이스 깊이

이 글은 Micrometer 입문에서 운영까지 시리즈 8편이에요. 1~7편이 기능의 이해였다면, 8편은 운영 1년이 쌓아 올린 무거운 사고의 자리예요. 설계 단계에서는 보이지 않다가 트래픽이 붙고 팀이 커지면서 비로소 드러나는 종류의 문제들 — 10가지를 하나씩 꺼내서 뜯어봐요.

이번 글의 범위

운영자에게 처음엔 안 보이고, 서서히 발견되며, 한 번 발견하면 잊혀지지 않는 사고들의 모음이에요.

사고 키워드
사고 1 카디널리티 폭발 → Prometheus OOM
사고 2 Gauge 강한참조 → 힙 누수
사고 3 percentile 평균 → SLO 오판
사고 4 step registry → 카운터 유실
사고 5 @Timed + 수동 Timer 중복 → double-counting
사고 6 메트릭 이름·태그셋 충돌 → IllegalArgumentException
사고 7 /actuator/prometheus 인증 없이 노출
사고 8 테스트 SimpleMeterRegistry 혼선 → 조용한 누락
사고 9 reactor/async context 태그 손실
사고 10 hot path 고빈도 Timer → CPU·할당 오버헤드

사고 1: 카디널리티 폭발로 Prometheus OOM

동적 태그 값은 메트릭의 시한폭탄이에요. 처음엔 잘 동작하는데, 어느 날 아침 Prometheus Pod가 OOMKilled 상태로 떨어져 있는 걸 발견하고 나서야 깨닫게 되죠.

⚠️ 가장 흔한 운영 사고

카디널리티 폭발은 Prometheus OOM의 1순위 원인이에요. userId·rawPath·requestId 같은 동적 값을 태그로 박는 순간, 시계열 수가 사용자 수·요청 수에 비례해서 폭증해요.

태그 값은 반드시 유한하고 예측 가능한 집합이어야 해요. region(us/kr/eu), status(200/400/500), method(GET/POST) 처럼요.

증상 — Prometheus Pod가 OOMKilled로 재시작을 반복해요. prometheus_tsdb_head_series 메트릭이 수백만 단위로 치솟아 있고, topk(10, count by (__name__)({__name__=~".+"})) 쿼리를 날리면 특정 메트릭 하나가 전체 시계열의 절반을 차지하고 있어요.

원인 — 흔한 패턴이 두 가지예요. 첫 번째는 userIdorderId 같은 식별자를 태그 값으로 직접 넣는 경우. 사용자가 100만 명이면 태그 조합이 100만 가지 시계열이 돼요. 두 번째는 raw URL 경로를 태그로 쓰는 것 — /products/12345·/products/12346처럼 숫자 부분이 동적이면 상품 수만큼 시계열이 생겨요.

해결 — 세 갈래로 접근해요.

// 1. MeterFilter로 태그 값 수 상한 설정
@Bean
public MeterRegistryCustomizer<MeterRegistry> cardinalityLimit() {
    return registry -> registry.config()
        .meterFilter(MeterFilter.maximumAllowableTags(
            "http.server.requests", // 대상 메트릭
            "uri",                  // 태그 키
            100,                    // 최대 허용 고유값 수
            MeterFilter.deny()      // 초과 시 등록 거부
        ));
}

// 2. URI를 템플릿으로 정규화 — 숫자 ID를 :id로 치환
// Spring MVC에서는 uri 태그가 자동으로 /products/{id}로 정규화됨
// 직접 계측할 때는 동적 부분을 제거하고 경로 패턴만 남김
registry.counter("api.requests",
    "uri", "/products/{id}",  // raw path가 아닌 route 패턴
    "method", "GET",
    "status", "200"
);

// 3. 높은 카디널리티 메트릭 식별 — 주기적으로 확인
// PromQL: topk(10, count by (__name__) ({__name__=~".+"}))

Micrometer 공식 문서의 MeterFilter에서 maximumAllowableTags 외에도 deny·ignore·rename 등 다양한 필터 패턴을 확인할 수 있어요.

사고 2: Gauge 강한참조 메모리 누수

"Gauge를 새로 등록할 때마다 메모리가 줄지 않는" 증상은 GC 로그를 뜯어보기 전까지 원인을 찾기 어려운 편이에요.

증상 — JVM 힙 사용량이 시간이 지날수록 서서히 늘어요. Full GC가 돌아도 회복이 안 돼요. 힙 덤프를 분석하면 특정 컬렉션 객체나 캐시 객체가 GC 루트에서 참조 체인으로 연결돼 회수되지 않고 있어요.

원인 — Micrometer의 Gauge는 기본적으로 WeakReference로 관찰 대상 객체를 잡아요. Gauge.builder("cache.size", cache, Cache::size).register(registry) 처럼 쓰면 cache 객체가 더 이상 다른 곳에서 강하게 참조되지 않을 때 GC가 수거하고 Gauge도 자동으로 비활성화되죠.

문제는 두 가지 상황에서 발생해요.

첫째, 람다가 외부 객체를 강하게 캡처하는 경우예요.

// 위험한 패턴 — 람다가 cache를 강하게 캡처
Gauge.builder("cache.size", () -> largeCache.size())
    .register(registry);
// largeCache는 람다 내부에서 강하게 참조되기 때문에
// 이 Gauge가 registry에 살아있는 한 largeCache도 GC 불가

둘째, 매 요청마다 Gauge를 새로 등록하는 경우예요. Micrometer는 동일한 이름+태그 조합의 Meter가 이미 있으면 첫 번째 등록 결과를 재사용하고 새 등록 시도는 무시해요. 그런데 함수형 Gauge(Gauge.builder("x", () -> ...))를 매 호출마다 새로 등록하면, Micrometer는 첫 등록만 유효로 보고 이후 등록을 무시하기 때문에 최신 람다가 아닌 최초 람다의 값이 계속 박혀요.

해결 — 관찰 대상 객체를 Gauge 빌더에 직접 전달하는 표준 패턴을 써요.

// 안전한 패턴 — registry가 WeakReference로 객체를 잡음
Gauge.builder("cache.size", myCache, cache -> cache.size())
    .description("현재 캐시 엔트리 수")
    .register(registry);

// 동일 id의 Gauge는 한 번만 등록
// — 생성자 또는 @PostConstruct에서 한 번, 이후 재등록 금지

Gauge는 서비스 필드나 @PostConstruct에서 딱 한 번 등록하고 재사용하는 게 기본이에요. 루프나 매 요청 핸들러 안에서 Gauge.builder(...).register(registry)를 호출하는 코드는 검토 대상이에요.

사고 3: percentile을 평균내서 SLO 오판

"p99 응답 시간이 200ms 이내"라는 SLO가 있는데, 실제 사용자 일부는 1초 넘게 기다리고 있는 상황. 대시보드는 SLO 초록불이에요. 이게 가능한 상황이 있어요.

증상 — SLO 대시보드는 양호한데 사용자 불만 티켓이 들어와요. Grafana 패널을 뜯어보면 인스턴스별 p99를 단순 avg()로 합쳐서 표현하고 있어요.

원인publishPercentiles(0.99) 옵션으로 생성되는 클라이언트 사이드 percentile 값은 합산(aggregation)이 수학적으로 불가능해요. p99는 분포의 위치 통계량이라, 두 집단의 p99를 평균 내면 실제 전체 p99보다 낮게 나올 수 있거든요.

예를 들어 인스턴스 A의 p99가 150ms, 인스턴스 B의 p99가 900ms라면 평균은 525ms인데, 실제 두 인스턴스를 합친 전체 데이터의 p99는 그 사이 어딘가예요 — 항상 단순 평균이 아니에요. 트래픽 분포가 인스턴스마다 다르면 오차가 더 커져요.

해결 — 정확한 percentile 집계가 필요하면 publishPercentileHistogram() 옵션을 켜야 해요.

// 잘못된 방법 — client-side percentile (합산 불가)
Timer.builder("api.latency")
    .publishPercentiles(0.5, 0.95, 0.99)  // 이 값들은 avg()하면 안 됨
    .register(registry);

// 올바른 방법 — server-side histogram + histogram_quantile
Timer.builder("api.latency")
    .publishPercentileHistogram()  // bucket 데이터를 Prometheus로 전송
    .sla(Duration.ofMillis(100), Duration.ofMillis(300), Duration.ofMillis(1000))
    .register(registry);
# Prometheus에서 정확한 p99 계산
histogram_quantile(
    0.99,
    sum(rate(api_latency_seconds_bucket[5m])) by (le)
)

publishPercentileHistogram() 방식은 bucket 데이터를 백엔드로 보내고 백엔드에서 histogram_quantile로 집계하기 때문에 인스턴스 수와 관계없이 정확한 결과를 내요. 3편(percentile·histogram 깊이)에서 두 방식의 차이를 더 자세히 다뤘어요.

사고 4: step registry로 카운터 유실

배포 직후나 점검 시간 이후에 메트릭 그래프에 구멍이 생기는 현상을 경험한 적 있다면, step registry의 동작 방식을 살펴볼 필요가 있어요.

증상 — 카운터 그래프에 주기적으로 데이터 포인트가 빠져 있어요. 특히 앱 재배포 시간대에 구멍이 생기거나, Prometheus scrape 간격을 늘린 이후에 데이터가 드문드문 나타나요.

원인StepMeterRegistry(Datadog·CloudWatch·InfluxDB 등 push 방식 registry의 기반)는 step 주기마다 카운터 값을 집계하고 리셋해요. 이 step이 Prometheus scrape interval보다 짧으면 scrape가 두 번 연속 같은 step 값을 읽거나, 반대로 step 사이에 앱이 재시작되면 그 step의 데이터가 통째로 사라져요.

예를 들어 step이 30초인데 Prometheus scrape interval이 60초면, scrape 한 번에 두 step 구간의 합산이 들어오게 되고 해석이 어그러져요. 반대로 앱이 step 전환 직전에 죽으면 그 step의 모든 카운터 기록이 날아가요.

step  = 30s
scrape = 60s

t=0   카운터 누적 시작
t=30  step reset, 30s 분량 push → scrape 아직 안 됨 (유실)
t=60  scrape — 최근 30s 분량만 읽힘, 이전 30s 구간 데이터 없음

해결 — push 방식 registry를 쓸 때는 scrape interval < step 관계가 되도록 설정해요. 또는 Prometheus pull 방식으로 전환하면 scrape 시점의 누적 카운터를 읽기 때문에 step 유실 문제 자체가 없어요.

# Datadog push 예시 — step을 scrape보다 충분히 크게
management:
  datadog:
    metrics:
      export:
        step: 60s   # scrape interval(15s)보다 크게
        # step이 너무 짧으면 그 안에 앱이 죽으면 구간 유실

배치 Job처럼 짧게 실행되고 죽는 앱은 PushGateway 패턴을 고려하는 게 맞아요. 그래야 Job 실행 데이터가 Prometheus에 남아 있어요.

사고 5: double-counting

메트릭 숫자가 이상하게 두 배로 보이면 가장 먼저 계측 중복을 의심해야 해요.

증상 — 같은 요청인데 카운터가 두 배씩 올라가요. rate(http.server.requests.seconds_count[5m])를 보면 실제 RPS의 두 배가 찍혀요. Grafana 대시보드가 이상하게 높은 수치를 보여주고, 팀이 "메트릭이 이상하다"는 신고를 올려요.

원인 — 두 가지 경로로 발생해요.

첫째, @Timed 어노테이션과 수동 Timer.record()를 같은 메서드에 동시에 적용하는 경우예요.

// 이중 계측 — 같은 시간이 두 번 기록됨
@Timed(value = "orders.create.time")  // AOP로 Timer 자동 적용
public Order createOrder(CreateOrderRequest req) {
    return processingTimer.record(() -> {  // 수동 Timer도 추가
        return doCreate(req);
    });
}

둘째, 인터셉터·필터 레벨에서 이미 계측하는데 컨트롤러에서 또 계측하는 경우예요. Spring MVC의 WebMvcTagsContributor나 Actuator의 MetricsHttpServerObservationFilter가 HTTP 요청을 자동으로 계측하는데, 거기에 컨트롤러에서 또 박으면 이중이 돼요.

해결 — 한 계측 지점에는 한 경로만 쓰는 게 원칙이에요. @Timed를 썼으면 수동 Timer는 빼요. Spring Boot Actuator의 자동 HTTP 계측(http.server.requests)을 쓰고 있다면 컨트롤러에서 따로 동일한 차원을 재계측하지 않아요.

// 올바른 패턴 1 — @Timed 단독
@Timed(value = "orders.create.time")
public Order createOrder(CreateOrderRequest req) {
    return doCreate(req);  // 수동 Timer 없음
}

// 올바른 패턴 2 — 수동 Timer 단독 (@Timed 없음)
public Order createOrder(CreateOrderRequest req) {
    return processingTimer.record(() -> doCreate(req));
}

사고 6: 메트릭 이름·태그셋 충돌

IllegalArgumentException: Meter with the same name but different tags exists라는 예외를 처음 마주하면 당황스러워요. 메트릭 이름이 겹쳤다는 건 알겠는데, 어디서 겹쳤는지 찾기 쉽지 않거든요.

증상 — 앱 시작 시 또는 특정 코드 경로 진입 시 예외가 터져요. Micrometer가 같은 이름의 Meter를 등록하려는데 기존 Meter와 태그 키 셋이 달라서 거부하는 거예요.

원인 — Micrometer는 같은 name의 Meter를 등록할 때 이미 등록된 Meter의 태그 키 집합과 일치하는지 검사해요. 이름이 같은데 어떤 코드 경로는 {region, status} 태그를 쓰고, 다른 경로는 {region, status, version} 태그를 쓰면 충돌이에요.

// 사고 패턴 — 같은 이름에 다른 태그 셋
// 경로 A에서:
registry.counter("api.calls",
    "region", "us", "status", "success")
    .increment();

// 경로 B에서:
registry.counter("api.calls",
    "region", "us", "status", "success", "version", "v2")
    .increment();  // IllegalArgumentException!

해결 — 메트릭 이름마다 태그 키 집합을 고정하고, 값만 달라지도록 설계해요. 특정 호출 경로에서만 의미 있는 태그는 있어도 항상 고정 키로 포함하고 해당 없는 경우엔 "unknown" 같은 센티넬 값으로 채워요.

// 안전한 패턴 — 태그 키 셋은 항상 고정
registry.counter("api.calls",
    "region", region,
    "status", status,
    "version", version != null ? version : "unknown")  // 키는 항상 포함
    .increment();

태그 설계는 코드 작성 시점이 아니라 메트릭 설계 시점에 확정해야 해요. 팀 레벨에서 공통 MeterBinder 라이브러리를 두면 이런 불일치를 사전에 막을 수 있어요.

사고 7: /actuator/prometheus 인증 없이 노출

모니터링 엔드포인트 하나가 공개망에 노출되는 건 생각보다 자주 일어나는 사고예요. ingress 규칙에 /* 와일드카드가 들어가거나, 방화벽 설정 실수 한 번으로 일어나거든요.

⚠️ 보안 사고

배포 후 반드시 확인: curl -s https://your-service.com/actuator/prometheus | head -3 를 실행해서 응답이 오면 이미 노출된 상태예요.

/actuator/prometheus 응답에는 JVM 힙 사용량, DB 커넥션 수, HTTP 요청 패턴, 커스텀 비즈니스 메트릭이 포함돼요. 공격자 입장에서는 시스템 내부 상태를 무료로 스캔하는 창이에요.

증상 — 보안 스캔 리포트에 /actuator 엔드포인트가 인증 없이 접근 가능하다고 나와요. 또는 내부 메트릭이 외부에서 스크랩되고 있다는 걸 로그로 발견해요.

원인 — Spring Boot 기본 설정에서 actuator는 앱과 같은 포트(8080)를 써요. ingress 또는 ALB 규칙에서 경로 기반 라우팅이 /actuator/**를 명시적으로 막지 않으면 그대로 노출돼요.

해결7편에서 자세히 다뤘는데, 핵심은 포트 분리예요.

# application.yml — actuator를 별도 포트로 분리
server:
  port: 8080          # 서비스 트래픽

management:
  server:
    port: 8081        # actuator 전용 — 내부망에서만 허용
  endpoints:
    web:
      exposure:
        include: health, info, prometheus
# Kubernetes NetworkPolicy — 8081은 Prometheus scraper IP에서만
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-prometheus-scrape
spec:
  podSelector:
    matchLabels:
      app: order-service
  ingress:
    - ports:
        - port: 8080    # 서비스 트래픽 — ingress/LB에서
    - ports:
        - port: 8081    # actuator — Prometheus namespace에서만
      from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: monitoring

포트 분리 + NetworkPolicy + Spring Security IP 필터 세 겹이 있으면, 하나가 실수로 뚫려도 나머지 두 개가 막아요.

사고 8: 테스트의 SimpleMeterRegistry 혼선

운영 코드에서 메트릭이 잘 수집되는 줄 알았는데, 알고 보니 테스트에서는 NoopMeter가 반환되고 있었던 것. 조용히 사라지는 사고라서 발견이 늦어요.

증상 — 테스트는 다 통과해요. 그런데 운영에 배포하고 나서 특정 메트릭이 Prometheus에 아예 안 나타나요. 코드를 열어보면 MeterRegistry를 주입받아서 쓰는 것 같은데, 테스트에서 어떤 registry가 주입됐는지 불분명해요.

원인 — Micrometer는 Metrics.globalRegistry라는 전역 레지스트리를 제공해요. 빈 주입 없이 Metrics.counter("name").increment()처럼 쓰면 전역 레지스트리를 사용해요. 테스트 환경에서 실제 MeterRegistry 빈을 등록하지 않으면 전역 레지스트리가 비어있거나 NoOpMeterRegistry가 되어 모든 계측이 아무 동작도 안 해요.

// 위험한 패턴 — 전역 레지스트리 직접 사용
import io.micrometer.core.instrument.Metrics;

public void processOrder(Order order) {
    Metrics.counter("orders.created").increment();  // 테스트 환경에서 NoOp일 수 있음
}

또 다른 상황 — Spring 테스트에서 @MockBean MeterRegistry registry로 mock을 주입하면, 실제 카운터 동작 없이 조용히 통과해요. 운영 코드가 제대로 계측되는지 검증이 안 되는 거죠.

해결 — 테스트에서는 SimpleMeterRegistry를 명시적으로 생성해서 주입해요. 그러면 카운터 값을 assert로 검증할 수 있어요.

@Test
void 주문_생성_시_카운터가_증가한다() {
    // given
    SimpleMeterRegistry registry = new SimpleMeterRegistry();
    OrderService orderService = new OrderService(registry);  // 명시적 주입

    // when
    orderService.createOrder(sampleRequest());

    // then
    Counter counter = registry.find("orders.created").counter();
    assertThat(counter).isNotNull();
    assertThat(counter.count()).isEqualTo(1.0);
}

운영 빈은 MeterRegistry 타입으로 생성자 주입을 받도록 설계하고, Metrics.globalRegistry를 직접 참조하는 패턴은 피하는 게 좋아요.

사고 9: reactor/async context 태그 손실

WebFlux나 CompletableFuture, @Async 메서드에서 Micrometer Observation을 쓸 때, 태그가 붙은 채로 시작했는데 다른 스레드로 전환되면서 태그가 사라지는 사고예요.

증상 — Observation API로 계측한 메트릭의 태그가 부분적으로 비어있거나 기본값("unknown")으로 나와요. 특히 Reactor 체인 안에서 .flatMap() 이후에 기록된 메트릭의 태그가 깨져 있어요. 분산 추적에서 span이 연결이 끊겨서 완성된 trace를 볼 수 없고요.

원인 — Micrometer의 Observation과 Micrometer Tracing의 span 컨텍스트는 ThreadLocal을 기반으로 전파돼요. 그런데 Reactor, @Async, CompletableFuture 같은 비동기 모델은 작업이 다른 스레드에서 계속될 때 ThreadLocal이 자동으로 이어지지 않아요.

// 컨텍스트가 유실되는 패턴 — WebFlux 예시
Mono.fromCallable(() -> fetchUser(userId))    // 스레드 A에서 실행
    .subscribeOn(Schedulers.boundedElastic())
    .flatMap(user -> {
        // 여기는 다른 스레드 — Observation context가 없음
        // 메트릭의 userId 태그가 "unknown"으로 나옴
        return processUser(user);
    })

해결io.micrometer:context-propagation 라이브러리와 Reactor의 ContextRegistry를 활용해요. Spring Boot 3.x에서는 micrometer-observationcontext-propagation 의존성이 함께 있으면 Reactor 통합이 자동 설정되기도 해요.

<!-- pom.xml -->
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>context-propagation</artifactId>
</dependency>
// Reactor 컨텍스트 전파 활성화 (Reactor 3.5.3+)
// 앱 시작 시 한 번 설정
Hooks.enableAutomaticContextPropagation();
// 이후부터 ThreadLocal 기반 컨텍스트(MDC, Observation)가
// Reactor 체인 전반에 걸쳐 자동으로 전파됨

// 또는 명시적 컨텍스트 전달
Mono.just(request)
    .contextWrite(Context.of(ObservationThreadLocalAccessor.KEY, observation))
    .flatMap(this::processRequest);

Micrometer context-propagation 공식 문서에서 지원 대상(Reactor, CompletableFuture, gRPC 등)과 설정 방법을 확인할 수 있어요.

사고 10: 고빈도 메트릭의 성능 오버헤드

"메트릭이 비싸다"는 얘기를 처음엔 믿지 않다가, 프로파일러를 돌리고 나면 눈에 들어와요.

증상 — 특정 서비스의 CPU 사용률이 예상보다 높아요. 프로파일러를 돌리면 hot path 안에서 Micrometer 관련 코드(MeterRegistry.counter(), Timer.sample(), 태그 해시 계산)가 샘플의 상당 비율을 차지하고 있어요. 초당 수만 건 처리하는 이벤트 소비 루프 같은 곳에서 주로 나타나요.

원인 — Micrometer의 계측 작업은 무거운 편이 아니에요. 그런데 hot path에서 걸리는 지점은 둘이에요.

하나, Meter를 매 호출마다 빌더로 생성해요. registry.counter("name", "key", dynamicValue) 식으로 호출하면 내부에서 태그 배열 생성·해시 계산·레지스트리 조회가 매번 일어나요.

둘, 태그가 많거나 동적으로 계산돼요. 태그가 4~6개고 일부 값이 메서드 호출로 만들어진다면, 초당 수만 번 호출 시 해시 계산 비용이 누적돼요.

해결 — 처음부터 세 가지를 지켜요.

// 나쁜 패턴 — 매 요청마다 Meter 조회
public void handleEvent(Event event) {
    // 이 코드가 초당 10만 번 호출된다면?
    registry.counter("events.processed",
        "type", event.getType(),  // 매번 태그 배열 생성
        "region", event.getRegion()
    ).increment();
}

// 좋은 패턴 1 — Meter를 필드로 캐싱
private final Counter processedCounter;

public MyEventHandler(MeterRegistry registry) {
    this.processedCounter = Counter.builder("events.processed")
        .tag("type", "ORDER")
        .tag("region", "us-east")
        .register(registry);
}

public void handleEvent(Event event) {
    processedCounter.increment();  // 필드 참조만 — 오버헤드 없음
}

// 좋은 패턴 2 — 태그 값이 다양한 경우 ConcurrentHashMap으로 캐싱
private final ConcurrentHashMap<String, Counter> counterCache = new ConcurrentHashMap<>();

public void handleEvent(Event event) {
    counterCache.computeIfAbsent(
        event.getType(),
        type -> Counter.builder("events.processed")
            .tag("type", type)
            .register(registry)
    ).increment();
}

계측 지점 선별도 중요해요. 모든 메서드를 계측할 필요는 없어요. 비즈니스 경계, SLO 측정 지점, 실제로 대시보드에서 볼 메트릭만 계측하고 나머지는 빼요. 운영에서 보지 않을 메트릭은 CPU를 낭비하는 것 외에 아무 역할이 없거든요.

운영 권장 패턴

10가지 사고를 예방하는 핵심 패턴을 정리해요.

Pattern 1: 카디널리티 대시보드를 항상 유지

# 전체 시계열 수 — 증가 추세를 모니터링
prometheus_tsdb_head_series

# 상위 10개 고카디널리티 메트릭 확인
topk(10,
  count by (__name__) ({__name__=~".+"})
)

prometheus_tsdb_head_series가 급격히 증가하면 즉시 알람이 오도록 해요. 폭발 전에 막는 게 훨씬 쉬어요.

Pattern 2: Meter는 필드에서 한 번만 생성

어떤 Meter든 서비스 클래스의 필드(또는 생성자 인자)로 선언하고 재사용해요. 메서드 안에서 Counter.builder(...).register(registry)를 호출하는 코드가 보이면 리팩터링 신호예요.

// 팀 표준 패턴 — 생성자에서 한 번 등록
@Service
public class PaymentService {
    private final Counter successCounter;
    private final Counter failedCounter;
    private final Timer latencyTimer;

    public PaymentService(MeterRegistry registry) {
        this.successCounter = Counter.builder("payment.processed")
            .tag("result", "success")
            .register(registry);
        this.failedCounter = Counter.builder("payment.processed")
            .tag("result", "failed")
            .register(registry);
        this.latencyTimer = Timer.builder("payment.latency")
            .publishPercentileHistogram()
            .register(registry);
    }
}

Pattern 3: SLO 메트릭은 서버 사이드 histogram으로

publishPercentiles()만 켜면 다수 인스턴스 환경에서 percentile을 정확히 집계할 수 없어요. SLO에 연결되는 핵심 latency 메트릭은 publishPercentileHistogram()과 함께 써요.

Pattern 4: 테스트에서 SimpleMeterRegistry로 카운터 검증

메트릭 계측 코드가 실제로 동작하는지 테스트에서 검증해요. Mock을 주입하면 조용히 통과해서 운영에서 메트릭이 누락되는 걸 모를 수 있어요.

// 표준 테스트 패턴
SimpleMeterRegistry testRegistry = new SimpleMeterRegistry();
MyService service = new MyService(testRegistry);
service.doSomething();

assertThat(testRegistry.find("my.metric").counter().count())
    .isEqualTo(1.0);

Pattern 5: 비동기 앱에서 context-propagation 활성화

WebFlux나 @Async를 쓰는 앱에서는 Reactor Hooks 설정을 앱 시작 시 해두는 게 안전해요.

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        Hooks.enableAutomaticContextPropagation();  // Reactor 컨텍스트 자동 전파
        SpringApplication.run(Application.class, args);
    }
}

Pattern 6: 배포 체크리스트에 actuator 노출 여부 포함

CI/CD 파이프라인이나 배포 후 스모크 테스트에 아래 검사를 포함해요.

# 배포 후 스모크 테스트 — actuator가 공개망에 노출됐는지 확인
PUBLIC_URL="https://your-service.com"
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "$PUBLIC_URL/actuator/prometheus")

if [ "$RESPONSE" = "200" ]; then
  echo "SECURITY ALERT: /actuator/prometheus is publicly accessible!"
  exit 1
fi

시험 직전 한 번 더 — 운영 함정 압축 노트

사고 1: 카디널리티 OOM

  • 동적 값(userId·requestId·rawPath) → 태그 금지
  • MeterFilter.maximumAllowableTags() 로 상한 설정
  • prometheus_tsdb_head_series 상시 모니터링
  • URI 태그는 경로 패턴으로 정규화

사고 2: Gauge 누수

  • Gauge.builder("x", obj, fn) — obj를 WeakReference로 관찰
  • 람다 내에서 객체를 강하게 캡처하는 패턴 주의
  • Gauge는 생성자에서 한 번만 등록, 재등록 금지
  • 힙 덤프에서 Micrometer 관련 강한 참조 체인 점검

사고 3: percentile 합산 오류

  • publishPercentiles() 값은 avg()·sum() 금지 (합산 불가)
  • SLO 메트릭은 publishPercentileHistogram() + histogram_quantile 사용
  • 인스턴스 수가 1개일 때만 client-side percentile이 정확

사고 4: step 카운터 유실

  • StepMeterRegistry는 step마다 리셋 → scrape interval < step 유지
  • 앱이 step 사이에 죽으면 그 구간 데이터 유실
  • 짧게 실행되는 배치 Job은 PushGateway 패턴 고려

사고 5: double-counting

  • @Timed + 수동 Timer.record() 동시 적용 금지
  • Spring Boot 자동 HTTP 계측과 컨트롤러 수동 계측 중복 주의
  • 계측은 한 지점 한 경로 원칙

사고 6: 태그셋 충돌

  • 동일 메트릭 이름 → 태그 키 셋은 항상 고정
  • 상황에 따라 없는 태그 키는 sentinel 값("unknown")으로 채움
  • MeterBinder 라이브러리로 팀 표준화

사고 7: actuator 노출

  • management.server.port 분리 (8080 ≠ 8081)
  • Kubernetes NetworkPolicy로 Prometheus scraper IP에서만 허용
  • 배포 체크리스트에 노출 여부 검사 포함

사고 8: 테스트 NoopMeter

  • Metrics.globalRegistry 직접 참조 피하기
  • 테스트는 SimpleMeterRegistry 명시 주입 + count() assert
  • @MockBean MeterRegistry는 계측 동작 검증 불가

사고 9: async context 손실

  • ThreadLocal 기반 Observation/MDC는 thread 경계에서 유실
  • Hooks.enableAutomaticContextPropagation() — Reactor 자동 전파
  • context-propagation 라이브러리 의존성 추가
  • WebFlux에서는 Reactor Context로 명시적 전달도 가능

사고 10: hot path 오버헤드

  • Meter는 필드 캐싱, 매 호출마다 builder 사용 금지
  • 태그 값이 가변적이면 ConcurrentHashMap으로 Meter 캐싱
  • 불필요한 계측 지점 정리 — 보지 않을 메트릭은 비용만 있음

공통 예방 3원칙

  1. 태그 값은 유한 집합
  2. Meter는 필드에서 한 번, 재사용
  3. SLO 연결 메트릭은 서버 사이드 histogram

공식 문서: Micrometer 공식 docs · Spring Boot Actuator 운영 가이드 에서 더 깊은 운영 레퍼런스를 확인할 수 있어요.

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

이전 글:

다음 글:

답글 남기기

error: Content is protected !!