본문 바로가기

Micrometer 레지스트리

미터 이름과 태그 규칙을 정하고 카운터·타이머·MeterFilter의 카디널리티를 관리합니다.

애플리케이션이 Prometheus 클라이언트 API에 직접 묶이면, 백엔드를 바꾸거나 테스트용 레지스트리를 쓰기 어렵습니다. Micrometer는 미터(meter)를 만드는 계측 API와 백엔드별 레지스트리를 분리합니다. 코드는 MeterRegistry에 의미 있는 측정을 기록하고, Prometheus나 OTLP 같은 구현이 각 백엔드의 이름 규칙과 전송 형식으로 바꿉니다.

다만 이 추상화가 메트릭의 의미까지 정해 주지는 않으므로, 이름과 태그는 애플리케이션이 지켜야 할 규칙으로 직접 설계해야 합니다. 이 글에서는 미터의 식별자, 레지스트리 백엔드, 공통 태그의 비용, MeterFilter, 타이머 기록, 테스트용 레지스트리를 차례로 다룹니다.

MICROMETER · ID AND SERIES BUDGET

미터 식별자는 이름과 태그이며 시계열 비용을 함께 검사한다

Micrometer가 백엔드를 분리해도 측정 의미와 타입·단위·태그 카탈로그는 애플리케이션 책임입니다. 이름/태그가 같은 미터에 서로 다른 계약을 섞지 않습니다.

계측 계약과 레지스트리 및 백엔드의 관계같은 이름과 태그는 미터 식별자이고 타입·단위·허용 값은 카탈로그 계약이다. 레지스트리를 주입해 등록하고 백엔드에 내보낸 이름과 추가 계열을 확인한다. MeterFilter 정책은 별도 구성이다. 원 와이드 SVG와 보조 목록은 숨김 지원으로 보존하며 모바일에는 같은 3노드2관계를 표시한다.
미터 카탈로그이름·태그·단위
연결 관계미터 카탈로그 · 이름·태그·단위 → MeterRegistry · 주입·등록·필터
MeterRegistry주입·등록·필터
연결 관계MeterRegistry · 주입·등록·필터 → 백엔드 · 내보낸 이름·계열
백엔드내보낸 이름·계열
계측 계약에서 레지스트리와 백엔드로카탈로그는 이름과 태그 및 타입 단위를 정의하고 레지스트리에 등록하며 백엔드가 내보낸 이름과 시계열을 검증한다.
미터 카탈로그이름·태그 + 타입·단위
연결 관계미터 카탈로그 · 이름·태그 + 타입·단위 → MeterRegistry · 주입·등록·filter
MeterRegistry주입·등록·filter
연결 관계MeterRegistry · 주입·등록·filter → 백엔드 · 내보낸 이름·시계열
백엔드내보낸 이름·시계열

MeterFilter 차단 정책은 별도 구성 · 본문 예제에 자동 적용되지 않음

  1. 카탈로그 → 레지스트리 → 백엔드

    이름·태그로 식별하고 타입/단위를 고정합니다. 레지스트리를 주입하며 filter 정책과 실제 내보낸 이름을 별도 검증합니다.

Counter · 누적 사건

양의 사건을 누적합니다. 현재 대기열처럼 감소하는 상태로 읽지 않고 프로세스 재시작을 쿼리에 반영합니다.

Timer · 완료 한 번

정상과 예외 모두 표본을 종료합니다. 개수와 전체 시간을 가지므로 같은 사건의 중복 카운터를 검토하고 재시도 전체/시도 경계를 나눕니다.

시계열 예산 · 조합 상한

50 경로 × 5 메서드 × 6 상태 × 3 영역 × 20 인스턴스 = 90,000 태그 조합 상한입니다. 발행 버킷·추가 계열까지 별도 산정합니다.

값과 ID 집합을 함께 검증

본문 SimpleMeterRegistry 예시는 accepted=2, rejected=1, 미터 2개입니다. 예상치 못한 태그·동적 이름과 증가하는 미터 수를 함께 확인합니다.

등록 전에 값 집합을 제한하고 filter 적용 순서를 검증합니다. SimpleMeterRegistry 성공만으로 Prometheus 이름·라벨·히스토그램 비용이 증명되지는 않습니다.

그림은 이름, 태그, 타입, 단위를 정한 미터 카탈로그가 주입받은 MeterRegistry에 등록되고, 백엔드가 실제로 내보낸 이름과 시계열을 확인하는 흐름입니다. 같은 이름과 태그가 미터를 식별하며, MeterFilter의 차단 정책은 따로 구성해야 할 뿐 본문 예제에 자동으로 적용되지 않습니다. 아래 칸들은 카운터와 타이머가 각각 무엇을 기록하는지, 50 × 5 × 6 × 3 × 20 = 90,000이라는 시계열 예산의 계산, 그리고 값과 미터 ID 집합을 함께 검증해야 한다는 점을 정리합니다.


미터 식별자

이름이 board.entry.processed로 같아도 outcome=accepted와 outcome=rejected는 서로 다른 시계열입니다. 태그의 순서보다 키-값 집합이 중요하며, 같은 이름에 미터 타입이나 태그 키 집합을 섞어 쓰면 레지스트리 백엔드가 거부하거나 해석이 깨질 수 있습니다. 그래서 이름, 타입, 기반 단위, 설명, 허용하는 태그 키와 값의 목록을 카탈로그로 관리합니다.

점으로 구분한 이름은 Micrometer에서 백엔드에 상관없이 쓸 수 있는 형식이고, 백엔드 레지스트리가 Prometheus의 밑줄 표기 같은 관례에 맞게 바꿉니다. 코드에서 백엔드에 맞춰 바뀐 이름을 미리 쓰지 않습니다. 단위는 이름에 중복해 넣는 대신 기반 단위와 설명으로 제공하되, 쿼리할 때 쓰는 실제 내보낸 이름은 통합 테스트에서 확인합니다.

src/main/java/board/metrics/EntryMetrics.java
package board.metrics;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;

public final class EntryMetrics {
    private final Counter accepted;
    private final Counter rejected;

    public EntryMetrics(MeterRegistry registry) {
        accepted = Counter.builder("board.entry.processed")
                .description("Completed entry processing attempts")
                .baseUnit("entries")
                .tag("outcome", "accepted")
                .register(registry);
        rejected = Counter.builder("board.entry.processed")
                .description("Completed entry processing attempts")
                .baseUnit("entries")
                .tag("outcome", "rejected")
                .register(registry);
    }

    public void accepted() {
        accepted.increment();
    }

    public void rejected() {
        rejected.increment();
    }
}

카운터(counter)는 음수를 기록하지 않고 프로세스가 살아 있는 동안 계속 누적합니다. 현재 대기열 크기처럼 줄어들 수 있는 상태를 두 카운터의 차로 그때그때 계산할지, 게이지로 직접 볼지는 데이터 유실과 재시작 때의 초기화를 고려해 정합니다.


레지스트리 백엔드

애플리케이션 컨텍스트에는 레지스트리가 하나 이상 있을 수 있습니다. 복합 레지스트리(composite registry)를 쓴다면, 자식 레지스트리가 추가되기 전에 기록한 값이 어떻게 되는지와 레지스트리를 동적으로 바꿀 때의 동작을 이해해야 합니다. 운영 환경에는 백엔드 레지스트리를, 단위 테스트에는 SimpleMeterRegistry를 주입해 같은 코드를 검증합니다.

전역 정적 레지스트리는 편리하지만, 테스트 사이에 미터가 남고 의존성이 숨겨집니다. 생성자로 레지스트리를 받으면 서비스의 계측 의존성과 생명 주기가 분명해집니다. 라이브러리는 레지스트리 빈을 임의로 새로 만들지 않고, 사용자가 제공한 레지스트리에 등록합니다.

더 깊이: 자식이 없는 복합 레지스트리와 Boot의 전역 레지스트리 연결

복합 레지스트리의 시점 문제는 Micrometer 문서가 구체적으로 설명합니다. CompositeMeterRegistry에 복합이 아닌 실제 레지스트리가 하나도 없는 동안에는 기록해도 아무 효과가 없고, 나중에 자식 레지스트리를 더해도 그 전에 일어난 기록은 새 레지스트리에 반영되지 않습니다. 그래서 문서는 레지스트리와 필터를 모두 더한 뒤에 미터를 등록하라고 권합니다. 정적 Metrics.globalRegistry도 복합 레지스트리이므로, Metrics.counter(...)로 미리 만든 미터는 레지스트리가 붙을 때 각 레지스트리에 추가되지만 그 전의 증가분은 남지 않습니다.

Spring Boot는 이 전역 레지스트리를 기본으로 씁니다. Spring Boot 4.1.2의 MetricsProperties에서 management.metrics.use-global-registry의 기본값은 true여서, 자동 구성된 레지스트리가 Metrics의 전역 정적 레지스트리에 연결됩니다. 덕분에 주입을 받지 않은 라이브러리 코드의 정적 호출도 값을 내보내지만, 같은 JVM에서 여러 테스트 컨텍스트가 돌면 한 전역 레지스트리를 나눠 쓰게 됩니다. 같은 속성의 설명도 테스트에서는 이 값을 false로 두어 테스트 사이의 독립성을 높이라고 안내하며, 이 글처럼 레지스트리를 생성자로 주입받는 코드라면 이 설정과 상관없이 테스트를 격리할 수 있습니다.


공통 태그의 비용

애플리케이션, 영역, 환경처럼 카디널리티가 낮은 차원을 공통 태그로 붙이면, 여러 인스턴스를 아우르는 쿼리가 쉬워집니다. 인스턴스 ID까지 붙이면 시계열 수가 복제본 수만큼 늘지만, 운영에서 자세히 분석할 때는 필요할 수 있습니다. 반면 사용자 ID, 게시글 ID, URL 쿼리, 예외 메시지처럼 값이 사실상 끝없는 데이터는 공통 태그로 절대 쓰지 않습니다.

시계열 예산은 미터마다 허용하는 태그 값 수의 곱으로 계산합니다. 엔드포인트 50 × 메서드 5 × 상태 6 × 영역 3 × 인스턴스 20이면, 하나의 메트릭이 최대 90,000개의 시계열을 만들 수 있습니다. 히스토그램을 쓴다면 버킷 수까지 곱해 메모리와 저장소를 산정합니다. 이 곱은 가능한 태그 조합의 상한일 뿐 모든 조합이 실제로 등록됐다는 뜻은 아니며, 히스토그램의 버킷, 개수, 합계처럼 함께 내보내는 계열은 따로 셉니다.

예산을 넘으면 대시보드가 조금 느려지는 정도로 끝나지 않습니다. 레지스트리가 새 미터를 계속 보관해 힙이 늘고, 수집 페이로드와 백엔드의 수집량이 급증하며, 쿼리가 타임아웃될 수 있습니다. 관측 비용이 늘어나는 동시에 조회까지 실패하게 되는 운영 문제입니다.

entryId 태그가 요청마다 달라져 등록된 미터의 수가 계속 늘어난다면, 카디널리티 누수로 판정하고 그 계측을 릴리스 전에 막습니다. 트래픽은 일정한데 시계열이 많다면, 태그 값의 집합과 미터를 만든 호출 경로를 함께 조사합니다.

cardinality failure 판별
requests=1000, bounded outcomes=3
registered series=1003
unexpected tag key=entryId
result=CARDINALITY_LEAK

결과 태그는 세 값뿐인데 등록된 시계열이 요청 수보다도 많다는 것은, 요청마다 달라지는 entryId가 미터를 계속 새로 만들었다는 신호입니다. 예산을 코드로 고정하면 릴리스 전에 상한을 넘는지 검사할 수 있습니다. 아래 SeriesBudget은 태그마다 허용하는 값의 수를 곱해 최대 시계열 수를 구하고, 한도를 넘으면 SERIES_BUDGET_EXCEEDED로 실패합니다. Math.multiplyExact를 쓰므로 곱이 long 범위를 넘어도 조용히 엉뚱한 값이 되지 않고 ArithmeticException이 납니다.

src/main/java/board/metrics/SeriesBudget.java
package board.metrics;

import java.util.List;

public record SeriesBudget(String meterName, List<Integer> dimensions, long limit) {
    public SeriesBudget {
        dimensions = List.copyOf(dimensions);
        if (meterName.isBlank() || limit < 1) {
            throw new IllegalArgumentException("invalid series budget");
        }
    }

    public long maximumSeries() {
        long total = 1;
        for (int values : dimensions) {
            total = Math.multiplyExact(total, values);
        }
        return total;
    }

    public void verify() {
        if (maximumSeries() > limit) {
            throw new IllegalStateException("SERIES_BUDGET_EXCEEDED");
        }
    }
}

MeterFilter 정책

MeterFilter로 특정 미터를 거부하거나 태그 값의 수를 제한하고, 공통 태그, 이름 변경, 히스토그램 설정을 적용할 수 있습니다. 계측 코드가 값의 범위가 제한되지 않은 ID로 미터를 계속 만든 뒤 백엔드에서 버리는 것보다, 등록 시점에 막고 위반을 메트릭과 로그로 남기는 편이 낫습니다.

필터의 순서도 결과에 영향을 줍니다. 이름을 바꾼 뒤의 거부 조건이 어느 ID를 보는지 테스트하고, 백엔드마다 다른 필터가 메트릭 규칙을 갈라놓지 않도록 공유하는 관례를 둡니다. 애플리케이션이 시작할 때 카탈로그에 없는 태그 키를 찾아내는 레지스트리 리스너를 두는 방법도 있습니다.


타이머 기록

타이머(timer)는 개수와 전체 시간을, 백엔드 설정에 따라 최댓값과 히스토그램을 함께 제공합니다. 시작과 종료를 nanoTime으로 직접 재서 서로 다른 카운터에 기록하면, 예외가 난 분기에서 기록이 빠지기 쉽습니다. record나 Timer.Sample의 시작과 중지를 써서 호출 하나를 정확히 한 번 기록합니다.

src/main/java/board/metrics/ImportTimer.java
package board.metrics;

import java.util.function.Supplier;

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;

public final class ImportTimer {
    private final MeterRegistry registry;

    public ImportTimer(MeterRegistry registry) {
        this.registry = registry;
    }

    public <T> T record(String format, Supplier<T> action) {
        var sample = Timer.start(registry);
        String outcome = "failure";
        try {
            T result = action.get();
            outcome = "success";
            return result;
        } finally {
            Timer timer = Timer.builder("board.import.duration")
                    .description("Post import latency")
                    .tag("format", format)
                    .tag("outcome", outcome)
                    .register(registry);
            sample.stop(timer);
        }
    }
}

finally에서 결과 태그를 정한 타이머에 표본을 멈추므로, 정상 반환과 예외 모두 한 번씩 기록됩니다. format은 markdown이나 json처럼 제한된 열거형에서 가져오며, 파일 이름을 태그로 쓰지 않습니다. 재시도 전체의 지연 시간과 시도별 지연 시간은 이름이나 태그로 서로 구분합니다.


SimpleMeterRegistry로 검증하기

테스트에서는 SimpleMeterRegistry를 주입해 값과 미터 집합을 함께 확인합니다. SimpleMeterRegistry는 값을 메모리에만 두고 어디로도 내보내지 않으므로, 테스트는 registry.get(...)으로 이름과 태그가 맞는 카운터를 찾아 누적값을 읽습니다. 그런 미터가 없으면 get이 예외를 던져 테스트가 실패하므로, 이름이나 태그의 오타도 함께 걸러집니다.

src/test/java/board/metrics/EntryMetricsTest.java
package board.metrics;

import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class EntryMetricsTest {
    @Test
    void recordsBoundedOutcomes() {
        var registry = new SimpleMeterRegistry();
        var metrics = new EntryMetrics(registry);

        metrics.accepted();
        metrics.accepted();
        metrics.rejected();

        assertThat(registry.get("board.entry.processed")
                .tag("outcome", "accepted").counter().count()).isEqualTo(2.0);
        assertThat(registry.get("board.entry.processed")
                .tag("outcome", "rejected").counter().count()).isEqualTo(1.0);
        assertThat(registry.getMeters()).hasSize(2);
    }
}

accepted()를 두 번, rejected()를 한 번 호출했으므로 결과는 다음과 같아야 합니다. 이 테스트는 콘솔에 아무것도 출력하지 않으므로, 아래 블록은 세 단언이 확인하는 값과 그 결론을 한 줄씩 적은 요약입니다. 마지막 단언 hasSize(2)가 통과했다면 두 outcome 값 말고 다른 태그 조합의 미터는 생기지 않은 것입니다.

EntryMetricsTest가 단언하는 값
board.entry.processed{outcome=accepted}=2.0
board.entry.processed{outcome=rejected}=1.0
registered meters=2
result=METER_ID_CONTRACT_PRESERVED

개수만 확인하면, 예상하지 못한 태그로 미터가 하나 더 생긴 회귀를 놓칩니다. 그래서 미터의 수와 태그 키 집합, 설명도 함께 검증합니다. 이 두 미터만 등록한 레지스트리에서 미터가 3개 이상이라면, 기대한 ID 집합과 실제 태그 조합을 대조합니다. 이 단위 예제에는 데이터베이스 트랜잭션이나 커밋 분기가 없으므로, 승인 건수가 기대와 다를 때 커밋 누락을 의심하기보다 호출과 기록 경로를 먼저 확인합니다.


정리

  • 미터는 이름과 태그 집합으로 식별되며, 같은 이름에 타입이나 태그 키 집합을 섞지 않도록 카탈로그로 관리합니다.
  • 레지스트리는 생성자로 주입받아, 운영에서는 백엔드 레지스트리를, 테스트에서는 SimpleMeterRegistry를 씁니다.
  • 시계열 예산은 태그 값 수의 곱으로 계산하며, 사용자 ID나 요청별로 바뀌는 값은 태그로 쓰지 않습니다.
  • 타이머는 Timer.Sample이나 record로 정상과 예외를 모두 한 번씩 기록하고, 테스트에서는 값과 미터 ID 집합을 함께 확인합니다.

연습 문제

1. 가져오기 기능의 메트릭 설계

회원 게시판 가져오기 기능의 허용·거부 카운터와, 형식과 결과를 태그로 갖는 타이머를 설계하세요. 영역과 인스턴스 공통 태그를 포함한 최대 시계열 수를 계산하고, 사용자 ID 태그를 등록하려 하면 거부하는 정책을 만드세요. SimpleMeterRegistry에서 개수, 타이머 개수, 미터 ID 집합을 검증하세요.

해설 보기

메트릭 이름부터 짓지 말고, 관측할 이벤트와 가능한 차원의 집합을 먼저 표로 고정합니다. 백엔드에 맞춘 이름 변환은 레지스트리에 맡깁니다. 미터마다 허용하는 태그 키를 다음처럼 고정해 둘 수 있습니다.

package board.metrics;

import java.util.Set;

public record TagContract(String meter, Set<String> allowedKeys) {
    public TagContract {
        allowedKeys = Set.copyOf(allowedKeys);
    }

    public void requireAllowed(String key) {
        if (!allowedKeys.contains(key)) {
            throw new IllegalArgumentException("UNBOUNDED_OR_UNKNOWN_TAG: " + key);
        }
    }
}

허용하는 키뿐 아니라, 각 키가 실제로 갖는 고유 값의 수도 운영 중에 측정합니다.

2. 예산 검사의 경곗값

SeriesBudget에 형식 2, 결과 2, 영역 3, 인스턴스 4를 차원으로 넣고 limit을 48로 둡니다. 모든 조합이 독립적으로 나올 수 있다고 할 때 verify()는 통과하나요? 여기에 값이 1000개인 entryId 차원을 추가하면 어떻게 되나요? dimensions에 0을 넣어 검사를 통과하는 것은 유효한 예산인가요?

해설 보기

2 × 2 × 3 × 4 = 48이고 비교가 > limit이므로, 같은 48은 통과합니다. entryId 차원을 더하면 48,000이 되어 SERIES_BUDGET_EXCEEDED로 실패합니다.

이 생성자는 각 차원이 양수인지 검사하지 않으므로, 0을 넣으면 곱이 0이 되어 통과해 버립니다. 0이나 음수는 유효한 차원이 아니므로, 양의 고유 값 수만 받는다는 전제를 명시하고 그 입력 검증을 따로 구현해야 합니다. 곱이 long의 범위를 넘는 경우는 Math.multiplyExact가 예외로 드러내 줍니다.

3. 결과에 따라 갈리는 타이머

빈 SimpleMeterRegistry로 만든 ImportTimer에 record("json", ...)을 두 번 호출합니다. 첫 호출의 동작은 값을 반환하고, 둘째 호출의 동작은 IllegalStateException을 던집니다. 레지스트리에는 몇 개의 타이머가 생기고, 각 타이머의 개수는 얼마인가요? 이어서 record("posts-2026.json", ...)을 정상으로 한 번 더 호출하면 무엇이 문제인가요?

해설 보기

finally에서 결과 태그를 정해 타이머를 등록하므로, format=json, outcome=success와 format=json, outcome=failure 두 타이머가 생기고 각각의 개수는 1입니다. 둘째 호출의 예외는 기록된 뒤 호출자에게 그대로 전달됩니다.

posts-2026.json처럼 파일 이름을 형식 자리에 넣으면 format 태그의 값이 하나 더 생겨 새로운 시계열이 만들어집니다. 파일마다 이름이 다르다면 시계열이 끝없이 늘어나는 카디널리티 누수가 되므로, format은 정해진 열거형 값만 받게 막아야 합니다.

다음 글 Prometheus·Grafana에서는 이렇게 기록한 미터를 Prometheus가 수집하고, Grafana가 쿼리해 그래프로 보여 주는 과정을 다룹니다.