Projects/hub-eleven

[리팩토링] 재고 API - (3) 동시성 처리 최적화 - Micrometer 란?

annovation 2026. 7. 30. 22:03

Micrometer란?

💡 Micrometer란?

  • Micrometer는 Java 애플리케이션의 메트릭을 수집하기 위한 계측 라이브러리이다.
  • 메트릭을 직접 장기간 저장하거나 대시보드로 시각화하지는 않는다.
  • Prometheus, Datadog 등 다양한 모니터링 시스템에 연결할 수 있는 공통 API를 제공한다.
  • 동일한 계측 코드를 유지하면서 모니터링 시스템을 변경할 수 있도록 설계되었다.

메트릭(Metric)

  • 애플리케이션 상태를 숫자로 표현한 측정 데이터이다.
  • 예: HTTP 요청 수, HTTP 응답 시간, JVM Heap 사용량, DB Connection 수

Micrometer가 해결하는 문제

  • Prometheus와 Datadog 같은 모니터링 시스템은 서로 다른 계측 방식을 사용한다.
  • Micrometer는 공통 API를 제공하여 애플리케이션 코드의 변경을 줄인다.
  • 이를 통해 특정 모니터링 시스템에 강하게 종속되는 문제인 Vendor Lock-in을 줄일 수 있다.

역할을 구분하면
Micrometer는 애플리케이션 안에서 값을 측정하고, Prometheus는 그 값을 주기적으로 수집·저장하며, Grafana는 저장된 값을 그래프로 보여준다.


📖 공식 문서

  • 문서명: Micrometer Documentation
  • 위치: Introduction
  • 근거: Micrometer는 여러 관측 시스템의 계측 클라이언트 위에 공통 facade를 제공한다.
  • 링크: Micrometer Documentation 열기

💡 Meter란?

Meter는 Micrometer에서 메트릭을 측정하는 기본 단위이다. 무엇을 측정할 것인지에 따라 서로 다른 Meter를 사용하며, 모든 Meter는 MeterRegistry에 등록되어 관리된다.

Meter 측정하는 값 대표 사용 예시
Counter 누적 횟수 회원가입 수, 예외 발생 횟수
Timer 실행 시간과 실행 횟수 HTTP 요청, DB 조회 시간
Gauge 현재 상태 값 메모리 사용량, Connection Pool 수
DistributionSummary 값의 크기와 분포 파일 크기, 응답 크기
LongTaskTimer 진행 중인 긴 작업의 시간 배치, 대용량 데이터 처리
FunctionCounter 함수가 반환하는 누적값 기존 객체의 누적값 노출
FunctionTimer 함수가 반환하는 횟수와 총 시간 기존 객체의 count와 time 노출

즉, Meter는 “무엇을 측정할 것인가”를 나타내는 추상적인 개념이고, Counter와 Timer는 Meter의 구체적인 종류이다.


📖 공식 문서


Timer란?

💡 Timer란?

  • Timer는 Micrometer가 제공하는 Meter 중 하나이다.
  • 짧게 실행되는 작업의 실행 시간(Latency)실행 횟수(Frequency)를 함께 측정한다.
  • 한 번 시간을 재고 끝나는 스톱워치가 아니라, 같은 작업이 여러 번 실행될 때 측정 결과를 계속 누적하는 Meter이다.
  • HTTP 요청 처리, DB 조회, Redis 조회, 외부 API 호출, 락 대기 시간 등에 사용할 수 있다.

Latency(지연 시간)는 작업이 시작된 순간부터 종료될 때까지 걸린 시간이다. 요청 처리에 120ms가 걸렸다면 해당 요청의 latency는 120ms이다.

Frequency(발생 빈도)는 동일한 작업이 몇 번 수행되었는지를 뜻한다. 상품 조회가 1분 동안 500번 실행되었다면 해당 구간의 실행 횟수는 500회이다.


📖 공식 문서

  • 문서명: Micrometer Timers
  • 위치: Concepts → Timers
  • 근거: Timer는 짧은 작업의 latency와 발생 빈도를 측정하기 위한 Meter이다.
  • 링크: Micrometer Timers 열기

💡 Timer가 기록하는 값

설명
count 측정이 완료된 횟수
totalTime 모든 실행 시간을 더한 누적 시간
max 관찰 시간 창 안에서 가장 오래 걸린 실행 시간
Histogram 실행 시간을 구간(bucket)별 누적 개수로 기록한 데이터(설정 시)
Percentile P50, P95, P99 같은 백분위 값(설정 시)

실행 시간이 100ms, 200ms, 300ms였다면 다음과 같이 누적된다.

  • count = 3
  • totalTime = 600ms
  • mean = 600ms ÷ 3 = 200ms
  • max = 300ms

Histogram과 Percentile은 Timer를 등록했다고 항상 자동으로 생성되는 값이 아니다. 분포 통계 설정을 활성화해야 하며, 실제 노출 방식은 사용하는 MeterRegistry 구현체에 따라 달라질 수 있다.


💡 왜 Timer를 사용하는가?

1. 기능 성공 여부와 성능은 서로 다른 문제이다.

두 요청이 모두 성공하더라도 하나는 80ms, 다른 하나는 2,300ms가 걸릴 수 있다. 사용자는 성공 여부뿐 아니라 기다린 시간도 체감하므로 실행 시간을 별도로 측정해야 한다.

2. 로그와 메트릭은 목적이 다르다.

long start = System.currentTimeMillis();
productService.findById(id);
long elapsed = System.currentTimeMillis() - start;
log.info("elapsed={}ms", elapsed);

로그는 특정 요청의 상세 상황을 확인할 때 유용하다. 그러나 전체 요청의 평균, P95, 최대값과 시간에 따른 변화를 보려면 로그를 다시 집계해야 한다. Timer는 반복 실행 결과를 메트릭으로 누적해 이러한 집계를 쉽게 만든다.

3. 시간을 잴 수 있는 작업은 Counter를 중복 생성할 필요가 없는 경우가 많다.

Counter는 횟수만 측정하지만 Timer는 시간과 횟수를 함께 기록한다. 단, timeout처럼 별도의 사건 자체를 명확하게 세어야 한다면 별도 Counter가 유용할 수 있다.

4. 평균만 보면 일부 매우 느린 요청이 가려질 수 있다.

99개가 100ms이고 1개가 10초라면 평균은 약 199ms이다. 평균만 보면 한 사용자가 10초를 기다렸다는 사실이 잘 드러나지 않으므로 P95, P99, max 같은 지표를 함께 본다.


Timer 등록과 MeterRegistry

💡 Timer.builder()로 등록하기

Timer를 사용하려면 먼저 MeterRegistry에 등록해야 한다. Builder는 Timer의 이름, 설명, 태그, 분포 통계 설정을 구성하고, register(meterRegistry)가 실제 Timer를 등록하고 반환한다.

Timer lockWaitTimer = Timer.builder("stock.lock.wait")
    .description("재고 락 획득 대기 시간")
    .tag("lock.type", "redisson")
    .publishPercentiles(0.5, 0.95, 0.99)
    .minimumExpectedValue(Duration.ofMillis(1))
    .maximumExpectedValue(Duration.ofSeconds(3))
    .register(meterRegistry);

위 설정의 의미는 다음과 같다.

설정 설명
stock.lock.wait 메트릭 이름
description() 메트릭의 목적을 설명한다.
tag() 값의 종류가 제한적인 분류 정보를 추가한다.
publishPercentiles() 이 애플리케이션 인스턴스에서 P50, P95, P99를 계산한다.
minimumExpectedValue() 예상 최소 시간을 분포 통계 설정에 제공한다.
maximumExpectedValue() 예상 최대 시간을 분포 통계 설정에 제공한다.
register() 설정된 Timer를 MeterRegistry에 등록하고 반환한다.

예시 이름 주의
http.server.requests는 Spring Boot가 자동으로 제공하는 HTTP 서버 메트릭 이름과 충돌하거나 혼동될 수 있다. 직접 만든 락 메트릭에는 stock.lock.wait처럼 목적이 분명한 별도 이름을 사용하는 편이 안전하다.

Prometheus에서 여러 인스턴스의 분포를 합칠 계획이라면 다음처럼 histogram을 등록할 수 있다.

Timer lockWaitTimer = Timer.builder("stock.lock.wait")
    .description("재고 락 획득 대기 시간")
    .publishPercentileHistogram()
    .serviceLevelObjectives(
        Duration.ofMillis(10),
        Duration.ofMillis(50),
        Duration.ofMillis(100),
        Duration.ofMillis(300),
        Duration.ofSeconds(1)
    )
    .minimumExpectedValue(Duration.ofMillis(1))
    .maximumExpectedValue(Duration.ofSeconds(3))
    .register(meterRegistry);

serviceLevelObjectives()는 “100ms 이하 요청 비율”처럼 팀이 중요하게 보는 경계의 bucket을 추가한다. 이는 percentile 자체를 지정하는 설정이 아니라 특정 시간 기준을 직접 관찰하기 위한 설정이다.


💡 왜 register()가 필요한가?

  • Builder는 아직 Timer의 설정 정보를 구성하는 단계이다.
  • register()를 호출해야 MeterRegistry에서 관리되는 실제 Timer를 얻는다.
  • 같은 이름과 같은 태그 조합으로 다시 등록하면 Registry는 일반적으로 이미 등록된 Meter를 반환한다.
  • 이름은 같아도 태그 값이 다르면 별도의 Meter ID로 관리된다.
Timer getTimer = Timer.builder("product.lookup")
    .tag("method", "GET")
    .register(meterRegistry);

Timer postTimer = Timer.builder("product.lookup")
    .tag("method", "POST")
    .register(meterRegistry);

위 두 Timer는 이름은 같지만 태그가 다르므로 서로 다른 Meter이다.


💡 MeterRegistry란?

MeterRegistry는 Meter를 등록하고 이름과 태그 조합으로 관리하며, Registry 구현체에 맞는 형태로 측정값을 제공하는 중심 컴포넌트이다.

  • Counter, Timer, Gauge 같은 Meter를 생성·등록·조회한다.
  • 같은 Meter ID가 불필요하게 중복 생성되지 않도록 관리한다.
  • 각 Meter가 기록한 현재 누적 상태를 애플리케이션 메모리에서 관리한다.
  • Prometheus Registry라면 scrape 가능한 형식으로 값을 노출할 수 있게 연결한다.

“MeterRegistry가 매 요청마다 Prometheus에 메트릭을 전송한다”라고 이해하면 안 된다. 일반적인 Prometheus 구성에서는 요청 처리 중 Timer가 메모리의 값을 갱신하고, Prometheus가 별도의 주기로 메트릭 엔드포인트를 조회한다.


📖 공식 문서


Percentile과 Histogram

💡 Percentile이란?

Percentile(백분위수)은 여러 측정값을 작은 값부터 정렬했을 때 전체 중 일정 비율이 어느 값 이하에 위치하는지 보여준다.

  • P50 = 10ms: 요청의 약 50%가 10ms 이내에 완료되었다.
  • P95 = 100ms: 요청의 약 95%가 100ms 이내에 완료되었다.
  • P99 = 500ms: 요청의 약 99%가 500ms 이내에 완료되었다.

P95가 100ms라는 말은 평균이 100ms라는 뜻이 아니다. 요청의 약 95%가 100ms 이내에 완료되었고 나머지 약 5%는 그보다 오래 걸렸다는 뜻이다.

중요한 구분
요청 한 건에는 P95가 없다. 요청 한 건에는 10ms, 200ms처럼 실행 시간 하나만 있다. P95는 여러 요청의 실행 시간을 모은 분포에서 계산한다.


💡 publishPercentiles()

publishPercentiles()는 Micrometer가 애플리케이션 인스턴스 내부에서 지정된 percentile을 계산해 내보내도록 설정한다.

Timer.builder("stock.lock.wait")
    .publishPercentiles(0.5, 0.95, 0.99)
    .register(meterRegistry);
  • 0.5는 P50, 0.95는 P95, 0.99는 P99를 의미한다.
  • 사용자별 P95를 구한 뒤 평균 내는 방식이 아니다.
  • 해당 인스턴스가 측정한 여러 요청의 분포에서 percentile을 계산한다.
  • 단일 Spring Boot 인스턴스의 로컬 성능 실험에서 결과를 바로 확인하기 편하다.

한계: 서버별 percentile은 전체 percentile로 다시 합칠 수 없다.

서버 요청 수 P95
서버 A 10,000건 100ms
서버 B 10건 1,000ms

두 P95의 평균 550ms는 전체 10,010건의 P95가 아니다. Percentile은 이미 분포를 하나의 경계값으로 압축한 결과이므로 단순 합산이나 평균으로 원래 분포를 복원할 수 없다.


💡 Bucket과 publishPercentileHistogram()

Bucket은 측정된 시간을 분류하는 시간 구간이다. Prometheus의 classic histogram bucket은 보통 누적형이므로 “100ms 이하” bucket에는 10ms, 50ms 이하 요청도 모두 포함된다.

요청 락 대기 시간
A 8ms
B 40ms
C 80ms
D 250ms
E 700ms
누적 Bucket 포함 요청 개수
10ms 이하 A 1
50ms 이하 A, B 2
100ms 이하 A, B, C 3
300ms 이하 A, B, C, D 4
1초 이하 A, B, C, D, E 5
stock_lock_wait_seconds_bucket{le="0.01"} 1
stock_lock_wait_seconds_bucket{le="0.05"} 2
stock_lock_wait_seconds_bucket{le="0.1"} 3
stock_lock_wait_seconds_bucket{le="0.3"} 4
stock_lock_wait_seconds_bucket{le="1.0"} 5

le는 less than or equal, 즉 “이 값 이하”라는 뜻이다.

publishPercentileHistogram()은 이러한 bucket을 모니터링 시스템에 노출할 수 있게 한다.

Timer.builder("stock.lock.wait")
    .publishPercentileHistogram()
    .register(meterRegistry);
  • 각 애플리케이션 인스턴스는 같은 경계의 bucket별 누적 개수를 제공한다.
  • Prometheus는 같은 bucket끼리 합산할 수 있다.
  • 합산된 전체 분포를 기반으로 다중 인스턴스의 P95, P99 근삿값을 계산할 수 있다.
  • Bucket 경계 사이의 실제 값은 알 수 없으므로 histogram percentile은 근삿값이다.
구분 publishPercentiles() publishPercentileHistogram()
계산 주체 애플리케이션 Prometheus 같은 백엔드
노출 데이터 계산된 P50, P95, P99 Bucket별 누적 개수
서버 간 집계 전체 percentile로 재집계 불가 동일 bucket을 합산해 재집계 가능
적합한 경우 단일 인스턴스 실험, 개별 인스턴스 확인 다중 인스턴스 운영 집계

두 설정을 동시에 사용할 수도 있지만 분포 통계 메모리 비용과 노출 시계열 수가 늘 수 있다. 단일 인스턴스 포트폴리오 실험이라면 publishPercentiles()만으로 시작하고, 다중 인스턴스 및 장기 시계열 운영이 필요해질 때 histogram과 Prometheus를 도입하는 선택도 충분히 타당하다.


📖 공식 문서


Timer 시간 측정 방법

Timer를 등록한 다음에는 실제 작업 시간을 기록해야 한다. 대표적인 방법은 record(), Timer.Sample, @Timed 세 가지이다.

방법 측정 범위 적합한 상황
record() 하나의 코드 블록 Timer가 미리 정해져 있고 시작·종료가 붙어 있을 때
Timer.Sample 개발자가 정한 시작부터 종료까지 결과별 태그 또는 세부 구간 측정이 필요할 때
@Timed 메서드 전체 메서드 경계를 간단히 자동 측정할 때

💡 1. record()

record()는 Timer가 코드 블록 실행 전후의 시간을 측정해 같은 Timer에 기록하는 가장 단순한 방법이다.

Timer databaseTimer = Timer.builder("stock.database.decrease")
    .publishPercentiles(0.5, 0.95, 0.99)
    .register(meterRegistry);

databaseTimer.record(() -> {
    stockRepository.decrease(productId, quantity);
});

반환값이 있는 작업은 recordCallable()을 사용할 수 있다.

Product product = databaseTimer.recordCallable(() ->
    productRepository.findById(productId)
        .orElseThrow()
);

개념적인 동작 순서는 다음과 같다.

  1. 시작 시간을 확인한다.
  2. 현재 호출 스레드에서 전달받은 작업을 실행한다.
  3. 성공하거나 예외로 종료되면 경과 시간을 계산한다.
  4. Timer의 count와 totalTime 등에 결과를 기록한다.

주의: record()는 작업을 비동기로 바꾸지 않는다. 내부 작업이 2초 걸리면 호출한 스레드도 그 작업이 끝날 때까지 실행을 계속하지 못한다. Timer가 추가하는 비용과 실제 측정 대상 작업의 실행 시간은 구분해야 한다.


💡 2. Timer.Sample

Timer.Sample은 시작 시각을 보관했다가, 작업이 끝난 뒤 선택한 Timer에 경과 시간을 기록하는 측정용 객체이다. Sample 자체는 count나 totalTime을 누적하는 Timer가 아니다.

Timer.Sample = 시작 시각을 들고 있는 스톱워치
Timer        = 측정 결과를 계속 누적하는 기록장

Sample은 결과를 알기 전에 측정을 시작하고, 결과를 확인한 뒤 태그를 선택해야 할 때 특히 유용하다.

Timer.Sample waitSample = Timer.start(meterRegistry);
String result = "error";

try {
    boolean acquired = lock.tryLock(
        1,
        3,
        TimeUnit.SECONDS
    );

    result = acquired ? "acquired" : "timeout";

    if (!acquired) {
        throw new StockLockTimeoutException();
    }
} catch (InterruptedException e) {
    result = "interrupted";
    Thread.currentThread().interrupt();
    throw new IllegalStateException("락 획득 중 인터럽트 발생", e);
} finally {
    waitSample.stop(
        meterRegistry.timer(
            "stock.lock.wait",
            "result",
            result
        )
    );
}

위 코드는 tryLock() 직전부터 성공, 타임아웃 또는 인터럽트 결과가 정해질 때까지의 락 대기 시간을 기록한다.

락을 실제로 보유한 전체 시간은 락 획득 직후 Sample을 시작하고 unlock() 완료 뒤 중지한다.

Timer.Sample holdSample = Timer.start(meterRegistry);

try {
    stockRepository.decrease(productId, quantity);
} finally {
    try {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    } finally {
        holdSample.stop(
            meterRegistry.timer("stock.lock.hold")
        );
    }
}

이 측정 범위는 다음과 같다.

락 획득 성공
→ 임계 구역의 재고 차감 작업
→ unlock() 실행 완료
→ stock.lock.hold에 기록
  • Sample을 시작하고 stop()하지 않으면 아무 Timer에도 기록되지 않는다.
  • 예외 경로에서도 정확히 한 번 중지되도록 흐름을 설계해야 한다.
  • 동일한 Sample을 여러 번 중지하는 구조는 피해야 한다.
  • 결과 태그는 acquired, timeout, interrupted처럼 종류가 제한된 값만 사용한다.

💡 3. @Timed

@Timed는 애너테이션이 붙은 메서드의 실행 시간을 AOP로 측정한다. 개발자가 메서드 안에서 Timer의 시작과 종료 코드를 직접 작성하지 않아도 된다는 장점이 있다.

@Timed(
    value = "stock.decrease.total",
    percentiles = {0.5, 0.95, 0.99}
)
public void decreaseStock(Long productId, int quantity) {
    // 락 획득, 재고 차감, 락 반환
}
호출자
  ↓
Spring AOP Proxy
  ↓ 시작 시간 기록
실제 @Timed 메서드 실행
  ↓ 종료 시간 기록
Timer에 경과 시간 누적

@Timed가 실제로 동작하려면 현재 프로젝트의 Spring Boot와 Micrometer 구성에 맞는 애너테이션 계측 설정이 필요하다. Micrometer의 TimedAspect를 직접 사용할 경우에는 다음처럼 Bean으로 등록할 수 있다.

@Configuration
public class MetricsConfig {

    @Bean
    public TimedAspect timedAspect(MeterRegistry meterRegistry) {
        return new TimedAspect(meterRegistry);
    }
}

버전과 구성 확인 필요
애너테이션 계측 활성화 방식과 필요한 의존성은 Spring Boot 및 Micrometer 버전에 따라 달라질 수 있다. 실제 프로젝트에서는 사용 중인 버전의 공식 문서를 기준으로 확인해야 한다.

@Timed의 측정 범위

메서드 진입
→ 락 획득 대기
→ 락 획득
→ 재고 차감
→ 락 반환
→ 메서드 종료

따라서 @Timed 하나만 사용하면 전체 작업이 느리다는 사실은 알 수 있지만, 락 대기와 락 점유 중 어느 구간이 병목인지는 분리하기 어렵다.

Self-invocation 주의

@Service
public class StockService {

    public void processOrder() {
        decreaseStock(); // 같은 객체 내부 호출
    }

    @Timed("stock.decrease.total")
    public void decreaseStock() {
        // 재고 차감
    }
}

같은 객체 안에서 직접 호출하면 Spring AOP Proxy를 거치지 않으므로 @Timed가 적용되지 않을 수 있다. 다른 Spring Bean이 프록시를 통해 호출하는 구조인지 확인해야 한다.

비동기 작업 주의

메서드가 CompletableFuture, Reactor Mono·Flux 등을 실제 완료 전에 반환한다면 단순 메서드 경계 측정과 실제 비동기 완료 시간은 다를 수 있다. 비동기 작업은 사용하는 계측 방식이 완료 신호까지 추적하는지 별도로 확인해야 한다.


💡 record(), Timer.Sample, @Timed 비교

구분 record() Timer.Sample @Timed
범위 코드 블록 직접 지정 메서드 전체
Timer 선택 시작 전 종료 시 선택 가능 애너테이션 설정
동적 결과 태그 직접 분기 필요 적합 세밀한 제어는 제한적
AOP 필요 아니오 아니오
Self-invocation 영향 없음 없음 있음
대표 사례 DB 호출 한 구간 락 대기·점유 시간 서비스 메서드 전체

동시성 처리 구간 측정

락 성능을 분석하려면 락 대기 시간락 점유 시간을 구분해야 한다.

메트릭 측정 범위 권장 방식
stock.decrease.total 메서드 진입부터 종료까지 @Timed 또는 기존 HTTP 자동 계측
stock.lock.wait 락 획득 시도부터 결과 반환까지 Timer.Sample
stock.lock.hold 락 획득부터 unlock 완료까지 Timer.Sample
락 획득 시도
  ↓ stock.lock.wait 시작
다른 요청의 락 반환까지 대기
  ↓ stock.lock.wait 종료
락 획득
  ↓ stock.lock.hold 시작
재고 조회 및 차감
락 반환 완료
  ↓ stock.lock.hold 종료

결과 해석 예시 1

  • 전체 작업 P95 = 900ms
  • 락 대기 P95 = 800ms
  • 락 점유 P95 = 70ms

락을 잡은 뒤의 작업은 비교적 빠르지만, 여러 요청이 같은 락을 얻기 위해 오래 기다리는 상황으로 해석할 수 있다. 같은 키에 트래픽이 집중되는지, 락 범위가 너무 넓은지, 재시도가 경쟁을 늘리는지 확인한다.

결과 해석 예시 2

  • 전체 작업 P95 = 900ms
  • 락 대기 P95 = 20ms
  • 락 점유 P95 = 850ms

락 경쟁은 심하지 않지만 락 안의 DB 쿼리, 트랜잭션 또는 비즈니스 로직이 오래 걸리는 상황으로 해석할 수 있다. 임계 구역에 외부 API 호출이나 불필요한 작업이 포함됐는지 확인한다.

시간만으로는 부족하다.

락 획득 timeout의 횟수나 비율도 함께 확인해야 한다. Timer의 result=timeout count를 이용하거나 사건 자체를 명확히 분리해야 한다면 Counter를 추가할 수 있다.


주의 사항

💡 전체 작업과 세부 작업은 목적에 맞게 측정한다.

  • 하나의 Timer로 전체 시간을 측정하면 어느 단계가 느린지 알기 어렵다.
  • 모든 메서드에 Timer를 붙이면 비용과 복잡도가 커진다.
  • 성능상 중요하거나 외부 의존성이 있거나 실제 병목 가능성이 있는 경계를 우선 측정한다.
  • HTTP 요청 전체 시간은 Spring Boot 자동 계측이 이미 제공하는지 먼저 확인한다.

💡 태그는 값의 종류가 제한적인 경우에만 사용한다.

  • Meter는 이름과 태그 조합으로 식별된다.
  • result=success|failure, HTTP method처럼 값의 종류가 제한된 분류가 적절하다.
  • userId, orderId, traceId, 이메일, 전체 query string은 태그로 사용하지 않는다.
  • 태그 값의 종류가 많아지는 고카디널리티는 Meter 수, 메모리 사용량, 저장 비용을 급격히 늘릴 수 있다.

💡 Timer만으로 병목 원인을 확정할 수 없다.

  • Timer는 어느 구간이 얼마나 느린지 보여준다.
  • 왜 느린지는 로그, 분산 추적, 프로파일러, SQL 실행 계획, 스레드 덤프, Connection Pool 상태 등을 함께 분석해야 알 수 있다.
  • Timer는 성능 저하를 발견하고 범위를 좁히는 도구이지 원인을 자동 진단하는 도구는 아니다.

💡 현재 프로젝트 범위에 맞는 선택을 한다.

  • 단일 Spring Boot 인스턴스에서 k6로 통제된 성능 실험을 한다면 publishPercentiles(0.5, 0.95, 0.99)로도 목적을 달성할 수 있다.
  • 다중 인스턴스의 값을 통합하거나 시간에 따른 변화를 저장하고 경보를 만들 필요가 생기면 histogram과 중앙 메트릭 시스템 도입 가치가 커진다.
  • MSA라고 해서 Prometheus라는 특정 제품이 필수인 것은 아니다. 다만 분산된 서비스의 메트릭, 로그, 트레이스를 중앙에서 관찰할 체계는 중요하다.

핵심 정리

  1. Timer는 짧은 작업의 실행 시간과 실행 횟수를 함께 기록한다.
  2. MeterRegistry는 이름과 태그 조합으로 Meter를 등록하고 관리한다.
  3. publishPercentiles()는 한 인스턴스가 자신의 측정 분포에서 percentile을 계산한다.
  4. publishPercentileHistogram()은 bucket별 누적 개수를 노출해 여러 인스턴스의 분포를 합칠 수 있게 한다.
  5. record()는 하나의 코드 블록, Timer.Sample은 개발자가 정한 세부 구간, @Timed는 메서드 전체 측정에 적합하다.
  6. 락 성능은 대기 시간과 점유 시간을 분리해야 원인을 좁힐 수 있다.
  7. Timer는 병목을 보여주지만 원인을 자동으로 확정하지는 않는다.

참고 자료

  1. Micrometer Documentation
  2. Micrometer Timers
  3. Micrometer Histograms and Percentiles
  4. Micrometer Registry
  5. Baeldung Micrometer Guide
  6. Micrometer Timer 입문