본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
15장 : Actuator와 관측

Micrometer 레지스트리

미터 이름·태그·기반 단위·설명을 규칙으로 설계하고 카운터·타이머를 MeterRegistry에 등록하며 복합 레지스트리·공통 태그·MeterFilter·카디널리티 예산을 실행 테스트로 검증합니다.

애플리케이션이 Prometheus 클라이언트 API에 직접 결합하면 백엔드를 바꾸거나 테스트 레지스트리를 쓰기 어렵습니다. Micrometer는 미터를 만드는 계측 API와 백엔드별 레지스트리를 분리합니다. 코드는 MeterRegistry에 의미 있는 측정을 기록하고, Prometheus·OTLP 같은 구현이 각 백엔드의 이름 규칙과 전송 형식으로 변환합니다. 추상화가 메트릭 의미까지 대신 정해 주지는 않으므로 이름과 태그는 애플리케이션 계약으로 설계해야 합니다.


미터 식별자

board.entry.processed라는 이름만 같아도 outcome=acceptedoutcome=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();
    }
}

카운터는 음수를 기록하지 않고 프로세스 수명 동안 누적합니다. 현재 대기열 크기처럼 줄어드는 상태를 카운터 두 개의 차로 즉석 복원할지 게이지로 직접 볼지 데이터 유실과 초기화를 고려해 결정합니다.

레지스트리 백엔드

애플리케이션 컨텍스트에는 레지스트리가 하나 이상 있을 수 있습니다. 복합 레지스트리는 자식 레지스트리가 추가되기 전 기록의 의미와 동적 레지스트리 교체를 이해해야 합니다. 운영 환경은 백엔드 레지스트리를, 단위 테스트는 SimpleMeterRegistry를 주입해 같은 코드를 검증합니다.

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

공통 태그 비용

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

시계열 예산은 미터별 허용 태그 값의 곱으로 계산합니다. 엔드포인트 50 × 메서드 5 × 상태 6 × 영역 3 × 인스턴스 20이면 하나의 메트릭 계열이 최대 90,000개 시계열을 만들 수 있습니다. 실제 히스토그램 버킷까지 곱해 메모리와 저장소를 산정합니다.

예산을 초과한 상태는 대시보드가 조금 느려지는 정도가 아닙니다. 레지스트리가 계속 새 미터를 보관해 힙이 늘고, 수집 페이로드와 백엔드 수집량이 급증하며, 쿼리가 타임아웃될 수 있습니다. 이것은 관측 비용 증가와 조회 실패를 동시에 만드는 운영 문제입니다. entryId 태그가 요청마다 달라져 등록된 미터 수가 지속 증가하면 CARDINALITY_LEAK으로 판정하고 해당 계측을 릴리스 전에 차단합니다. 시계열이 많은데 트래픽이 고정되어 있다면 값 집합과 미터 생성 스택을 함께 조사합니다.

cardinality failure 판별
requests=1000, bounded outcomes=3
registered series=1003
unexpected tag key=entryId
result=CARDINALITY_LEAK
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를 보는지 테스트하고, 백엔드마다 다른 필터가 메트릭 규칙을 갈라놓지 않게 공유 관례를 둡니다. 애플리케이션 시작 시 카탈로그에 없는 태그 키를 탐지하는 레지스트리 리스너도 고려합니다.

타이머 기록

타이머는 개수와 전체 시간, 백엔드 설정에 따라 최댓값과 히스토그램을 제공합니다. 시작과 종료를 수동 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);
        }
    }
}

형식은 markdown|json처럼 제한된 열거형에서 가져옵니다. 파일 이름을 태그로 쓰지 않습니다. 재시도 전체 지연 시간과 시도별 지연 시간은 이름 또는 태그 계약을 분리합니다.

SimpleMeterRegistry 검증

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=2.0, rejected=1.0, registry meter count=2입니다. 미터 개수가 3 이상이면 동일 이름에 예기치 않은 태그 조합이 추가된 것이고, 승인 건수가 1이면 커밋 분기 중 한 곳에서 기록이 빠진 것입니다. 측정값과 미터 ID의 카디널리티를 함께 확인합니다.

SimpleMeterRegistry 실행 결과
board.entry.processed{outcome=accepted}=2.0
board.entry.processed{outcome=rejected}=1.0
registered meters=2
result=METER_ID_CONTRACT_PRESERVED

연습 문제

회원 게시판 가져오기의 허용·거부 카운터와 형식·결과 타이머를 설계하세요. 영역·인스턴스 공통 태그를 포함한 최대 시계열 수를 계산하고 사용자 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);
        }
    }
}

허용 키뿐 아니라 각 키의 최대 고유 값 수도 운영 중 측정합니다.