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

안동민 개발노트

본문 시작
15장 : Actuator와 관측

카운터·타이머·게이지

게시글 등록·취소·가져오기 지연 시간·대기열 깊이를 카운터·타이머·게이지에 대응시키고 결과 태그·예외 경로·게이지 참조·재시도 중복·SimpleMeterRegistry 검증으로 업무 메트릭의 의미를 보존합니다.

CPU와 힙은 프레임워크가 자동 측정할 수 있지만, “게시글이 접수됐는가”, “검증에서 거부됐는가”, “대기열이 위험한가”는 도메인만 압니다. 업무 메트릭은 장애가 기술 리소스에 나타나기 전에 사용자의 결과 변화를 보여 줍니다. 다만 이벤트와 상태를 구분하지 않으면 카운터를 재고처럼 읽거나 게이지로 처리량을 계산하는 오류가 생깁니다.


카운터 누적

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

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

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를 태그로 확장하지 않습니다.

타이머 완료 기록

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

재시도가 있는 경우 사용자가 느낀 전체 시간과 각 시도 시간을 구분합니다. 전체 타이머가 재시도 애스펙트 밖, 시도 타이머가 트랜잭션 어댑터 안에 있어야 의도한 개수가 나옵니다. 타임아웃 취소와 클라이언트 연결 중단도 결과 정책에 포함합니다.

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));
        }
    }
}

형식은 호출자가 임의 문자열로 넣지 않고 열거형에서 전달합니다. 모든 인스턴스에 클라이언트 측 백분위수를 발행할지, 히스토그램 버킷을 백엔드에서 집계할지 트래픽과 시계열 예산으로 정합니다.

게이지 상태 관측

대기열 깊이, 사용 중인 연결 수, 현재 재고처럼 오르내리는 값을 게이지로 봅니다. 게이지 갱신을 위해 별도 숫자를 복사하면 실제 상태와 어긋날 수 있으므로 가능하면 상태를 소유한 객체에서 값을 직접 읽습니다. 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();
    }
}

메모리 기반 개수가 영속적 대기열과 같다는 보장이 없다면 대기열 클라이언트의 공식 깊이를 표본 추출합니다. 비용이 큰 쿼리를 매 수집마다 실행하지 않고 캐시된 스냅샷의 경과 시간 메트릭도 함께 제공합니다.

분포·장기 작업 계측

지속 시간이 아닌 페이로드 바이트나 배치의 기록 수에는 DistributionSummary가 적합합니다. 긴 가져오기 작업이 아직 끝나지 않았을 때 일반 타이머에는 완료 기록이 없으므로 LongTaskTimer로 활성 작업 수와 현재 지속 시간을 볼 수 있습니다. 하나의 작업에 여러 미터를 붙일 때 측정 목적이 중복되지 않는지 확인합니다.

배치 크기를 원시 사용자 값 태그로 붙이지 않고 히스토그램으로 분포를 기록합니다. SLO 경계는 의미 있는 임계값에 맞추고 너무 많은 버킷을 피합니다. 페이로드에 개인정보가 없어도 크기 측정이 테넌트 행동을 유추할 수 있는지 접근 정책을 검토합니다.

계측 책임

@Timed@Counted는 공개 프록시 메서드에 반복되는 계측을 선언하기 좋고, 수동 API는 결과·도메인 경계를 정밀하게 선택하기 좋습니다. 동일 메서드에 둘을 중복 적용하거나 컨트롤러와 서비스가 같은 논리 이벤트를 각각 개수하지 않습니다.

메트릭 카탈로그에는 논리 이벤트의 기록 주체를 명시합니다. HTTP 요청 수는 프레임워크가, 게시글 승인 이벤트는 애플리케이션 서비스가, 대기열 깊이는 대기열 어댑터가, 메트릭 전송 실패는 전송 어댑터가 기록합니다. 측정 코드의 실패가 업무 결과를 바꾸지 않도록 레지스트리 예외 정책도 확인합니다.

계측 테스트

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);
    }
}

예외 분기에서 타이머 개수가 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

관측 데이터가 틀리면 잘못된 운영 결정을 만듭니다. 메트릭 파이프라인이 일부 전송 실패를 허용하더라도 의미의 정확도와 데이터 최신성은 테스트해야 합니다.

연습 문제

회원 게시판 허용·취소·거부 이벤트, 가져오기 전체·개별 시도 지연 시간, 대기 게이지를 계측하세요. 롤백, 예외, 재시도 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");
        }
    }
}

업무 이벤트가 영속적해야 한다면 메트릭 증가 자체보다 소스 이벤트 저장을 우선합니다.