Micrometer 입문 2편 — Meter 타입 깊이 (Counter·Gauge·Timer·DistributionSummary)

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

Micrometer 입문 2편. Counter(단조 증가, 요청·에러 수)·Gauge(현재 순간 값, 큐·커넥션)·Timer(count+totalTime+max, latency 측정)·DistributionSummary(크기·건수 분포)·LongTaskTimer(진행 중 장기 작업)·FunctionCounter·FunctionTimer 등 Function 계열까지. 어느 Meter 타입을 언제 써야 하는지 비교표 포함.

📚 Micrometer 입문에서 운영까지 · 2편 — Meter 타입 깊이 (Counter·Gauge·Timer·DistributionSummary)

이 글은 Micrometer 입문에서 운영까지 시리즈 2편이에요. 1편에서 Micrometer 의 전체 그림 — MeterRegistry, 태그, 지원 백엔드, Observation API 큰 그림 — 을 잡았다면, 이번 글은 "실제로 무엇을 어떻게 측정하느냐" 로 내려가요. Meter 타입 하나하나를 직접 다뤄야 하는 자리예요.

이번 글의 범위

Micrometer 가 제공하는 Meter 타입은 생각보다 다양해요. 종류만 나열하면 아래와 같아요.

Meter 타입 핵심 용도
Counter 단조 증가 카운트 — 총 요청 수, 에러 수
Gauge 현재 순간의 값 — 큐 크기, 커넥션 수, 스레드 수
Timer 실행 시간 측정 — count + totalTime + max 자동 제공
DistributionSummary 시간 외의 분포 — 응답 크기(byte), 배치 건수
LongTaskTimer 아직 끝나지 않은 장기 작업 — active count + duration
FunctionCounter 이미 존재하는 외부 카운트 누적값 노출
FunctionTimer 이미 존재하는 외부 시간 누적값 노출
TimeGauge 시간 단위의 Gauge — 시간 값을 다른 단위로 변환 노출
MultiGauge 여러 행(row)을 한 번에 Gauge 로 노출

하나씩 뜯어 볼게요.

Counter — 단조 증가만 한다

Counter 는 가장 단순한 Meter 예요. 숫자가 오직 올라가기만 해요. 음수로 내리는 게 불가능하고, 재설정도 안 돼요. 이 제약이 Counter 의 정체성이에요.

비유하자면 출퇴근 기록부 같은 거예요. "오늘 총 몇 명이 들어왔냐"는 올라가기만 하죠. "지금 몇 명이 안에 있냐"는 Gauge 가 할 일이고, Counter 는 그 질문에 대답을 못 해요.

// 기본 생성 패턴
Counter counter = Counter.builder("http.requests.total")
    .tag("uri", "/api/orders")
    .tag("method", "POST")
    .tag("status", "200")
    .description("Total HTTP requests handled")
    .register(registry);

// 증가 방법
counter.increment();        // 1씩 증가
counter.increment(5.0);     // 5만큼 증가 (double 허용)

// 현재 누적값 읽기
double total = counter.count(); // 예: 4802.0

여기서 중요한 포인트가 하나 있어요. Counter 가 주는 건 절대 누적값 이지, rate 가 아니에요. "초당 몇 건이냐"라는 질문은 Counter 가 아니라 Prometheus 의 rate() 함수가 답해요.

# Prometheus 쿼리 — 지난 5분 평균 초당 요청 수
rate(http_requests_total{uri="/api/orders"}[5m])

Counter 자체는 그냥 쌓아 놓고, 백엔드가 그 시계열에서 rate 를 계산하는 구조예요. 잊고 있다가 "Counter 값이 계속 늘어난다"고 놀라는 경우가 있어요 — 원래 그렇게 설계된 거예요.

Spring Boot 에서의 Counter 단축 표현

// registry.counter() 단축 호출 (builder 없이)
registry.counter("orders.failed", "reason", "validation").increment();

빌더가 번거로울 때 쓰는 편의 메서드예요. 단 description 이 없어서 메트릭 문서화 관점에서는 빌더 패턴이 더 나아요.

Gauge — 지금 이 순간의 값

Gauge 는 Counter 와 달리 올라갔다 내려갔다 해도 돼요. 큐에 메시지가 쌓이면 올라가고, 처리되면 내려가죠. 현재 순간의 상태 를 찍는 스냅샷 도구예요.

Gauge 를 만드는 방법이 Counter 와 조금 달라요. Gauge 는 "주기적으로 어딘가에서 값을 읽어 와야" 하기 때문에, 값 자체가 아니라 값을 읽어 올 함수 + 관찰할 객체 를 같이 넘겨요.

// 컬렉션 크기를 Gauge 로 노출 — 권장 패턴
Queue<String> orderQueue = new LinkedBlockingQueue<>();

Gauge.builder("order.queue.size", orderQueue, Collection::size)
    .tag("queue", "processing")
    .description("Current number of orders waiting")
    .register(registry);

Collection::size 가 값을 읽어 오는 함수예요. Micrometer 는 이 함수를 주기적으로 호출해서 그 순간의 값을 기록해요.

⚠️ Gauge 강한참조 메모리 누수 함정

Gauge 는 내부적으로 관찰 대상 객체를 약한참조(WeakReference) 로 잡아요. 그래야 GC 가 원래 객체를 정상적으로 수집할 수 있거든요. 그런데 람다 캡처나 강한참조를 실수로 넘기면, Micrometer 가 그 참조를 붙들고 있어서 GC 가 객체를 못 수거하는 메모리 누수가 생겨요.

특히 Gauge.builder("name", () -> someObj.getValue()) 처럼 람다가 외부 객체를 캡처할 때 발생해요. 항상 Gauge.builder("name", targetObj, obj -> obj.getValue()) 형태로 객체를 두 번째 인자로 명시하는 게 안전해요.

Gauge 의 약한참조 동작 확인

// ❌ 람다 캡처 패턴 — 누수 위험
AtomicInteger count = new AtomicInteger(0);
Gauge.builder("active.count", () -> count.get())  // count 강한참조 캡처
    .register(registry);

// ✅ 객체 명시 패턴 — 안전
AtomicInteger count = new AtomicInteger(0);
Gauge.builder("active.count", count, AtomicInteger::get)  // 약한참조로 감쌈
    .register(registry);

약한참조로 감싼 경우, 만약 count 가 다른 곳에서 참조를 잃으면 Gauge 가 NaN 을 리포트하기 시작해요. 이게 "객체가 GC 됐다"는 신호예요. 반가운 신호는 아니지만 누수보다는 낫죠.

Gauge 단순 값 설정

외부 객체 없이 단순 숫자를 Gauge 로 박고 싶을 때는 AtomicIntegerAtomicLong 을 활용해요.

AtomicInteger activeConnections = new AtomicInteger(0);

Gauge.builder("db.connections.active", activeConnections, AtomicInteger::get)
    .description("Active DB connections")
    .register(registry);

// 다른 곳에서 값 변경
activeConnections.incrementAndGet();  // 커넥션 열림
activeConnections.decrementAndGet();  // 커넥션 닫힘

Timer — 얼마나 걸렸나

Timer 는 실행 시간을 측정하는 Meter 예요. Counter 와 Timer 를 합쳐 놓은 거라고 보면 돼요. 한 번 record() 할 때마다 내부적으로 세 가지 값이 동시에 갱신돼요.

count (실행 횟수) · totalTime (누적 실행 시간) · max (최근 구간 최댓값)

그래서 "초당 처리량"도 Timer 하나로 뽑아낼 수 있고, "평균 latency"도 totalTime / count 로 구해요. Prometheus 에서는 _count, _sum, _bucket suffix 로 분리돼서 나와요.

// 기본 Timer 사용 — record(Runnable)
Timer timer = Timer.builder("orders.processing.time")
    .tag("type", "online")
    .description("Order processing latency")
    .register(registry);

// 방법 1: record(Runnable) — 가장 간단
timer.record(() -> processOrder(request));

// 방법 2: record(Callable<T>) — 반환값 필요할 때
Order result = timer.recordCallable(() -> processOrder(request));

// 방법 3: Timer.Sample — 시작·종료를 분리해야 할 때
Timer.Sample sample = Timer.start(registry);
try {
    Order order = processOrder(request);
    sample.stop(timer);
    return order;
} catch (Exception e) {
    sample.stop(Timer.builder("orders.processing.time")
        .tag("type", "online")
        .tag("error", "true")
        .register(registry));
    throw e;
}

Timer.Sample 은 시작과 종료 시점이 메서드 경계를 넘어야 할 때 유용해요. 예를 들어 요청 수신 시점에 Timer.start() 를 찍고, 응답을 다 보낸 다음에 sample.stop() 을 호출하는 패턴이 그래요.

Timer 의 나노초 정밀도

Timer 는 내부적으로 나노초(nanosecond) 정밀도로 시간을 기록해요. 수동으로 System.currentTimeMillis() 를 계산해서 넘기는 것보다 훨씬 정확하고, Exception 이 터졌을 때 finally 에서 처리하는 실수도 없어요.

// ❌ 이렇게 하지 마세요 — 수동 계산 + Exception 누락 위험
long start = System.currentTimeMillis();
try {
    processOrder(request);
} finally {
    long elapsed = System.currentTimeMillis() - start;
    timer.record(elapsed, TimeUnit.MILLISECONDS); // ms 단위 정밀도 손실
}

// ✅ timer.record() 에 맡기세요
timer.record(() -> processOrder(request)); // 나노초 정밀도, Exception 안전

percentile(p50·p95·p99) 과 histogram 설정은 3편에서 자세히 다뤄요.

DistributionSummary — 분포를 잰다

DistributionSummary 는 Timer 와 구조가 비슷해요. 내부적으로 count · sum · max 를 관리해요. 차이는 딱 한 가지 — 시간이 아닌 수량 의 분포를 잴 때 써요.

"이번 API 응답이 몇 byte 였나", "이번 배치에서 몇 건을 처리했나", "이번 주문 금액이 얼마였나" — 이런 게 다 DistributionSummary 의 자리예요. Timer 는 내부적으로 나노초를 기준으로 설계돼 있어서 bytes 나 건수를 넣으면 단위가 맞지 않아요.

// 응답 크기(byte) 측정
DistributionSummary responseSizeBytes = DistributionSummary.builder("http.response.size")
    .tag("uri", "/api/orders")
    .baseUnit("bytes")
    .description("Size of HTTP responses in bytes")
    .register(registry);

// 응답 보낼 때마다 기록
responseSizeBytes.record(response.getContentLength());

// 배치 처리 건수 측정
DistributionSummary batchSize = DistributionSummary.builder("batch.processed.count")
    .tag("job", "order-settlement")
    .description("Number of records processed per batch execution")
    .register(registry);

batchSize.record(processedCount); // 각 배치 실행 후

baseUnit("bytes") 는 Prometheus 메트릭 이름 접미사에 영향을 줘요. 설정하면 http_response_size_bytes_sum 처럼 단위가 이름에 붙어서 대시보드에서 헷갈림이 줄어요.

DistributionSummary 도 Timer 처럼 percentile 과 histogram 설정을 동일한 API 로 지원해요.

LongTaskTimer — 진행 중인 작업

Counter 나 Timer 는 "작업이 끝난 다음" 에 기록해요. 그런데 어떤 작업은 완료까지 몇 분, 심하면 몇 시간이 걸리기도 하잖아요. 배치 Job, 대용량 파일 처리, 외부 API 폴링 — 이런 작업이 지금 몇 개나 돌고 있는지, 각각 얼마나 됐는지 알아야 할 때가 있어요.

LongTaskTimer 는 그런 상황을 위해 설계됐어요. 작업이 시작되는 시점 에 기록하고, 끝나는 시점까지 active count 와 경과 시간을 실시간으로 리포트해요.

LongTaskTimer longTaskTimer = LongTaskTimer.builder("batch.settlement.active")
    .tag("job", "daily-settlement")
    .description("Long-running settlement batch tasks")
    .register(registry);

// 작업 시작 시점에 sample 열기
LongTaskTimer.Sample sample = longTaskTimer.start();
try {
    runSettlementBatch(); // 몇 분 걸리는 작업
} finally {
    sample.stop(); // 완료 시 닫기
}

작업이 진행 중일 때 Prometheus 에서 읽으면 active_tasksduration_sum 이 찍혀 있어요. 30분이 넘게 돌고 있다면 대시보드에서 즉시 보여서 조기에 알아챌 수 있죠.

여기서 주의할 점이 하나 있어요. LongTaskTimer 는 완료된 작업의 latency 를 집계하지 않아요. 완료된 건 Timer 로 따로 찍어야 해요. LongTaskTimer 는 순전히 "지금 얼마나 많은 작업이 얼마나 오래 돌고 있느냐"만 봐요.

Function 계열 — 외부 상태를 관찰

FunctionCounter, FunctionTimer, TimeGauge, MultiGauge 는 공통점이 있어요. 앱 내부에서 Micrometer 가 직접 측정하는 게 아니라, 이미 어딘가에 쌓여 있는 카운트·시간 값을 Micrometer 메트릭으로 노출 하는 역할이에요.

JVM 내장 MXBean, 서드파티 라이브러리 내부 카운터, DBCP 풀 통계 — 이런 것들이 이미 값을 들고 있어요. Micrometer 는 그 값을 주기적으로 읽어서 자기 레지스트리에 싣는 거예요.

FunctionCounter

// 서드파티 라이브러리의 누적 카운터를 Micrometer 로 노출
SomeThirdPartyClient client = ...; // 내부에 long processedCount 를 보유

FunctionCounter.builder("thirdparty.processed", client, SomeThirdPartyClient::getProcessedCount)
    .description("Cumulative count from third-party client")
    .register(registry);

Micrometer 는 getProcessedCount() 를 주기적으로 호출해서 이전 값과 차이를 Counter 처럼 다뤄요. 직접 increment() 를 호출할 수 없고, 외부에서 읽어 오기만 해요.

FunctionTimer

// 누적 호출 횟수와 누적 시간 둘 다 외부에서 읽어 오는 경우
FunctionTimer.builder("cache.get.time",
        cache,
        Cache::getHitCount,        // count 함수
        Cache::getTotalGetTime,    // totalTime 함수 (나노초 또는 지정 단위)
        TimeUnit.NANOSECONDS)
    .description("Cache get operation timing")
    .register(registry);

TimeGauge

일반 Gauge 와 같은데, 값의 단위가 시간인 경우예요. Micrometer 가 내부적으로 단위 변환을 처리해 줘요.

// 캐시 TTL 남은 시간을 초 단위로 노출
TimeGauge.builder("cache.ttl.remaining", cache, TimeUnit.SECONDS, Cache::getTtlSeconds)
    .description("Remaining TTL of the cache in seconds")
    .register(registry);

MultiGauge

데이터베이스 테이블별 행 수처럼, 동일한 구조의 Gauge 를 여러 레코드에 대해 한꺼번에 갱신 해야 할 때 써요.

MultiGauge tableRowCounts = MultiGauge.builder("table.row.count")
    .description("Approximate row count per table")
    .register(registry);

// 주기적으로 갱신
tableRowCounts.register(
    Stream.of("orders", "users", "products")
        .map(table -> MultiGauge.Row.of(Tags.of("table", table), getRowCount(table)))
        .collect(Collectors.toList()),
    true  // overwrite 여부
);

각 Row 가 별도의 시계열이 돼요. 테이블이 추가되면 다음 갱신 시 자동으로 새 시계열이 생겨요.

어떤 타입을 언제 쓰나

Meter 타입 선택이 처음엔 애매하게 느껴지는데, 사실 판단 기준이 꽤 명확해요.

상황 추천 Meter
요청 수, 에러 수, 처리 완료 수 (누적 총합) Counter
현재 큐 길이, 현재 커넥션 수, 현재 스레드 수 Gauge
API 응답 시간, DB 쿼리 시간, 메서드 실행 시간 Timer
응답 크기(bytes), 배치 처리 건수, 요청 페이로드 크기 DistributionSummary
아직 진행 중인 배치·스케줄 작업의 현재 상태 LongTaskTimer
서드파티·JVM 내부 누적 카운터를 외부로 노출 FunctionCounter
서드파티·JVM 내부 누적 시간값을 외부로 노출 FunctionTimer
시간 값을 Gauge 로 노출 (단위 변환 필요) TimeGauge
동일 구조 Gauge 를 여러 레코드에 동시 노출 MultiGauge

가장 많이 헷갈리는 조합은 Counter vs GaugeTimer vs DistributionSummary 예요.

Counter vs Gauge — "지금까지 총 몇 번?"이면 Counter, "지금 이 순간 몇 개?"이면 Gauge예요. 내려갈 수 있으면 Gauge, 올라가기만 하면 Counter.

Timer vs DistributionSummary — "단위가 시간이면" Timer, "시간 아닌 수량이면" DistributionSummary. 응답 바이트 수를 Timer 에 넣으면 백엔드가 나노초로 오해할 수 있어요.

함정 정리

사고 1: Counter 에 음수 increment 시도

원인 — "취소된 주문"을 표현하려고 counter.increment(-1.0) 을 호출해요.

해결 — Counter 는 음수 increment 를 허용하지 않아요(IllegalArgumentException 발생). "취소된 주문 수"는 별도 Counter 로 만들어야 해요. orders.cancelled Counter 를 따로 두는 게 맞는 설계죠.

사고 2: Gauge 람다 캡처 → 메모리 누수

원인Gauge.builder("size", () -> list.size()) 처럼 람다가 외부 객체를 강한참조로 캡처해요.

해결Gauge.builder("size", list, Collection::size) 로 객체를 두 번째 인자로 명시해요. Micrometer 가 약한참조로 잡아서 GC 정상 작동을 보장해요.

사고 3: Timer 를 수동으로 시간 계산해서 넘기기

원인System.currentTimeMillis() 차이를 구해 timer.record(elapsed, TimeUnit.MILLISECONDS) 로 넣어요.

해결timer.record(() -> {...}) 에 맡기면 됩니다. 나노초 정밀도, Exception 안전 처리, 시작·종료 관리 전부 Timer 가 해요.

사고 4: DistributionSummary 에 시간 단위 값을 넣음

원인 — "배치 처리 시간"을 DistributionSummary 로 기록해요. 값이 밀리초 단위라 숫자가 엄청 커요.

해결 — 처리 시간은 Timer 가 맞아요. Timer 는 내부적으로 나노초로 기록하고 백엔드가 단위를 알고 있어서 쿼리가 제대로 돼요. DistributionSummary 는 순수 수량(bytes, 건수 등) 전용이에요.

사고 5: LongTaskTimer 를 완료된 작업 latency 에 사용

원인 — 배치 작업 latency 를 LongTaskTimer 로 찍어요.

해결 — LongTaskTimer 는 "진행 중" 상태만 추적해요. 완료된 배치의 소요 시간을 알고 싶으면 Timer 를 함께 써야 해요. 둘을 같이 사용하면 "지금 몇 개가 돌고 있느냐"(LongTaskTimer) + "완료된 배치의 평균 시간은"(Timer) 두 질문을 동시에 답할 수 있어요.

사고 6: FunctionCounter 에 감소하는 값을 넘기기

원인 — 누적값이 아닌 현재 값(증감 가능한 값)을 FunctionCounter 에 연결해요.

해결 — FunctionCounter 는 단조 증가하는 누적 카운트를 읽어야 해요. 값이 내려갈 수 있는 상태는 Gauge 나 FunctionGauge 로 처리해야 해요. 값이 내려가면 Micrometer 가 전 주기보다 값이 작아졌다고 판단해 delta 를 0으로 처리해요.

사고 7: Gauge 약한참조로 객체가 GC 되어 NaN 리포트

원인 — Gauge 에 연결된 객체가 다른 곳에서 강한참조를 잃으면 GC 가 수거해요. 그 후 Gauge 가 NaN 을 리포트해요.

해결 — Gauge 가 관찰할 객체는 서비스·컴포넌트 필드처럼 생명주기가 명확한 곳에 보관해야 해요. 로컬 변수에만 있는 객체를 Gauge 에 연결하면 GC 에 금방 수거됩니다.

사고 8: Timer.Sample stop 을 finally 밖에서 호출

원인 — 정상 흐름에서만 sample.stop() 을 부르고, Exception 이 터지면 sample 이 닫히지 않아요.

해결sample.stop() 은 항상 finally 블록에서 호출해야 해요. 또는 처음부터 timer.record(() -> {...}) 를 쓰면 이 문제를 원천 차단할 수 있어요.

사고 9: DistributionSummary baseUnit 없이 byte 값 기록

원인DistributionSummary.builder("response.size") 에서 baseUnit("bytes") 를 빠뜨려요.

해결baseUnit("bytes") 를 붙이면 Prometheus 메트릭 이름이 response_size_bytes_sum 처럼 단위를 포함해요. 대시보드에서 단위 혼동이 줄어들고 팀 내 표준화에도 도움돼요.

사고 10: 같은 이름·같은 태그로 다른 Meter 타입 중복 등록

원인 — 코드 여러 군데에서 Counter.builder("orders.total")Gauge.builder("orders.total") 를 같은 이름으로 등록해요.

해결 — MeterRegistry 는 이름+태그 조합이 같으면 기존 Meter 를 반환해요. 하지만 타입이 다르면 IllegalArgumentException 이 발생해요. Meter 이름은 타입을 반영하도록 설계하는 게 좋아요 — orders.created.count (Counter) vs orders.queue.size (Gauge) 처럼요.

운영 권장 패턴

Pattern 1: Counter + Gauge 세트로 처리 파이프라인 모니터링

@Service
public class OrderPipelineService {

    private final Counter receivedCounter;
    private final Counter processedCounter;
    private final Counter failedCounter;
    private final AtomicInteger inProgressGauge;

    public OrderPipelineService(MeterRegistry registry) {
        this.receivedCounter = Counter.builder("orders.received")
            .description("Orders received into pipeline").register(registry);
        this.processedCounter = Counter.builder("orders.processed")
            .description("Successfully processed orders").register(registry);
        this.failedCounter = Counter.builder("orders.failed")
            .description("Failed order processing attempts").register(registry);

        this.inProgressGauge = new AtomicInteger(0);
        Gauge.builder("orders.in_progress", inProgressGauge, AtomicInteger::get)
            .description("Orders currently being processed").register(registry);
    }

    public void process(Order order) {
        receivedCounter.increment();
        inProgressGauge.incrementAndGet();
        try {
            doProcess(order);
            processedCounter.increment();
        } catch (Exception e) {
            failedCounter.increment();
            throw e;
        } finally {
            inProgressGauge.decrementAndGet();
        }
    }
}

이 패턴은 "총 몇 개 받았나", "총 몇 개 성공했나", "총 몇 개 실패했나", "지금 처리 중인 게 몇 개나"를 하나의 서비스에서 한꺼번에 볼 수 있게 해줘요.

Pattern 2: Timer.Sample 로 비동기 흐름 측정

@Service
public class AsyncOrderService {

    private final Timer orderTimer;

    public AsyncOrderService(MeterRegistry registry) {
        this.orderTimer = Timer.builder("orders.async.time")
            .description("Async order processing latency")
            .register(registry);
    }

    public CompletableFuture<Order> processAsync(CreateOrderRequest req) {
        Timer.Sample sample = Timer.start(); // 시작점 저장

        return CompletableFuture
            .supplyAsync(() -> createOrder(req))
            .whenComplete((order, ex) -> sample.stop(orderTimer)); // 완료 시 stop
    }
}

비동기 흐름에서는 record(() -> {...}) 가 안 맞아요. Timer.Sample 을 시작점에 만들어 두고, 비동기 콜백에서 stop() 을 호출하는 패턴이 맞아요.

Pattern 3: MeterBinder 로 서드파티 라이브러리 메트릭 통합

@Component
public class HikariPoolMetricsBinder implements MeterBinder {

    private final HikariDataSource dataSource;

    public HikariPoolMetricsBinder(HikariDataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public void bindTo(MeterRegistry registry) {
        HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();

        Gauge.builder("hikari.connections.active", pool, HikariPoolMXBean::getActiveConnections)
            .description("Active HikariCP connections").register(registry);

        Gauge.builder("hikari.connections.idle", pool, HikariPoolMXBean::getIdleConnections)
            .description("Idle HikariCP connections").register(registry);

        Gauge.builder("hikari.connections.pending", pool, HikariPoolMXBean::getThreadsAwaitingConnection)
            .description("Threads waiting for a connection").register(registry);
    }
}

MeterBinder@Component 로 등록하면 Spring Boot 가 MeterRegistry 와 함께 자동 바인딩해요.

Pattern 4: DistributionSummary 로 SLO 위반 추적

DistributionSummary responseSizeKB = DistributionSummary.builder("api.response.size")
    .baseUnit("kilobytes")
    .tag("endpoint", "/api/export")
    .sla(100, 500, 1000)        // 100KB, 500KB, 1MB 버킷
    .publishPercentiles(0.5, 0.95, 0.99)
    .register(registry);

// 응답 보낼 때마다
responseSizeKB.record(response.getContentLength() / 1024.0);

sla() 버킷을 설정하면 Prometheus 에서 "100KB 이하 응답 비율"을 쿼리로 뽑을 수 있어요. 응답 크기 SLO 관리에 유용해요.

Pattern 5: 스케줄 작업에 LongTaskTimer + Timer 이중 적용

@Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시
public void runDailySettlement() {
    LongTaskTimer.Sample longSample = longTaskTimer.start(); // 진행 중 추적
    Timer.Sample timerSample = Timer.start(registry);       // 완료 후 latency 기록
    try {
        doSettlement();
    } finally {
        longSample.stop();
        timerSample.stop(completionTimer);
    }
}

이중 적용이면 "지금 몇 개의 배치가 돌고 있냐"(LongTaskTimer)와 "완료된 배치는 평균 몇 분 걸렸냐"(Timer)를 동시에 알 수 있어요.

Pattern 6: Gauge 로 JVM 외부 리소스 상태 노출

@Component
public class ExternalResourceGauges implements MeterBinder {

    private final RedisConnectionPool redisPool;
    private final KafkaProducer<?, ?> kafkaProducer;

    @Override
    public void bindTo(MeterRegistry registry) {
        // Redis 풀 상태
        Gauge.builder("redis.pool.active", redisPool, RedisConnectionPool::getNumActive)
            .description("Active Redis connections").register(registry);

        // Kafka 프로듀서 버퍼 사용률
        FunctionGauge.builder("kafka.producer.buffer.available.bytes",
                kafkaProducer.metrics(), metrics ->
                    metrics.get(new MetricName("buffer-available-bytes", "producer-metrics", "", Collections.emptyMap()))
                           .map(Metric::metricValue)
                           .map(v -> ((Double) v))
                           .orElse(0.0))
            .description("Available bytes in Kafka producer buffer")
            .register(registry);
    }
}

시험 직전 한 번 더 — Meter 타입 압축 노트

Counter

  • 단조 증가만 허용 — 음수 increment 불가
  • 총 요청 수·에러 수·처리 완료 수 등 누적 총합에 사용
  • Counter.builder("name").tag(...).register(registry), increment(), increment(n)
  • rate 계산은 Counter 가 아니라 Prometheus rate() 함수의 몫

Gauge

  • 올라갔다 내려갔다 OK — 현재 순간의 상태 스냅샷
  • 큐 크기·커넥션 수·스레드 수·메모리 사용률에 사용
  • Gauge.builder("name", targetObj, obj -> obj.getValue()) — 객체 두 번째 인자 명시 필수
  • 람다 캡처 강한참조 → GC 못 수거 → 메모리 누수
  • 약한참조 GC 시 Gauge 는 NaN 리포트

Timer

  • count + totalTime + max 세 값 자동 동시 관리
  • 실행 시간 측정 전용 — 내부 단위 나노초
  • timer.record(Runnable), timer.recordCallable(Callable), Timer.Sample
  • Timer.Sample 은 비동기·시작·종료 분리 흐름에서 사용
  • percentile·histogram 설정은 3편 주제

DistributionSummary

  • Timer 와 동일 구조(count·sum·max)이나 시간 아닌 수량 전용
  • 응답 크기(bytes)·배치 건수·금액 분포에 사용
  • baseUnit("bytes") 설정하면 Prometheus 이름에 단위 자동 포함
  • 시간 값을 DistributionSummary 에 넣는 실수 → 단위 맞지 않음

LongTaskTimer

  • 진행 중인 작업의 active count + duration 실시간 리포트
  • 완료된 작업 latency 는 추적 안 함 — Timer 와 함께 사용해야
  • LongTaskTimer.Sample sample = longTaskTimer.start(), sample.stop()

Function 계열

  • FunctionCounter — 외부 누적 카운트 노출 (단조 증가 값만)
  • FunctionTimer — 외부 누적 카운트 + 시간 동시 노출
  • TimeGauge — 시간 단위 Gauge (단위 변환 처리)
  • MultiGauge — 동일 구조 Gauge 를 여러 레코드에 한 번에

흔한 사고

  • Counter 에 음수 → IllegalArgumentException
  • Gauge 람다 캡처 → 강한참조 → 메모리 누수
  • Timer 수동 시간 계산 → 단위 실수, Exception 누락
  • DistributionSummary 에 시간 단위 값 → 단위 혼동
  • LongTaskTimer 를 완료 latency 에 오용
  • FunctionCounter 에 감소 가능한 값 → delta 0 처리
  • Timer.Sample stop() 을 finally 밖에서 호출
  • Gauge GC 수거 후 NaN 리포트 인지 못함
  • DistributionSummary baseUnit 누락 → 단위 불명확

타입 선택 핵심 기준

  • "총 몇 번?" → Counter / "지금 몇 개?" → Gauge
  • "얼마나 걸렸나?" → Timer / "크기·건수 분포?" → DistributionSummary
  • "아직 진행 중인 작업?" → LongTaskTimer
  • "외부 값 노출?" → Function 계열

공식 레퍼런스: Micrometer 공식 docs — Concepts 에서 각 Meter 타입의 전체 옵션을 확인할 수 있어요.

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

이전 글:

다음 글:

답글 남기기

error: Content is protected !!