본문 바로가기

카운터·타이머·게이지

게시판의 완료 사건·처리 시간·현재 대기열을 카운터·타이머·게이지에 대응시키고 실패와 재시도에서도 의미를 보존합니다.

CPU와 힙은 프레임워크가 자동으로 측정할 수 있지만, 게시글이 접수됐는지, 검증에서 거부됐는지, 대기열이 위험한 수준인지는 도메인만 압니다. 업무 메트릭은 장애가 기술 리소스에 드러나기 전에, 사용자가 겪는 결과의 변화를 먼저 보여 줍니다.

다만 사건(event)과 상태(state)를 구분하지 않으면, 카운터를 재고처럼 읽거나 게이지로 처리량을 계산하는 실수가 생깁니다. 이 글에서는 확정된 사건은 카운터로, 완료된 작업의 시간은 타이머로, 현재의 상태는 게이지로 대응시키고, 실패와 재시도에서도 그 의미를 지키는 방법을 다룹니다.

METERS · EVENT TIME AND STATE

사건·완료 시간·현재 상태를 서로 다른 미터로 기록한다

먼저 논리 사실·기록 소유자·확정 시점을 정합니다. rollback·retry·비동기 완료에서도 그 의미를 유지하고 동일 사건을 두 계층에서 중복 세지 않습니다.

관측하려는 사실에 맞는 세 미터의 선택각 화살표는 관측하려는 사실과 선택한 미터의 대응이다. 세 쌍은 비교이며 Counter→Timer→Gauge 순차 처리 사슬이 아니다. 누적 사건은 현재 재고가 아니고 Timer의 미터 ID 수와 표본 개수를 구별하며 Gauge는 표본 사이 중간 변화를 보존하지 않는다. 같은 6노드3관계를 모바일에서도 분리해 표시한다.
확정된 사건
연결 관계확정된 사건 → Counter · 누적 개수
Counter누적 개수
완료된 시간
연결 관계완료된 시간 → Timer · 개수·시간
Timer개수·시간
현재 대기 상태
연결 관계현재 대기 상태 → Gauge · 표본 시점 값
Gauge표본 시점 값

Counter · 확정된 사건

accepted·cancelled·rejected는 별도 누적 사건입니다. 취소돼도 과거 accepted를 줄이지 않으며 누적값 차이를 현재 재고로 가정하지 않습니다.

성공 확정 시점에 맞춰 기록합니다. 커밋 후 콜백은 프로세스 장애 유실을, 영속 outbox 투영은 전달/중복 계약을 검토합니다.

Timer · 완료와 재시도

정상/실패 모두 종료하고 비동기는 실제 콜백 완료까지 측정합니다. 전체 시간과 시도별 시간을 별도 미터로 둡니다.

완료된 시도가 3개인 예: 전체 타이머 1회, 시도 타이머 3회입니다. 본문 Callable wrapper 자체는 retry·TX·비동기 연결을 구현하지 않습니다.

Gauge · 현재 소유 상태

대기열 깊이를 상태 소유자에게서 표본 추출합니다. 강한 참조가 사라지면 약한 참조 게이지는 NaN/누락될 수 있습니다.

메모리 개수를 영속 대기열 깊이로 단정하지 않습니다. 캐시 스냅샷은 sample age도 제공하고 표본 사이 순간 변화는 보존하지 않습니다.

분포와 아직 끝나지 않은 작업

DistributionSummary는 바이트·배치 수 분포, LongTaskTimer는 진행 중 작업 수/시간을 봅니다. 일반 Timer는 완료 전 표본이 없습니다.

값을 원시 ID 태그로 만들지 않고 제한된 결과·형식과 필요한 버킷만 사용합니다.

HTTP 수는 프레임워크, 업무 사건은 서비스, 대기 깊이는 어댑터, 전송 실패는 전송 어댑터가 소유합니다. 값·미터 ID·예외/재시도 개수·최신성과 업무 영향 정책을 함께 검증합니다.

그림의 세 화살표는 관측하려는 사실과 그에 맞는 미터를 짝지은 것입니다. 확정된 사건은 누적 개수를 세는 카운터로, 완료된 작업의 시간은 개수와 시간을 함께 갖는 타이머로, 현재 대기 상태는 표본을 뜨는 시점의 값을 기록하는 게이지로 봅니다. 세 쌍은 나란히 비교한 것이며, 카운터에서 타이머, 게이지로 이어지는 처리 순서가 아닙니다. 아래 칸들은 각 미터에서 주의할 점과, 분포와 진행 중인 작업을 재는 미터, 그리고 계층마다 무엇을 기록하는지를 정리합니다.


카운터의 누적

게시글이 등록되면 승인 카운터를 올리고, 나중에 취소되더라도 승인 건수를 줄이지 않습니다. 취소는 별도의 카운터로 기록합니다. 두 누적값의 차이로 현재 활성 기록의 수를 구할 수는 없습니다. 프로세스 재시작, 이벤트 전달의 유실, 복구 작업이 끼어들기 때문입니다.

카운터를 올리는 위치는 업무 결과가 확정되는 시점과 맞춥니다. 트랜잭션을 시작할 때 올리면 롤백된 요청도 성공으로 세고, 컨트롤러가 응답한 뒤 비동기로 올리면 프로세스 장애 때 유실됩니다. 영속적인 아웃박스 이벤트를 메트릭 처리기가 읽어 반영하거나, 커밋 후 콜백을 쓰는 등, 필요한 정확도와 비용에 맞춰 방식을 고릅니다.

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

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

public final class EntryOutcomeMetrics {
    private final Counter accepted;
    private final Counter cancelled;
    private final Counter rejected;

    public EntryOutcomeMetrics(MeterRegistry registry) {
        accepted = counter(registry, "accepted");
        cancelled = counter(registry, "cancelled");
        rejected = counter(registry, "rejected");
    }

    private static Counter counter(MeterRegistry registry, String outcome) {
        return Counter.builder("board.entry.outcomes")
                .description("Finalized 회원 게시판 entry outcomes")
                .tag("outcome", outcome)
                .register(registry);
    }

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

결과 값은 세 가지로 제한되어 있으며, 검증 메시지나 게시글 ID를 태그로 넓히지 않습니다.


타이머의 완료 기록

타이머는 지속 시간뿐 아니라 호출 개수도 함께 가집니다. 그러니 같은 작업에 별도의 카운터가 꼭 필요한지 검토해 중복 계측을 줄입니다. 정상과 실패는 제한된 결과 태그로 나누고, 예외가 나도 표본을 멈춥니다. 비동기 작업은 콜백이 완료될 때 멈추며, 스레드가 바뀐다는 이유로 시작 시각을 스레드 로컬에만 두지 않습니다.

재시도가 있다면 사용자가 느낀 전체 시간과 시도마다의 시간을 구분합니다. 예를 들어 총 세 번의 시도가 100 ms 실패, 200 ms 실패, 80 ms 성공이고 시도 사이의 대기가 각각 20 ms라고 해 봅시다. 다른 부가 비용을 빼면 전체 시간은 100 + 20 + 200 + 20 + 80 = 420 ms이고, 시도 시간의 합은 380 ms입니다. 전체 타이머의 완료 표본은 1회, 시도 타이머의 표본은 3회입니다. 이름, 형식, 공통 태그가 고정되고 결과만 달라진다면 시도 타이머의 미터 ID는 failure와 success 두 개이며, 두 번의 실패 표본은 같은 미터에 쌓입니다.

전체 타이머는 재시도 루프를 바깥에서 한 번 감싸고, 시도 타이머는 반복 하나하나를 감쌉니다. 트랜잭션 어댑터 안쪽의 콜백만 재면 콜백의 실행 시간이 되고, 어댑터 바깥에서 재면 트랜잭션의 시작과 커밋·롤백 같은 종료 처리까지 포함한 시간이 됩니다. 타임아웃으로 인한 취소와 클라이언트 연결 중단을 어떤 결과로 기록할지도 정책에 포함합니다.

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

import java.util.concurrent.Callable;

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

public final class PostImportMetrics {
    private final MeterRegistry registry;

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

    public <T> T measure(String format, Callable<T> operation) throws Exception {
        var sample = Timer.start(registry);
        String outcome = "failure";
        try {
            T result = operation.call();
            outcome = "success";
            return result;
        } finally {
            sample.stop(Timer.builder("board.import.latency")
                    .tag("format", format)
                    .tag("outcome", outcome)
                    .publishPercentileHistogram()
                    .register(registry));
        }
    }
}

format은 호출자가 임의의 문자열로 넘기지 않고 열거형에서 가져옵니다. 이 타이머는 publishPercentileHistogram()으로 히스토그램 버킷을 발행하므로, Prometheus·Grafana의 p95 쿼리를 쓸 수 있습니다. 모든 인스턴스에서 클라이언트 쪽 백분위수를 발행할지, 히스토그램 버킷을 백엔드에서 집계할지는 트래픽과 시계열 예산으로 정합니다.


게이지로 상태 관측하기

대기열의 깊이, 사용 중인 연결 수, 현재 재고처럼 오르내리는 값은 게이지(gauge)로 봅니다. 게이지를 갱신하려고 숫자를 따로 복사해 두면 실제 상태와 어긋날 수 있으므로, 가능하면 그 상태를 가진 객체에서 값을 직접 읽습니다. Micrometer의 게이지는 대상 객체를 약한 참조로 보관하므로, 애플리케이션이 강한 참조를 유지하지 않으면 측정값이 NaN이 될 수 있습니다.

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

import java.util.concurrent.atomic.AtomicInteger;

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

public final class PendingEntries {
    private final AtomicInteger count = new AtomicInteger();

    public PendingEntries(MeterRegistry registry) {
        Gauge.builder("board.entries.pending", count, AtomicInteger::get)
                .description("Entries waiting for processing")
                .baseUnit("entries")
                .register(registry);
    }

    public void accepted() {
        count.incrementAndGet();
    }

    public void completed() {
        count.updateAndGet(value -> Math.max(0, value - 1));
    }

    public int current() {
        return count.get();
    }
}

메모리 안의 개수가 영속적인 대기열과 같다는 보장이 없다면, 대기열 클라이언트가 제공하는 공식 깊이를 표본으로 읽습니다. Micrometer의 게이지 설명처럼 게이지는 관측하는 순간의 값만 기록하고, 표본 사이의 중간 값은 보존하지 않습니다. PendingEntries가 count 필드를 강하게 참조하더라도, PendingEntries 자체를 애플리케이션이 계속 보관해야 그 수명이 유지됩니다.

비용이 큰 쿼리를 수집할 때마다 실행하지 말고, 캐시한 스냅샷을 쓰되 그 스냅샷이 얼마나 오래됐는지를 나타내는 메트릭도 함께 제공합니다.


분포와 오래 걸리는 작업의 계측

지속 시간이 아닌 페이로드의 바이트 수나 배치의 기록 수에는 DistributionSummary가 알맞습니다. 오래 걸리는 가져오기 작업이 아직 끝나지 않았다면 일반 타이머에는 완료 기록이 없으므로, LongTaskTimer로 진행 중인 작업 수와 지금까지의 지속 시간을 볼 수 있습니다. 작업 하나에 미터를 여러 개 붙일 때는 측정 목적이 겹치지 않는지 확인합니다.

배치 크기를 사용자 값 그대로 태그로 붙이지 말고, 히스토그램으로 분포를 기록합니다. SLO 기준은 의미 있는 임계값에 맞추고, 버킷을 너무 많이 두지 않습니다. 페이로드에 개인정보가 없더라도, 크기를 측정한 값으로 특정 테넌트의 행동을 짐작할 수 있는지 접근 정책을 검토합니다.

더 깊이: 타이머의 최댓값과 백분위수는 시간이 지나면 잊힙니다

타이머와 분포 요약의 통계 가운데 일부는 누적되지 않고 시간이 지나면 사라집니다. Micrometer 1.17.2의 DistributionStatisticConfig 설명에 따르면 최댓값, 백분위수, 히스토그램 개수는 최근 표본에 더 큰 무게를 두도록 시간이 지나면 감쇠합니다. 표본은 링 버퍼에 쌓이고, 버퍼는 expiry마다 회전하며, 버퍼가 bufferLength개이므로 한 표본이 완전히 잊히기까지는 expiry × bufferLength가 걸립니다. 기본값은 expiry가 2분, bufferLength가 3이므로, 오래 걸린 요청 하나가 max에서 완전히 사라지기까지 대략 6분이 걸립니다.

예외도 있습니다. 같은 설명은 누적 히스토그램 버킷을 기대하는 시스템에서는 히스토그램 개수가 누적된다고 적고 있어서, Prometheus가 수집하는 _bucket 계열은 감쇠하지 않고 계속 늘어납니다. 그래서 대시보드의 max가 몇 분 뒤에 낮아졌다고 그 사이에 느린 요청이 없었다고 볼 수 없고, 구간의 최악값이 필요하다면 버킷이나 로그, 트레이스에서 확인해야 합니다. Spring Boot에서는 미터 이름별로 management.metrics.distribution.expiry와 management.metrics.distribution.buffer-length로 이 값을 바꿀 수 있습니다.


계측을 맡는 곳

@Timed와 @Counted는 공개 프록시 메서드에 반복되는 계측을 선언하기에 좋고, 수동 API는 결과와 도메인의 의미에 맞춰 측정 지점을 정밀하게 고르기에 좋습니다. 같은 메서드에 둘을 함께 적용하거나, 컨트롤러와 서비스가 같은 논리적 사건을 각자 세지 않습니다.

메트릭 카탈로그에는 논리적 사건마다 기록하는 쪽을 적어 둡니다. HTTP 요청 수는 프레임워크가, 게시글 승인 사건은 애플리케이션 서비스가, 대기열의 깊이는 대기열 어댑터가, 메트릭 전송의 실패는 전송 어댑터가 기록합니다.

측정 코드의 실패가 업무 결과를 바꾸지 않는지도 확인합니다. PostImportMetrics.measure는 finally에서 타이머를 등록하고 sample.stop을 실행하므로, 정상 경로와 업무 예외 경로 모두에서 기록을 시도합니다. 하지만 그 계측 코드 자체가 예외를 던지면, 원래의 업무 예외를 가리거나 성공한 반환을 실패로 바꿀 수 있습니다. 계측 실패를 격리하는 정책은 따로 설계해야 하며, 이 래퍼가 저절로 해결해 주지 않습니다.


계측 테스트

게이지는 상태가 오르내린 뒤 마지막 값과 미터 개수를 함께 확인합니다. 게이지는 값을 따로 쌓아 두지 않고 읽을 때마다 연결된 count를 다시 보므로, 테스트는 다섯 번의 호출이 모두 끝난 뒤의 값 하나만 읽습니다. 그래서 중간에 값이 2였던 순간은 이 단언으로 확인되지 않습니다.

src/test/java/board/metrics/PendingEntriesTest.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 PendingEntriesTest {
    @Test
    void gaugeTracksCurrentStateWithoutGoingNegative() {
        var registry = new SimpleMeterRegistry();
        var pending = new PendingEntries(registry);

        pending.accepted();
        pending.accepted();
        pending.completed();
        pending.completed();
        pending.completed();

        assertThat(registry.get("board.entries.pending").gauge().value())
                .isZero();
        assertThat(registry.getMeters()).hasSize(1);
    }
}

두 번 접수하고 세 번 완료했으므로 값은 0이고, 마지막 completed()는 0 아래로 내려가지 않습니다. 타이머는 예외 분기에서 개수가 1인지, 재시도가 두 번이면 시도 타이머 2와 전체 타이머 1인지 확인합니다. 미터의 수가 계속 늘어난다면 값의 범위가 제한되지 않은 태그나 반복해서 만들어지는 동적 이름을 찾습니다.


계측의 신뢰도

관측 데이터가 틀리면 잘못된 운영 판단으로 이어집니다. 다음과 같은 실패 신호는 메트릭의 의미가 깨졌다는 뜻입니다.

업무 metric 품질 실패
accepted transaction rollback + counter increment=1 -> PRECOMMIT_OVERCOUNT
one request + manual counter + @Counted=2 -> DUPLICATE_INSTRUMENTATION
gauge value=NaN -> GAUGE_REFERENCE_LOST
attempt timer=3, total timer=3 -> RETRY_BOUNDARY_MISPLACED
series for entryId grows each call -> CARDINALITY_LEAK

메트릭 파이프라인이 일부 전송 실패를 허용하더라도, 값의 의미가 정확한지와 데이터가 최신인지는 테스트해야 합니다.


정리

  • 카운터는 확정된 사건의 누적이며, 취소 같은 반대 사건은 별도 카운터로 세고 두 값의 차를 현재 상태로 읽지 않습니다.
  • 타이머는 개수와 시간을 함께 가지며, 재시도가 있으면 전체 시간과 시도별 시간을 다른 미터로 나눕니다.
  • 게이지는 상태를 가진 객체에서 표본 시점의 값을 읽으며, 대상 객체의 강한 참조를 유지해야 하고 표본 사이의 변화는 남지 않습니다.
  • 논리적 사건마다 기록하는 계층을 하나로 정하고, 계측 코드의 실패가 업무 결과를 바꾸지 않게 합니다.

연습 문제

1. 게시판의 업무 메트릭 계측

회원 게시판의 허용, 취소, 거부 사건과, 가져오기의 전체·시도별 지연 시간, 대기 게이지를 계측하세요. 롤백, 예외, 재시도 3회, 게이지 대상 객체의 가비지 컬렉션, ID 태그의 유입을 각각 재현하고, 예상되는 미터 개수와 시계열 수를 표로 만드세요.

해설 보기

각 미터 옆에 사건인지 상태인지, 어느 계층이 기록하는지, 언제 값이 확정되는지를 먼저 적습니다. 같은 논리적 완료를 두 계층에서 중복으로 세지 않도록, 기록하는 쪽을 다음처럼 명시해 둘 수 있습니다.

package board.metrics;

public record MeterOwnership(String event, String owner, String completionPoint) {
    public MeterOwnership {
        if (event.isBlank() || owner.isBlank() || completionPoint.isBlank()) {
            throw new IllegalArgumentException("metric ownership must be explicit");
        }
    }
}

업무 사건이 영속적이어야 한다면, 메트릭을 올리는 것보다 원본 사건을 저장하는 일을 먼저 챙깁니다.

2. 재시도 두 번의 시간

총 두 번의 시도가 50 ms 실패, 10 ms 대기, 70 ms 성공으로 끝났습니다. 다른 부가 비용은 0입니다. 전체 시간, 시도 시간의 합, 전체 타이머와 시도 타이머의 완료 표본 수, 시도 타이머의 미터 ID 수를 구하세요.

해설 보기

전체 시간은 50 + 10 + 70 = 130 ms, 시도 시간의 합은 120 ms입니다. 전체 타이머의 표본은 1회, 시도 타이머의 표본은 2회이고, 결과 태그가 failure와 success로 갈리므로 시도 타이머의 미터 ID는 2개입니다.

미터 ID의 개수와 기록된 호출의 개수는 다른 숫자입니다. 이 계산은 재시도 루프를 따로 구성했다는 조건에서 나온 값이며, PostImportMetrics 자체가 재시도나 대기를 구현하지는 않습니다.

3. 게이지가 놓치는 값

PendingEntriesTest처럼 두 번 접수하고 세 번 완료하면, 상태는 어떤 순서로 바뀌나요? 수집기가 마지막 상태에서만 표본을 떴다면 중간의 최댓값을 알 수 있나요? 대기열의 최고 깊이를 꼭 알아야 한다면 무엇을 더 기록해야 하나요?

해설 보기

상태는 0, 1, 2, 1, 0, 0 순서로 바뀝니다. 마지막의 0만 수집했다면 중간에 2까지 올라갔다는 사실은 복원할 수 없습니다. 게이지는 표본을 뜨는 순간의 값만 남기기 때문입니다.

최고 깊이가 필요하다면 접수와 완료를 카운터로 따로 세어 증가율을 보거나, 상태를 가진 쪽에서 구간별 최댓값을 직접 관리해 내보내야 합니다. 수집 간격을 줄이는 방법도 있지만, 그래도 표본 사이의 순간적인 값은 놓칠 수 있습니다.

다음 글 대시보드와 경보 설계에서는 이렇게 모은 메트릭으로 사용자 영향을 보여 주는 대시보드와, 사람이 대응해야 할 때만 울리는 경보를 설계합니다.