한 줄 정의
핵심 메시지
자바 진영의 관측성 구현은 마이크로미터(metrics SPI) + 프로메테우스(time series DB) + 오픈텔레메트리(통합 계측)의 조합이 사실상 표준입니다. 마이크로미터는 벤더 중립적인 퍼사드로서 애플리케이션 코드가 특정 지표 백엔드에 묶이지 않도록 추상화하고, 프로메테우스는 서버 풀(스크레이핑) 방식으로 지표를 수집해 PromQL로 질의할 수 있게 합니다.
중요한 점은 이 기술들이 서로 다른 적용 범위를 가진다는 사실입니다. 실제 시스템에서는 일부 또는 전부를 함께 써야 완전한 관측성이 만들어집니다. 그리고 관측성에서 가장 어려운 문제는 기술 선택이 아니라 배포 복잡성을 어떻게 관리하느냐입니다.
쉽게 말하면
애플리케이션 코드에서 지표를 기록할 때 프로메테우스 SDK를 직접 호출하면, 나중에 데이터독으로 바꿀 때 모든 호출 지점을 다시 손봐야 합니다. 마이크로미터는 그 사이에 한 겹 끼는 인터페이스입니다. 개발자는 registry.counter("battles.total").increment()만 호출하면 되고, 백엔드 교체는 메이븐 의존성과 설정만 바꾸면 됩니다.
프로메테우스는 그 지표들이 흘러가서 모이는 시계열 데이터베이스입니다. 각 서비스가 /actuator/prometheus 같은 HTTP 엔드포인트로 지표를 노출하면, 프로메테우스가 주기적으로 들러서 긁어옵니다(스크레이핑).
왜 중요한가?
배포 복잡성이 진짜 문제다
관측성에서 가장 어려운 일은 “어떤 SDK를 쓸까”가 아니라 여러 기술이 함께 돌아가는 환경을 관리하는 것입니다. 마이크로미터는 자바 애플리케이션 안에서 지표를 만드는 부분만 책임지고, 그 지표가 어디로 가는지(프로메테우스, 데이터독, 오픈텔레메트리 컬렉터 등)는 별개의 결정입니다. 이 둘을 같은 추상화 위에서 다루지 않으면 배포할 때마다 코드를 손대야 합니다.
단 하나의 정답이 없다
관측성은 설계상 복잡한 소프트웨어 시스템을 이해하기 위한 도구입니다. 현재 통용되는 몇 가지 패턴이 존재할 뿐, 단 하나의 정답은 존재하지 않습니다. 최적의 솔루션은 특정 소프트웨어 시스템의 세부 사항에 따라 달라집니다.
Fighting Animals 예제의 한계
예제로 Fighting Animals 애플리케이션을 사용하지만, 여기서 내린 아키텍처 결정이 모든 애플리케이션에서 최선은 아닙니다. 애플리케이션 아키텍처와 배포 환경의 세부 사항에 따라 최적의 관측성 전략은 달라집니다.
핵심 내용
11.1 마이크로미터 소개
2024년 8월 기준, 가장 널리 쓰이는 자바 지표 라이브러리는 마이크로미터(Micrometer)입니다. 원래 스프링 프로젝트의 일부로 개발되었지만 현재는 독립 프로젝트입니다.
자바/자바 가상 머신 기반 프로젝트이므로 이기종 아키텍처(heterogeneous architecture)에서 동일한 라이브러리를 쓰려는 팀은 다른 대안을 봐야 할 수도 있습니다. 마이크로미터의 정체성은 벤더 중립적(vendor-neutral) 애플리케이션 지표 인터페이스입니다.
연동되는 백엔드는 다음과 같습니다.
- 애저 모니터, AWS 클라우드워치, 데이터독, 다이나트레이스
- 엘라스틱, 자바 관리 확장 프로그램(JMX), 뉴 렐릭
- 오픈텔레메트리 프로토콜, 프로메테우스
- SignalFx, StatsD
마이크로미터는 개발자가 직접 호출하는 라이브러리입니다. 즉, 수집할 지표를 명시적으로 생성하고 기록해야 합니다.
11.1.1 미터와 레지스트리
마이크로미터는 SPI(service provider interface) 형태로 코어 라이브러리를 제공하고, 다양한 벤더와 오픈 소스 솔루션으로 데이터를 내보내는 구현체인 플러그형 지표 소비자(metric consumers)를 사용합니다. 로깅 프레임워크가 작동하는 방식과 유사합니다.
지표 시스템 간 차이의 추상화
모든 지표 시스템이 같은 방식으로 동작하지 않습니다. 마이크로미터 인터페이스는 다음 차이를 흡수합니다.
- 차원 기반 주석(dimensions-based annotation) 지원 여부 — 차원을 지원하지 않으면 계층적 명명(hierarchical naming)으로 대체
- 지표 생성 방식(집계 규칙) — 클라이언트 측 집계 vs 서버 측 집계
- 전달 방식 — 클라이언트 푸시 vs 서버 풀링
| 구분 | 방식 | 동작 |
|---|---|---|
| 클라이언트 측 집계 | 개별 샘플이 고정 속도로 변환(집계)된 후 서버로 전송 | 네트워크 부하 ↓, 클라이언트 자원 사용 ↑ |
| 서버 측 집계 | 모든 샘플이 네트워크로 전송되고 서버에서 집계 | 네트워크 부하 ↑, 클라이언트 자원 사용 ↓ |
| 클라이언트 푸시 | 익스포터가 서버에 연결해 업데이트 전송 | 데이터독·뉴 렐릭 등 |
| 서버 풀링 | 지표 백엔드가 표준 포트(주로 HTTP)에 연결해 수집 | 프로메테우스 |
미터와 미터 레지스트리
자바 개발자 관점에서는 이런 세부 사항을 신경 쓰지 않고 마이크로미터 API에 집중할 수 있습니다.
미터(meter)는 지표를 수집하는 핵심 인터페이스이며, 다양한 계측기 유형(meter types)이 이 인터페이스의 하위 구현입니다. 미터는 소문자와 점(.) 구분자로 명명하고, 백엔드로 내보낼 때 해당 백엔드의 네이밍 규칙에 맞게 변환됩니다.
각 미터는 특정 레지스트리(registry) 내에서 관리됩니다.
| 레지스트리 | 용도 |
|---|---|
SimpleMeterRegistry | 인메모리에서만 유지, 개발/단위 테스트용 |
LoggingMeterRegistry | 개발/테스트용, 주기적으로 미터 데이터를 로그로 출력 |
CompositeMeterRegistry | 여러 레지스트리를 포함하는 멀티퍼블리셔(multipub) |
Metrics.globalRegistry | 정적 전역 레지스트리 |
스프링 vs 비스프링
마이크로미터는 스프링 애플리케이션에 자동으로 SimpleMeterRegistry를 주입합니다. 스프링이 아닌 곳에서는 직접 인스턴스를 만들어 씁니다.
// 스프링이 아닌 애플리케이션 테스트용
MeterRegistry myRegistry = new SimpleMeterRegistry();
// 일부 출력 결과 생성
MeterRegistry withOutput = new LoggingMeterRegistry();Fighting Animals 예제(micrometer_only 분기)는 초기에 LoggingMeterRegistry를 씁니다. 스프링에서는 빈으로 노출해 연결합니다.
@SpringBootApplication
public class AnimalApplication {
@Bean
public MeterRegistry basicRegistry() {
return new LoggingMeterRegistry();
}
public static void main(String[] args) {
SpringApplication.run(AnimalApplication.class, args);
}
}지표가 콘솔에 로그로 떨어지므로 마이크로미터를 처음 시작할 때 합리적인 방법입니다. 단, 이 설정은 각 *Application 클래스마다 따로 해야 합니다. 마이크로서비스가 서로 다른 컨테이너에서 돌기 때문입니다.
계측기 유형
마이크로미터는 일반적인 사용 사례를 모두 다룰 수 있는 다양한 계측기를 지원합니다.
- 카운터 — 모든 이벤트의 수를 계산
- 게이지 — 단일 지표 값을 추적
- 타이머 — 모든 시간 측정 이벤트의 수와 총 시간
- 분포 요약 — 시간과 관련되지 않은 이벤트의 분포(히스토그램)
이보다 덜 일반적인 것으로 LongTaskTimer, TimeGauge, FunctionCounter, FunctionTimer 등이 있습니다.
차원은 Tag 객체로 표현되며, 점(.)으로 구분된 소문자 네이밍을 따르고 null 값이 될 수 없습니다.
11.1.2 카운터
카운터는 단조 증가(monotonic)하는 값을 나타냅니다. 시간이 지나도 감소하지 않으며, battle 같은 누적 횟수에 적합합니다.
@RestController
public class AnimalController {
private final Counter battlesTotal;
private final MeterRegistry registry;
public AnimalController(MeterRegistry registry) {
this.registry = registry;
this.battlesTotal = this.registry.counter("battles.total");
}
@GetMapping("/battle")
public String makeBattle() throws IOException, InterruptedException {
battlesTotal.increment();
// ...
}
}레지스트리의 counter() 메서드로 바로 생성하거나, 플루언트(fluent) 빌더 패턴으로 만들 수도 있습니다.
this.battlesTotal = Counter
.builder("battles.total")
.description("Total number of battles fought")
.register(this.registry);마지막 단계는 레지스트리에 등록하는 것입니다. 단위 설정이나 태그 추가 같은 선택적 메서드도 있습니다.
11.1.3 게이지
게이지는 카운터보다 약간 더 복잡합니다. 값이 오르내릴 수 있기 때문입니다.
예제는 시간에 따라 관찰된 feline 비율을 추적합니다. 변경 가능한 double 값을 저장하는 클래스가 필요하지만 JDK에 적절한 클래스가 없어, java.lang.Number를 확장하는 FelinePercent를 직접 만들어야 합니다.
AtomicDouble이 없다
자바의 동시성 라이브러리에는
AtomicDouble이 제공되지 않습니다. 값 변경 가능한 변수를 저장하려면 별도 클래스를 만들어야 합니다. (여기서는 동시성보다 값의 변경 가능성이 더 중요한 요소입니다.)
@RestController
public class MammalController {
private final FelinePercent felinePercent;
private int felineCount = 0;
private int mustelidCount = 0;
private final MeterRegistry registry;
public MammalController(MeterRegistry registry) {
this.registry = registry;
felinePercent = this.registry.gauge("battles.felinePercent",
new FelinePercent(0.5));
}
@GetMapping("/getAnimal")
public String getAnimal() throws IOException, InterruptedException {
var id = (int) (SERVICES.size() * Math.random());
if (id == 0) {
mustelidCount += 1;
} else {
felineCount += 1;
}
felinePercent.setValue((double) felineCount /
(double) (felineCount + mustelidCount));
}
}핵심은 registry.gauge() 호출입니다. 마이크로미터는 전달된 FelinePercent 인스턴스를 감시자(watcher)로 사용해 비율을 추적합니다. 프로그래머는 게이지 값만 업데이트하면 됩니다.
@Nullable
public <T extends Number> T gauge(String name, T number) {
return this.gauge((String)name, (Iterable)Collections.emptyList(),
(Number)number);
}제네릭에서 게이지 클래스가 Number를 확장해야 한다는 점에 주의해야 합니다. FelinePercent는 이를 만족하기 위해 intValue()·longValue()·floatValue()·doubleValue()를 오버라이드합니다.
public final class FelinePercent extends Number {
private volatile double value;
public FelinePercent(double v) {
if (v < 0.0 || v > 1.0) {
throw new IllegalArgumentException("Require 0 < felinePercent < 1");
}
value = v;
}
public void setValue(double v) {
if (v < 0.0 || v > 1.0) {
throw new IllegalArgumentException("Require 0 < felinePercent < 1");
}
value = v;
}
@Override public double doubleValue() { return value; }
// intValue, longValue, floatValue 오버라이드
}중간 값은 관찰되지 않을 수 있다
게이지는 필요할 때만 값을 업데이트하므로 모든 상태 변화가 기록되지는 않습니다. 업데이트 시점의 현재 값만 반영되며, 중간 값은 관찰되지 않을 수 있습니다. 이 특성이 카운터와의 결정적 차이입니다.
11.1.4 미터 필터
미터 필터(meter filter)는 다음을 보다 정밀하게 제어합니다.
- 미터가 언제, 어떻게 등록되는지
- 어떤 종류의 통계를 내보낼 것인지
기본 기능은 세 가지입니다.
- 미터 등록을 허용 또는 거부
- 미터 변환(지표 이름, 태그, 단위 변경)
- 분포 통계 구성 (타이머와 분포 요약에만 적용)
팩토리 메서드 방식
MeterFilter 인터페이스의 구현체로 표현되며, 팩토리 메서드로 간단히 추가할 수 있습니다.
// 내부 지표가 공개되지 않도록 방지
this.registry.config()
.meterFilter(MeterFilter.denyNameStartsWith("internal"));명시적 생성 방식
같은 필터를 직접 인터페이스 구현으로 만들 수도 있습니다.
new MeterFilter() {
@Override
public MeterFilterReply accept(Meter.Id id) {
if (id.getName().startsWith("internal")) {
return MeterFilterReply.DENY;
}
return MeterFilterReply.NEUTRAL;
}
};MeterFilterReply 열거형은 세 가지 값을 가집니다.
| 값 | 동작 |
|---|---|
DENY | 해당 미터를 레지스트리에 등록하지 않음 |
ACCEPT | 즉시 등록, 이후 다른 필터는 확인하지 않음 |
NEUTRAL | 특정 결정 없이 통과, 다음 필터 계속 확인 |
추가적 동작
meterFilter()메서드는 철저히 추가적인 방식으로 동작합니다. 필터 체인에 추가하는 순서를 신중하게 고려해야 합니다.
CompositeMeterRegistry의 커스터마이저로 필터를 활용하면, 일부 지표만 특정 보조 백엔드로 전송하는 패턴을 구현할 수 있습니다. 운영 환경 배포에서 매우 유용하지만 고급 활용법은 이 책에서 다루지 않습니다.
11.1.5 타이머
타이머는 내부적으로 최소 세 가지 값을 저장합니다.
- 기록된 모든 값들의 합
- 기록된 값들의 개수
- 일정 시간 동안 관찰된 최댓값 (게이지로 표현)
추가 통계를 내보내도록 구성할 수도 있습니다. 히스토그램 데이터, 사전 계산된 백분위수, 서비스 수준 목표(SLO) 경계 값 등을 포함할 수 있습니다.
@RestController
public class AnimalController {
private final Timer responseTimer;
private final MeterRegistry registry;
public AnimalController(MeterRegistry registry) {
this.registry = registry;
this.responseTimer = Timer
.builder("response.time")
.description("Response time")
.register(registry);
}
@GetMapping("/battle")
public String makeBattle() throws Exception {
Callable<String> callable = () -> {
var good = fetchRandomAnimal();
var evil = fetchRandomAnimal();
return String.format("""
{ "good": "%s", "evil": "%s" }""", good, evil);
};
return responseTimer.recordCallable(callable);
}
}Callable / Runnable / Supplier
코드 블록을 Callable로 감싸 recordCallable()에 넘기면 실행과 완료에 걸린 시간이 기록됩니다. Runnable과 Supplier는 record() 메서드로 처리합니다. Callable과 Supplier의 시그니처 충돌 때문에 이렇게 설계되었습니다.
분산 시스템에서는 추적이 더 낫다
분산 시스템에서 메서드 성능을 측정할 때는 타이머가 최선이 아닐 수 있습니다. 오픈텔레메트리 같은 추적이 더 나은 접근입니다. 단일 노드 안에서의 측정은 타이머가 적합하지만, 호출이 여러 서비스를 가로지를 때는 스팬과 추적 컨텍스트가 더 풍부한 정보를 제공합니다.
11.1.6 분포 요약
분포 요약은 타이머의 일반화된 형태입니다. 타이머가 시간 데이터의 분포를 다루는 특수 케이스라면, 분포 요약은 임의 값의 집합(분포)을 요약합니다.
시간이 아닌 값에 사용
분포 요약은 시간이 아닌 값을 측정할 때 써야 합니다. 지속 시간이라면 타이머를 대신 쓰는 것이 좋습니다.
단순 카운터보다 더 많은 데이터를 저장하므로 메모리를 더 쓰지만, 여전히 전체 분포를 완벽하게 저장하지는 못하는 손실(lossy) 표현입니다.
@RestController
public class AnimalController {
private static final Random random = new Random();
private final DistributionSummary winSummary;
private final MeterRegistry registry;
public AnimalController(MeterRegistry registry) {
this.registry = registry;
// 공격자가 승리했을 때 그 힘의 크기를 요약
this.winSummary = registry.summary("attacker.win.size");
}
@GetMapping("/fight/{a}/{d}")
public String resolveFight(
@PathVariable("a") String attacker, @PathVariable("d") String defender) {
final String winner;
// 방어자의 전투력은 0.5로 간주
var attackerStrength = random.nextDouble();
if (attackerStrength > 0.5) {
winner = attacker;
winSummary.record(attackerStrength);
} else {
winner = defender;
}
return String.format("""
{ "winner": "%s"}""", winner);
}
}공격자 승리 시에만 attackerStrength를 기록하므로, 0.5에서 1.0 사이의 균등 분포가 만들어지고 DistributionSummary가 이를 요약합니다.
LoggingMeterRegistry 출력 예시
animal-service_1 | 2024-01-14T08:27:31.748Z INFO 1 --- [trics-publisher]
i.m.c.i.logging.LoggingMeterRegistry : attacker.win.size{}
throughput=0.183333/s mean=0.699785 max=0.98829
고급 분포 통계: configure()
기본 설정이 부족하면 MeterFilter의 세 번째 비정적 메서드 configure()로 더 정교한 설정을 줄 수 있습니다.
@Nullable
default DistributionStatisticConfig configure(Meter.Id id,
DistributionStatisticConfig config) {
return config;
}기본 구현은 주어진 설정을 그대로 반환하지만, 사용자 정의 구현으로 입력 설정과 제공 설정을 병합할 수 있습니다. 카운트·합계·최댓값 외에 사전 계산된 백분위수, SLO, 히스토그램 등을 추가할 수 있습니다.
예를 들어, 모든 자바 가상 머신 지표에 대해 ‘롱테일’ 백분위수를 미리 계산하도록 설정하려면 다음 필터를 씁니다.
new MeterFilter() {
@Override
public DistributionStatisticConfig configure(Meter.Id id,
DistributionStatisticConfig config) {
if (id.getName().startsWith("jvm")) {
return DistributionStatisticConfig.builder()
.publishPercentiles(0.9, 0.99, 0.999, 0.9999)
.build()
.merge(config);
}
return config;
}
}사전 계산된 백분위수는 재집계할 수 없다
카운트·합계·일부 분포 요약 데이터는 차원이나 인스턴스 간에 재집계가 가능합니다. 그러나 사전 계산된 백분위수는 재집계가 불가능합니다. 백분위수를 다시 집계하려는 시도는 심각한 오류이며, 흔히 발생하는 실수입니다.
전체 데이터셋의 정확한 백분위수를 구하려면 원본 데이터셋을 합친 후 다시 계산해야 합니다. 일단 백분위수가 계산되면 일부 데이터가 손실되므로 정확한 결과를 보장할 수 없습니다.
11.1.7 런타임 지표
사용자가 직접 정의한 지표 외에도 마이크로미터는 자바 가상 머신과 애플리케이션 런타임의 다양한 지표를 수집·내보낼 수 있습니다.
핵심 인터페이스는 MeterBinder입니다.
public interface MeterBinder {
void bindTo(@NonNull MeterRegistry var1);
}가장 중요한 두 구현은 자바 가상 머신 메모리 지표와 프로세서 지표입니다.
@RestController
public class AnimalController {
private final MeterRegistry registry;
public AnimalController(MeterRegistry registry) {
this.registry = registry;
new ProcessorMetrics().bindTo(this.registry);
new JvmMemoryMetrics().bindTo(this.registry);
}
}스프링부트 자동 설치
스프링부트 애플리케이션에서는 이를 명시적으로 호출할 필요가 없습니다. 프레임워크가 자동으로 설치합니다. 다른 애플리케이션에서는 명시적으로 활성화해야 합니다.
| 클래스 | 제공 지표 예시 |
|---|---|
JvmMemoryMetrics | jvm.memory.used, jvm.memory.max |
ProcessorMetrics | system.cpu.usage, system.load.average.1m |
ExecutorServiceMetrics | 스레드 풀 지표 |
ExecutorService 지표는 new ExecutorServiceMetrics(executor, executorServiceName, tags).bindTo(registry)처럼 설정해 스레드 풀을 쉽게 모니터링할 수 있습니다.
11.2 자바 개발자를 위한 프로메테우스 소개
11.2.1 프로메테우스 아키텍처 개요
프로메테우스는 CNCF(클라우드 네이티브 컴퓨팅 재단) 프로젝트로 원래 사운드클라우드(SoundCloud)에서 개발되었습니다. 지표 백엔드, 데이터 수집 메커니즘, 다양한 통합 기능을 제공합니다.
프로메테우스는 숫자 기반의 정규 시계열 데이터(regular time series data)를 처리하도록 설계되었으며, 로그나 추적을 저장하는 용도로는 쓰지 않습니다.
서버 풀 방식: 스크레이핑
이전에 본 지표 아키텍처 옵션 중 프로메테우스는 서버 풀 방식을 씁니다. 이를 스크레이핑(scraping)이라고 부릅니다. 프로메테우스로 모니터링하려는 모든 서비스는 HTTP 엔드포인트를 제공해야 하며, 프로메테우스 스크래퍼가 해당 엔드포인트에서 지표를 수집합니다.
이는 프로메테우스가 서비스를 인식하거나 검색할 수 있어야 함을 뜻하며, 수명이 짧은 작업에서는 문제가 될 수 있습니다. 이를 해결하고 오픈텔레메트리 같은 푸시 기반 아키텍처와 더 잘 통합하기 위해 프로메테우스는 원격 쓰기(remote write) 기능도 제공합니다.
서버 풀 방식은 쿠버네티스 같은 시스템의 보안 모델과도 잘 맞습니다.
전체 아키텍처
flowchart LR SS[수명이 짧은 서비스] --> PG[푸시 게이트웨이] PG --> PS subgraph PS[프로메테우스 서버] SR[검색] --> TS[(시계열 DB)] HS[HTTP 서버] --> TS TS --> AM[알림 관리자] end Targets[대상 서비스들] -.스크레이핑.-> PS HS --> UI[프로메테우스 웹 UI] HS --> GF[그라파나] HS --> API[API 클라이언트] AM --> Out[페이저/이메일/기타]
PromQL과 시각화
프로메테우스는 PromQL이라는 쿼리 언어를 제공합니다. 이름과 달리 SQL이 아니며, 전통적인 관계형 데이터가 아니라 시계열 데이터를 쿼리하기 위한 DSL(도메인 특화 언어)입니다. 쿼리된 데이터는 다양한 방식으로 시각화할 수 있습니다.
프로메테우스에는 기본 UI가 포함되어 있지만 운영 환경에서는 부족합니다. 일반적으로 그라파나(Grafana)를 위에 얹습니다.
11.2.2 마이크로미터와 함께 프로메테우스 사용하기
퍼사드 패턴으로 백엔드 추상화
초기 예제에서는 LoggingMeterRegistry를 써서 콘솔에 지표를 출력했습니다. 운영 환경에는 부적합합니다. 목표는 퍼사드(façade) 패턴으로 지표 백엔드의 세부 사항을 추상화하는 것입니다.
즉, 애플리케이션 코드에 프로메테우스에 특화된 코드가 없어야 합니다. 이렇게 하면 테스트가 더 쉬워지고, 더미나 목 지표 종속성으로 실행할 수 있습니다.
퍼사드를 쓰면 컴포넌트를 교체할 수 있다는 이야기가 자주 나옵니다. 그러나 실제 환경에서 이런 변경이 항상 간단하지는 않습니다. 벤더 종속(vendor lock-in)은 우리가 예상하는 것보다 더 미묘한 방식으로 작용할 수 있습니다.
의존성 추가
마이크로미터의 SPI 구조 덕에 의존성 하나로 프로메테우스 연동이 끝납니다.
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope>
</dependency>application.properties
management.endpoints.web.exposure.include=health,info,prometheus마이크로미터로 지표를 노출하고 있으므로 프로메테우스가 연결할 수 있는 스크레이핑 가능한 엔드포인트가 생깁니다.
// 로그 노이즈를 줄이기 위해 bean 제거
@Bean
public MeterRegistry basicRegistry() {
return new LoggingMeterRegistry();
}Fighting Animals 아키텍처
flowchart LR Animal[animal 서비스] --> Mammal[mammal 서비스] Animal --> Fish[fish 서비스] Mammal --> Feline[feline 서비스] Mammal --> Mustelid[mustelid 서비스] Animal -.스크랩.-> P[프로메테우스 수집] Mammal -.스크랩.-> P Fish -.스크랩.-> P Feline -.스크랩.-> P Mustelid -.스크랩.-> P
docker-compose 추가
# 프로메테우스
prom:
container_name: prometheus
image: prom/prometheus
command:
- "--config.file=/config/prometheus.yml"
ports:
- "9090:9090"
user: root
volumes:
- './config:/config'
- './target/data/prometheus:/prometheus'prometheus.yml
global:
# 스크레이핑 주기를 15초로 설정. 기본값은 1분
scrape_interval: 15s
# 규칙을 1초마다 평가. 기본값은 1분
evaluation_interval: 15s
# scrape_timeout은 전역 기본값인 10초로 설정되어 있음
scrape_configs:
- job_name: prometheus
metrics_path: /metrics
scheme: http
static_configs:
- targets:
- localhost:9090
- job_name: animal
metrics_path: /actuator/prometheus
scrape_interval: 5s
static_configs:
- targets:
- animal-service:8080
# 다른 서비스들도 유사하게 구성되어 있음localhost 함정 회피
이는 의도적인 선택입니다. 인터넷에서 찾을 수 있는 많은 예제들이 localhost에서는 동작하지만, 실제 네트워크 환경에서는 제대로 동작하지 않는 경우가 많기 때문입니다. 각 서비스가 동일한 URI(/actuator/Prometheus)에서 지표를 제공하지만, 서로 다른 포트에서 실행되도록 구성됩니다.
스크레이핑 출력 예시
http://<Target IP>:8080/actuator/prometheus에서 출력되는 결과 일부입니다.
# HELP system_load_average_1m The sum of the number of runnable entities queued
# TYPE system_load_average_1m gauge
system_load_average_1m 0.2
# HELP jvm_classes_loaded_classes The number of classes that are currently
# loaded in the Java virtual machine
# TYPE jvm_classes_loaded_classes gauge
jvm_classes_loaded_classes 8934.0
# HELP jvm_memory_committed_bytes The amount of memory in bytes that is
# committed for the Java virtual machine to use
# TYPE jvm_memory_committed_bytes gauge
jvm_memory_committed_bytes{area="nonheap",id="CodeHeap 'profiled nmethods'",}
9109504.0
# HELP battles_total
# TYPE battles_total counter
battles_total 5.0
이러한 지표들 중 상당수는 애플리케이션 지표가 아니라 자바 가상 머신 지표임에 유의합니다. 커스텀 지표(battles_total)도 함께 노출됩니다.
프로메테우스 자체 지표
프로메테우스는 자신을 동일한 메커니즘으로 모니터링합니다. http://<Target IP>:9090/metrics에서 프로메테우스 자체 지표(고 런타임 지표·고루틴 개수 등)도 확인할 수 있습니다. 이는 프로메테우스가 구현하고 있는 중요한 아키텍처 원칙을 보여줍니다. 관측성 신호를 지원하고 전달하는 시스템 자체도 관측 가능해야 한다는 점입니다.
PromQL: 범위 벡터와 즉시 벡터
기본 프로메테우스 UI는 http://<Target IP>:9090/graph에서 확인할 수 있습니다.
쿼리 문자열 process_cpu_usage{job="animal"}[6h]는 지난 6시간 동안 animal 작업의 CPU 사용량을 나타내는 모든 데이터 포인트를 요청하는 PromQL 쿼리입니다. 이를 범위 벡터(range vector)라고 하며, 특정 시간 범위에 대한 시계열 벡터를 반환합니다.
이 쿼리는 테이블 뷰에서 <value>@<timestamp> 형식으로 표시됩니다. 그래프 뷰는 즉시 벡터(instant vector) 표현식을 사용하기 때문에, 그래프 뷰로 전환하려면 쿼리를 변경해야 합니다.
쿼리 process_cpu_usage{job=~"m.*"}는 이름이 m으로 시작하는 모든 작업(mammal, mustelid)의 CPU 지표를 반환합니다. =~는 프로메테우스의 정규 표현식 지원입니다.
PromQL 전체는 이 책의 범위를 벗어나며, 실제 작업에서는 프로메테우스 문서와 그라파나 같은 보조 도구의 문서를 함께 봐야 합니다.
11.3 오픈텔레메트리 소개
8장에서 등장했던 오픈텔레메트리(OpenTelemetry, OTel)는 CNCF가 개발 중인 관측성 데이터의 새로운 개방형 표준입니다. 오픈트레이싱(추적)과 오픈센서스(지표) 프로젝트가 병합되어 탄생했고, 2023년에 1.0이 출시되었습니다.
오픈트레이싱/오픈센서스는 폐지
오픈트레이싱과 오픈센서스는 더 이상 사용되지 않으며, 새로운 개발은 오픈텔레메트리에서 진행해야 합니다.
OTel의 강점은 핵심 도메인에 집중한다는 점입니다. OTel 자체는 데이터 수집 백엔드도, 저장소도 아닙니다. 관측성 시스템의 일부 컴포넌트로만 동작하며, 핵심 역할은 계측(instrumentation)과 데이터 추출(data exfiltration)입니다. 즉 “외부 관측성 시스템으로 데이터를 흘려보내는 표준화된 파이프”라고 보면 됩니다.
11.3.1 주요 프로젝트 영역
OTel는 세 가지 영역으로 구성됩니다.
| 영역 | 역할 |
|---|---|
| 사양 (specification) | 모든 구현에 대한 언어 간 요구 사항과 기대 사항을 정의. 지표·로그·추적의 형식·규칙·프로토콜 포함 |
| 구현 (implementations) | 각 언어별로 API와 SDK 제공. 다양한 프레임워크 통합. 자바는 코드 변경 없이도 계측 가능한 자바 에이전트까지 제공 |
| 컬렉터 (collector) | 벤더 독립적 방식으로 텔레메트리 데이터를 수신·처리·내보냄. 라우터 또는 스위칭 스테이션 역할 |
API와 SDK
API와 SDK는 역할이 다릅니다. API는 개발자가 애플리케이션이나 라이브러리를 계측할 때 사용하는 인터페이스를 포함합니다. SDK는 두 부류의 사용자를 대상으로 합니다.
- 애플리케이션 소유자가 배포를 구성할 때 사용하는 생성자
- 플러그인 작성자가 통합을 작성할 때 사용하는 인터페이스
이 구분은 코드를 작성하는 개발자(API만 보면 됨)와 배포·운영을 책임지는 팀(SDK 설정까지 다룸) 사이의 관심사 분리를 반영합니다.
시맨틱 버저닝과 지원 보장
OTel는 안정성과 이전 버전과의 호환성을 매우 중요하게 여겨 시맨틱 버저닝(semantic versioning)을 따릅니다.
| 구성 요소 | 지원 기간 |
|---|---|
| API | 3년 |
| 플러그인 인터페이스 | 1년 |
| 생성자 | 1년 |
보장은 실제보다 약할 수 있다
이러한 보장은 실제보다 약할 수 있으며, 특히 OTel-자바에서 그렇습니다. 안정적인 구성 요소에 대한 하위 호환성을 지원·유지하는 것이 커뮤니티의 주요 목표 중 하나이며, 주요 버전 발표는 호환성을 깨는 변경이 포함된다는 의미입니다.
자바 관련 깃허브 프로젝트
opentelemetry-java: API와 SDK를 포함한 핵심 구성 요소opentelemetry-java-instrumentation: 라이브러리 계측 또는 자동 계측 에이전트opentelemetry-java-contrib: 독립적으로 사용 가능한 유용한 라이브러리opentelemetry-java-examples: 수동 계측 예제
자바 구현은 초기에는 추적과 지표에 초점을 맞췄고, 로그는 2023년 말에 1.0에 도달했습니다. 로그가 가장 늦게 안정화된 건 자바 진영에 이미 SLF4J·로그백 같은 성숙한 표준이 있었기 때문이며, OTel가 처음부터 “기존 자바 생태계와 통합한다”는 방향을 택한 결과이기도 합니다.
11.3.2 오픈텔레메트리를 선택해야 하는 이유
시장 트렌드 두 가지
- 벤더 종속 탈피: APM 시장은 원래 독점 벤더가 지배했으나, 벤더 종속 감소·비용 절감·유연성 향상에 대한 수요로 오픈 소스 대안이 등장했습니다. 예거(추적용), 프로메테우스(지표 분야), ELK 스택(로깅용)이 이미 그런 사례입니다.
- 시스템 복잡성 증가: 소프트웨어가 점점 더 복잡해지고 다양한 언어·프레임워크가 인기를 얻으면서, 신뢰할 수 있는 계측 제공에 더 많은 자원이 필요해졌습니다.
벤더가 오픈 소스로 수렴 중
각 벤더가 자체 계측 라이브러리 제품군을 유지하는 것은 중복과 비효율을 부릅니다. 단일 오픈 소스 라이브러리 세트를 공동으로 개발하는 쪽이 합리적이라는 결론으로 이어집니다. 따라서 관측성 벤더가 제공하는 가치는 계측 자체보다는 사용자 경험·백엔드 기능·비용 최적화로 옮겨가고 있습니다.
이는 곧 자신을 관측성 제공업체로 재정의하려는 APM 벤더들의 시도이자, 오픈 소스 모델과 밀접하게 연관된 새로운 관측성 스타트업이 늘어나는 현상과도 연결됩니다.
벤더들이 표준화된 프로토콜과 계측 스택으로 수렴해 가는 방향이 결국 오픈텔레메트리입니다. 표준이 느리게 발전한다는 비판도 있지만, 안정적인 공통 명명 규칙과 의미론적 컨벤션을 제공한다는 점에서 큰 이점이 있습니다.
11.3.3 오픈텔레메트리 프로토콜
OTel 프로토콜(OTLP)은 전체 프로토콜 공간을 정의하려 하지 않고 핵심 요소에 집중합니다.
| 관심사 | 내용 |
|---|---|
| 인코딩 (encoding) | 데이터를 어떤 형식으로 직렬화할 것인가 |
| 전송 (transport) | 어떤 네트워크 프로토콜로 보낼 것인가 |
| 전달 (delivery) | 메시지 전달 보장 방식 |
성능이 중요한 고려 사항이므로 HTTP/2 또는 gRPC로 구현됩니다. gRPC는 기본적으로 원격 프로시저 호출(RPC) 프레임워크로, HTTP/2 기반의 프로토콜 버퍼(protobuf)라는 바이너리 형식을 활용합니다. 자바 핵심 구현은 두 가지 인코딩 방식을 모두 사용할 수 있으며, 기본값은 HTTPS/protobuf입니다.
11.3.4 오픈텔레메트리 컬렉터
OTel 컬렉터는 관측성 데이터를 다루는 네트워크 서비스로, 세 가지 주요 유형의 데이터(추적·지표·로그)를 수신하고 처리하며 내보냅니다. 아키텍처는 단순하면서도 확장 가능하도록 설계되었고, 벤더 중립적입니다. 고(Go) 언어로 작성되었으며 다양한 기업의 기여자로 구성된 오픈 소스 팀이 유지 관리합니다. 이름과 달리 OTLP뿐만 아니라 다양한 데이터 형식도 지원합니다.
컬렉터의 설정은 YAML로 작성하며 기본적으로 http://localhost:4317에서 실행됩니다.
주요 설정 영역
| 영역 | 역할 |
|---|---|
receivers | 컬렉터가 데이터를 수신할 데이터 소스 |
processors | 컬렉터가 적용할 데이터 변환 작업 |
connector | 선택. 특정 유형의 데이터를 다른 유형으로 변환 |
exporters | 변환된 데이터를 전송할 대상 |
extensions | 선택. 추가 기능 (예: 헬스 체크) |
파이프라인 구성 예시
service 영역에서 파이프라인을 정의하며, 각 관측성 신호(traces/metrics/logs)에 대해 별도의 파이프라인을 설정할 수 있습니다.
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp]OTLP 수신기로 데이터를 받아들이고, 배치 프로세서로 처리한 후 OTLP HTTP 익스포터(otlphttp)로 데이터를 전송합니다. 데이터는 전송 전에 배치 형태로 가공됩니다.
컬렉터의 자기 관측
프로메테우스와 마찬가지로 컬렉터 자체도 자신의 관측성 데이터를 생성하며, service 영역 아래의 telemetry 영역에 저장됩니다.
핵심 아키텍처 원칙
| 원칙 | 의미 |
|---|---|
| 사용성 | 기본 설정이 합리적이며 즉시 사용 가능 |
| 성능 | 다양한 부하·구성에서도 성능 유지 |
| 관측성 | 좋은 관측성을 제공하는 서비스의 모범 사례로 가능 |
| 확장성 | 핵심 코드 수정 없이 커스터마이징 가능 |
| 통합 | 단일 코드베이스에서 추적·지표·로그를 모두 지원 |
샘플 아키텍처
컬렉터는 짧은 수명의 애플리케이션 프로세스와 지표·추적 데이터 저장소 사이의 중간 계층으로 동작합니다.
flowchart LR App[자바 애플리케이션] -->|OTLP| Agent[에이전트] Agent --> Collector subgraph Collector[OTel 컬렉터] Receiver[OTLP 리시버] --> TP[추적 프로세서<br/>batch / attribute] Receiver --> MP[지표 프로세서<br/>batch / attribute] TP --> Exporter[OTLP 익스포터] MP --> Exporter Ext[Extensions: health, pprof, zpages] end Exporter --> Jaeger[(예거)] Exporter --> Prom[(프로메테우스)]
지표의 경우 프로메테우스는 컬렉터의 위치를 알고 풀링하거나, 컬렉터가 원격 쓰기를 수행하도록 구성할 수도 있습니다. 즉 풀과 푸시 중 어느 방향이든 컬렉터가 매개할 수 있습니다.
11.4 자바에서 오픈텔레메트리 추적 적용
OTel 도입에는 여러 방법이 있는데, 일부는 OTel API와 직접 연결하지 않고도 사용 가능합니다. 또한 OTel는 자바에서 모든 관측성 데이터를 위한 하나의 통합 API를 만들려 하지 않고, 기존 자바 생태계에서 널리 사용되는 구성 요소와 통합하는 방식을 선호합니다.
- 로그: 이미 적절한 패턴과 고수준 API가 존재 → 그대로 사용
- 추적: 적절한 기존 솔루션이 없음 → 수동 추적과 자동 추적 두 가지 방법 제공
11.4.1 수동 추적
수동 계측은 개발자가 직접 추적 라이브러리를 호출하는 코드를 삽입해야 합니다. 간단한 예제를 넘어서면 관리하기 어려울 정도로 복잡해집니다.
POM 설정: BOM으로 종속성 통합
핵심은 <dependencyManagement>로 BOM(Bill of Materials)을 추가해 여러 종속성 버전을 한꺼번에 관리하는 것입니다.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-bom</artifactId>
<version>1.40.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>추가로 opentelemetry-api, opentelemetry-sdk, opentelemetry-sdk-extension-autoconfigure, opentelemetry-exporter-otlp 종속성이 필요합니다.
OpenTelemetry bean 등록
@Bean
public OpenTelemetry openTelemetry() {
return AutoConfiguredOpenTelemetrySdk.initialize().getOpenTelemetrySdk();
}이렇게 등록한 OpenTelemetry 객체는 서비스 컨트롤러에서 생성자 주입(constructor injection)으로 사용할 수 있습니다.
컨트롤러에 스팬 삽입
@GetMapping("/battle")
public String makeBattle() throws IOException, InterruptedException {
// 요청에서 전파된 컨텍스트를 추출. 이 경우 어떤 컨텍스트도 추출되지 않으며 루트 스팬임
var extractedContext = extractContext(httpServletRequest, EXTRACTOR);
try (var scope = extractedContext.makeCurrent()) {
var span = serverSpan("/battle", HttpMethod.GET.name(),
AnimalController.class.getName(), "animal-service:8080");
try {
var good = fetchRandomAnimal(span);
var evil = fetchRandomAnimal(span);
return String.format("""
{ "good": "%s", "evil": "%s" }""", good, evil);
} finally {
span.end();
}
}
}serverSpan 헬퍼
public static Span serverSpan(Tracer tracer, String path, String method,
String serviceName) {
return tracer
.spanBuilder(path)
.setSpanKind(SpanKind.SERVER)
.setAttribute(SemanticAttributes.HTTP_METHOD, method)
.setAttribute(SemanticAttributes.HTTP_SCHEME, "http")
.setAttribute(SemanticAttributes.HTTP_HOST, serviceName)
.setAttribute(SemanticAttributes.HTTP_TARGET, path)
.startSpan();
}Tracer 객체는 로거(logger)와 유사하므로 생성자 코드에서 한 번만 초기화하는 것이 가장 좋습니다.
추적 누락 방지: 모든 서비스에 동일한 작업
이는 하나의 서비스에만 적용된 코드입니다. 추적 누락(gaps in tracing coverage)을 방지하려면 MammalController 등 모든 서비스에 유사한 추적 코드를 추가해야 합니다. 마이크로서비스 환경에서 수동 추적의 부담이 어떻게 폭증하는지 보여줍니다.
docker-compose: 컬렉터 + 예거
# 예거
# 로컬 GRPC 포트(4317)는 OTel 컬렉터의 GRPC 포트와 충돌을 피하기 위해 14317로 재매핑
jaeger-all-in-one:
image: jaegertracing/all-in-one:1.52.0
ports:
- "16686:16686"
- "14317:4317" # OTLP gRPC 수신기
- "4318:4318" # OTLP HTTP 수신기
# Collector
otel-collector:
image: otel/opentelemetry-collector:0.91.0
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- "13133:13133" # Health_check 익스텐션
- "4317:4317" # OTLP gRPC 수신기
- "55681:55681" # OTLP HTTP 수신기 대체 포트
depends_on:
- jaeger-all-in-one명시적 버전을 운영에서 그대로 쓰지 말 것
예제에서는 재현 가능성을 위해 명시적 버전을 사용했지만, 실제 운영 환경에서는 배포 전에 구성 요소의 최신 버전으로 업그레이드하는 것이 좋습니다. 오래된 이미지는 보안 취약점이나 버그를 포함할 수 있기 때문입니다.
otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
http:
exporters:
otlphttp:
endpoint: http://jaeger-all-in-one:4318
processors:
batch:
extensions:
health_check:
service:
extensions: [health_check]
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp]인프라 수준의 간접 계층
이런 구성 요소들은 애플리케이션 코드가 아닌 관측성 인프라에 속합니다. 운영팀이 애플리케이션 코드를 변경하지 않고도 컬렉터 설정만 조정해 백엔드를 바꿀 수 있는 간접 계층(indirection point)을 제공합니다.
마이크로서비스가 컬렉터의 위치를 알도록 명령줄 매개변수 -Dotel.exporter.otlp.endpoint=http://otel-collector:4317/나 OTEL_EXPORTER_OTLP_ENDPOINT 환경 변수로 설정합니다. 이 설정은 Dockerfile에 포함될 수 있습니다.
결론: “컴퓨터가 대신하게 하라”
두 제어기 클래스만으로도 애플리케이션에 추적을 추가하는 데 필요한 코드량이 상당함을 알 수 있습니다. 이것이 10.3.2절 ‘수동 계측과 자동 계측’에서 언급된 문제입니다. 수동 추적의 복잡성은 프로그래머가 관리하기 어려운 수준이며, 이런 지루하고 세밀한 복잡성을 해결하는 방법은 언제나 같습니다. 컴퓨터가 대신 처리하도록 하는 것입니다.
11.4.2 자동 추적
자동 계측은 자바 8 이상의 모든 애플리케이션에서 작동하는 에이전트를 배포하는 것입니다. 특정 상황에서는 동적으로 에이전트를 첨부하는 것도 가능합니다. 퀘르커스(Quarkus) 같은 일부 프레임워크는 자동 계측을 기본 지원하므로 에이전트를 쓸 필요가 없으며, 사용해서도 안 됩니다.
자바 에이전트
자바 에이전트는 메인 메서드가 호출되기 전에 실행되는 특별한 jar 파일입니다. 3장에서 간략히 다뤘으며, 이런 기능을 만드는 것은 고급 주제이므로 관심 있는 독자는 전문 자료를 참고하는 것이 좋습니다.
동작 방식
에이전트는 바이트코드를 삽입하여 메서드의 실행 시간과 추적 구축에 필요한 정보를 제공합니다. 추적 ID와 스팬 ID가 포함되며, 이를 이용해 여러 스팬을 하나의 추적으로 연결합니다. 스팬은 일시적으로 메모리에 저장된 후 OTel의 내보내기 기능을 통해 백엔드로 전송됩니다.
100개 이상 라이브러리 자동 지원
기본 코드의 계측과 에이전트를 통한 내보내기 외에도,
opentelemetry-java-instrumentation프로젝트는 100개 이상의 인기 있는 라이브러리와 프레임워크를 기본적으로 지원하는 모듈을 제공합니다.
자동 추적의 두 가지 강점
- 사용 편의성: 수동 추적보다 훨씬 쉽습니다.
- 컴파일 시점 의존성 제거: 프로젝트 코드베이스가 OTel에 대한 명시적인 컴파일 시점 의존성(compile-time dependency)에서 자유로워집니다. POM 파일에 OTel 관련 의존성이 없고, 스팬을 생성하는 코드도 포함되지 않습니다.
관심사의 분리
자동 추적을 사용하는 것은 관심사의 분리(separation of concern) 원칙을 잘 보여주는 예시입니다. 애플리케이션 코드는 추적의 세부 사항을 알 필요가 없으며, 추적 기능이 존재하는지조차 신경 쓰지 않습니다. 대신 추적 기능은 자바 에이전트와 일부 인프라 설정을 통해 제공됩니다.
에이전트는 기본적으로 OTLP 익스포터 기능을 사용하며, http://localhost:4317을 엔드포인트로 지정합니다. 컬렉터 설정은 수동 추적과 동일합니다.
11.4.3 샘플링 추적
”모자와 코끼리” 문제
모든 응답 카테고리가 동일한 정보량을 가지지는 않습니다. 응답 시간이 눈에 띄게 느려지지 않는 한 성공적인 응답은 특별히 흥미로운 데이터가 아닙니다. 추적도 마찬가지로 대부분 성공적으로 완료되므로 별로 흥미롭지 않습니다. 반면 실행이 느리거나 실패한 추적은 더 의미 있는 정보입니다.
모든 행복한 가정은 서로 닮아 있지만, 불행한 가정은 저마다 다른 방식으로 불행하다. — 레프 톨스토이, 『안나 카레니나』
차등 샘플링 전략
추적 샘플링 비율을 응답 코드·서비스 트랜잭션 수에 따라 다르게 적용합니다.
- 오류(4xx 또는 5xx 응답): 100% 캡처
- 성공적인 추적: 일정 비율만 샘플링 (요청량이 많은 서비스에서는 1%만 샘플링하기도 함)
이 방식이 효과적인 이유는 요청량이 많은 서비스에서는 성공한 추적의 일부만 샘플링해도 통계적으로 의미 있는 데이터를 확보할 수 있기 때문입니다. 성능 저하가 발생하면 샘플링된 데이터에서도 그 변화를 감지할 수 있습니다.
11.5 자바에서 오픈텔레메트리 지표 적용
OTel는 지표를 처리할 수 있는 수동 API(manual API)를 제공합니다. 마이크로미터와 비교해 보기 위해 먼저 raw API로 처리하는 예제(Fighting Animals otel_metrics_raw_api 분기)를 살펴봅니다.
Raw OTel 지표 API
이 분기는 수동 추적과 마찬가지로 OTel 라이브러리에 대한 명시적 의존성을 포함하며, POM 변경 사항도 비슷합니다. 추가로 opentelemetry-sdk-metrics 라이브러리가 필요합니다.
spring-boot-starter-actuator 제거
스프링 부트에 내장된 마이크로미터와의 충돌을 방지하기 위해 spring-boot-starter-actuator 의존성을 제거합니다. 따라서 MeterRegistry bean을 제공하는 코드를 제거하고, 대신 OpenTelemetry bean을 사용하는 방식으로 변경합니다(수동 추적 설정과 동일).
계측 도구 사용
OTel 지표 API는 io.opentelemetry.api.metrics 패키지에서 제공됩니다. AnimalController에서 LongCounter와 ObservableDoubleGauge를 사용하는 예시입니다.
public class AnimalController {
private final OpenTelemetry sdk;
private final Meter appMeter;
private final Meter memoryMeter;
private final LongCounter battlesTotal;
private final ObservableDoubleGauge cpuTotal;
public AnimalController(OpenTelemetry sdk) {
this.sdk = sdk;
Meter appMeter = sdk.getMeter(INSTRUMENTATION_SCOPE + ".app");
this.appMeter = appMeter;
this.battlesTotal = createCounter(appMeter);
Meter memoryMeter = sdk.getMeter(INSTRUMENTATION_SCOPE + ".memory");
this.memoryMeter = memoryMeter;
this.cpuTotal = createGauge(memoryMeter);
}
}카운터와 게이지 생성
static LongCounter createCounter(Meter meter) {
return meter
.counterBuilder("battles.total")
.setDescription("Counts total battles fought.")
.build();
}
static ObservableDoubleGauge createGauge(Meter meter) {
return meter
.gaugeBuilder("jvm.memory.total")
.setDescription("Reports JVM memory usage.")
.setUnit("By")
.buildWithCallback(
result -> result.record(Runtime.getRuntime().totalMemory(),
Attributes.empty()));
}카운터 vs 게이지의 동작 차이
| 계측기 | 동작 |
|---|---|
| 카운터 | 마이크로미터와 매우 유사. battlesTotal.add(1)처럼 새 battle이 발생할 때마다 값을 증가 |
| 게이지 | buildWithCallback()이 콜백 함수를 전달받음. 콜백은 게이지가 관찰될 때만 실행되며, 여러 게이지가 있을 때 실행 순서는 보장되지 않음 |
이 분기에서는 animal_service에서만 지표를 OTel 컬렉터로 전송하도록 설정되어 있어 로그 노이즈를 방지합니다. 컬렉터의 지표 파이프라인은 debug 익스포터만 설정되어 있어 별도 백엔드 설정이 필요 없습니다. JVM 수준 지표 수집과 자바 관리 확장 프로그램, JFR 통합도 지원됩니다.
마이크로미터 + OTel 결합 (micrometer_with_otel 분기)
OTel 지표 API를 직접 쓰는 것보다 많은 팀은 마이크로미터 같은 유연한 퍼사드 접근을 선호합니다. micrometer_with_otel 분기는 마이크로미터와 OTel 익스포터를 함께 쓰는 예제이며, 마이크로미터용 OTel 익스포터를 제공하는 micrometer-registry-otlp 라이브러리를 의존성으로 포함합니다.
micrometer-registry-otlp 의존성
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-otlp</artifactId>
<scope>runtime</scope>
</dependency>마이크로미터 라이브러리에서 제공하므로 OTel BOM을 추가할 필요가 없으며, POM에서 OTel와 직접적으로 연결되지 않습니다. 실제로 이 분기의 AnimalController 코드는 micrometer_only 분기 코드와 완전히 동일합니다.
마이크로미터 OTel 레지스트리 설정
management.otlp.metrics.export.url=http://otel-collector:4318/v1/metrics
management.otlp.metrics.export.step=10s현재 마이크로미터 레지스트리는 압축된 HTTP만 지원하며, gRPC는 지원하지 않습니다. 따라서 컬렉터가 HTTP 기반 OTLP 요청을 받을 수 있도록 docker-compose.yml ports 영역에 "4318:4318"을 추가합니다. 이 설정을 추가하면 gRPC 포트뿐만 아니라 HTTP 포트도 열리게 됩니다.
11.6 자바에서 오픈텔레메트리 로그 적용
OTel의 로그 지원은 자바 개발자들이 이미 익숙한 퍼사드 패턴을 따르도록 진행되었습니다. 따라서 로그를 다루는 방식은 추적·지표와 조금 다릅니다. 추적과 지표의 경우 먼저 저수준의 ‘raw’ OTel API를 살펴본 후 대체 접근 방식을 설명했지만, 로그는 처음부터 기존 프레임워크와의 통합을 우선합니다.
로그 브릿지는 권장하지 않음
OTel는 로그를 OTel 파이프라인으로 전송할 수 있도록 하는 로그 브릿지(logs bridge) API를 제공합니다. 그러나 대부분의 팀에서는 이 접근 방식을 사용하지 않는 것이 좋습니다. 기존 방식과 코드베이스를 너무 많이 변경해야 하기 때문입니다.
두 가지 대안
| 방식 | 동작 | 특징 |
|---|---|---|
| 파일 기반 앱 추가기 | 서비스에서 파일 기반 appender로 로그를 파일에 기록, 컬렉터가 이 파일을 수집해 OTLP로 백엔드 전달 | 아키텍처적 중립성이 있어 보이지만 실제로 유연성이 부족함 |
| OTel 계측 라이브러리 | 선택한 로깅 프레임워크의 로그를 컬렉터로 전송 (보통 추적·지표와 함께 로그를 내보냄) | 초기 설정에 더 많은 노력, 중기적으로 유지보수 노력 감소 |
두 번째 옵션에 집중합니다.
명시적 의존성 + 자동 주입
에이전트 기반 추적이나 마이크로미터 기반 지표와 달리, 로그에는 OTel에 대한 명시적 의존성을 포함하지 않는 서비스 제공자 인터페이스나 퍼사드 API가 존재하지 않습니다. POM에서 OTel 라이브러리에 직접 의존성을 추가할 수밖에 없습니다. 지표와 마찬가지로 OpenTelemetry bean을 자동 주입(auto-wiring)할 수 있도록 설정해야 합니다.
LogbackAppenderConfig
@ConditionalOnClass(LoggerContext.class)
@ConditionalOnProperty(name = "otel.instrumentation.logback.enabled",
matchIfMissing = true)
@Configuration
static class LogbackAppenderConfig {
@Bean
ApplicationListener<ApplicationReadyEvent> logbackOtelAppenderInitializer(
OpenTelemetry openTelemetry) {
return event -> OpenTelemetryAppender.install(openTelemetry);
}
}POM 의존성
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-logback-appender-1.0</artifactId>
<version>2.0.0-alpha</version>
</dependency>logback.xml
<appender name="OpenTelemetry"
class="io.opentelemetry.instrumentation.logback.appender.v1_0.
OpenTelemetryAppender">
<captureExperimentalAttributes>true</captureExperimentalAttributes>
<captureKeyValuePairAttributes>true</captureKeyValuePairAttributes>
</appender>application.properties
otel.instrumentation.logback.enabled=true이를 통해 AnimalApplication 클래스에서 조건부 bean을 활성화할 수 있습니다. 개발자는 기존과 동일하게 SLF4J와 로그백을 사용할 수 있으며, 로그는 OTel 컬렉터로 전송된 후 로그 백엔드로 전달됩니다.
권장 통합 스택
추적·지표·로그를 종합한 자바 애플리케이션의 권장 스택은 다음과 같습니다.
| 신호 | 계측 | 데이터 흐름 |
|---|---|---|
| 추적 | 자바 에이전트 | 자바 에이전트 → OTLP 익스포터 → OTel 컬렉터 → 예거 |
| 지표 | OTLP 레지스트리를 포함한 마이크로미터 | 마이크로미터 → OTLP 익스포터 → OTel 컬렉터 → 프로메테우스 |
| 로그 | OTLP 로깅 추가기를 포함한 로그백 | SLF4J/로그백 → OTLP 익스포터 → OTel 컬렉터 → Loki |
컬렉터의 진가: 간접 계층
로컬 OTel 컬렉터(일반적으로 클러스터당 하나)는 유용한 간접 계층(indirection point) 역할을 합니다. 이를 통해 아키텍처 변경과 다중 발행(multipublication) 같은 작업이 더 쉬워지며, 개발자가 관측성의 아키텍처 세부 사항을 신경 쓰지 않아도 됩니다.
물론 이것이 유일한 아키텍처 방식은 아닙니다. OTel 추적은 수동 또는 자동 방식으로 설정할 수 있으며, 프로메테우스는 OTel 컬렉터에서 데이터를 직접 가져오거나 원격 쓰기를 통해 수신할 수 있습니다. 궁극적으로 중요한 것은 시스템의 전체적인 아키텍처를 이해하는 것입니다. 현재 상태뿐만 아니라 향후 발전 가능성까지 고려해야 합니다.
11.7 요약
자바 클라우드 애플리케이션에서 관측성을 구현하는 실용적인 방법을 살펴봤습니다.
핵심 기술인 마이크로미터·프로메테우스·OTel를 결합하여 완전한 오픈 소스 관측성 시스템을 구축할 수 있습니다. 가능한 한 개발 팀의 기존 업무 방식에 영향을 주지 않는 접근에 초점을 맞췄습니다. OTel 에이전트를 사용한 자동 추적, 마이크로미터 레지스트리를 활용한 OTel 기반 지표 제공, OTel 컬렉터와 OTLP가 모두 이런 철학을 따릅니다.
애플리케이션 프로파일링이라는 중요한 주제는 이 장에서 다루지 않았습니다. 매우 중요하지만 규모가 크고, 이 장에서 소개한 관측성 프레임워크와 직접적으로 연결되지 않기 때문입니다.
비교 / 트레이드오프
퍼사드 vs 직접 호출 (마이크로미터·OTel 공통 패턴)
| 항목 | 퍼사드 (마이크로미터 / OTel) | 백엔드 SDK 직접 호출 |
|---|---|---|
| 백엔드 교체 | 의존성 + 설정 변경 | 코드 전체 수정 |
| 학습 곡선 | 한 API로 여러 백엔드 커버 | 백엔드별 별도 학습 |
| 미세 제어 | SPI 추상화로 일부 제약 | 백엔드 전체 기능 사용 |
서버 풀(스크레이핑) vs 클라이언트 푸시
| 항목 | 서버 풀 (프로메테우스) | 클라이언트 푸시 (데이터독·OTLP) |
|---|---|---|
| 서비스 디스커버리 | 필요 | 불필요 |
| 수명 짧은 작업 | 어려움 → 푸시 게이트웨이 | 자연스러움 |
| 보안 모델 | 쿠버네티스와 잘 맞음 | 방화벽 통과 필요 |
수동 추적 vs 자동 추적
| 항목 | 수동 추적 | 자동 추적 (에이전트) |
|---|---|---|
| 코드 변경 | 모든 서비스에 스팬 코드 | 불필요 |
| 컴파일 시점 의존성 | OTel API/SDK 필요 | POM에 OTel 의존성 없음 |
| 커버리지 | 개발자가 신경 쓴 곳만 | 100+ 라이브러리 기본 지원 |
| 비즈니스 의미 부여 | 자유롭게 가능 | 도메인 스팬은 별도 추가 필요 |
내 생각
퍼사드의 진짜 가치는 ‘교체 가능성’이 아니라 ‘코드와 인프라의 분리’
마이크로미터·OTel의 흔한 세일즈 포인트는 “백엔드를 데이터독으로 바꿀 수 있다”이지만, 실무에서 백엔드를 실제로 갈아엎는 일은 잘 없습니다. 더 큰 가치는 개발자가 백엔드 세부 사항을 몰라도 된다는 점입니다. 신규 입사자가 첫날 registry.counter("...") 한 줄로 지표를 추가할 수 있다는 게 진짜 효과입니다.
사전 계산된 백분위수를 다시 집계하지 말라
인스턴스 3개의 p99를 평균 내서 “전체 p99”라고 부르는 건 통계적으로 의미 없는 숫자입니다. 백엔드에서 raw 분포 데이터를 합쳐 다시 계산하든지, 처음부터 인스턴스별 p99만 보고 클러스터 전체 p99는 별도로 백엔드가 계산하게 해야 합니다.
자동 추적과 수동 추적은 “둘 중 하나”가 아니라 “둘 다”
자동 추적이 100개 라이브러리를 커버해도 비즈니스 의미가 있는 스팬(결제 게이트웨이 호출, 재고 차감 등 도메인 경계)은 자동 에이전트가 알 수 없습니다. 인프라성 스팬은 자동, 도메인성 스팬은 수동의 하이브리드가 정답입니다.
OTel 컬렉터의 진가는 간접 계층이다
컬렉터를 “데이터 모으는 박스”로만 보면 가치를 절반밖에 못 씁니다. 진짜 역할은 관측성 신호의 라우터입니다. 같은 추적을 예거와 데이터 웨어하우스에 동시에 보내고, 컬렉터 단에서 PII를 마스킹할 수 있습니다. 컬렉터 설정만 바꾸면 끝나므로 애플리케이션 재배포 없이 관측성 아키텍처를 진화시킬 수 있습니다.
더 알아볼 것
-
MeterFilter로CompositeMeterRegistry커스터마이징해서 일부 지표만 보조 백엔드로 보내는 패턴 실험 - 분포 요약에서
publishPercentileHistogram()vspublishPercentiles()차이 - PromQL 핵심 함수:
rate,irate,histogram_quantile,sum by - 카디널리티 폭발 방지: 어떤 라벨을 차원으로 쓰고 어떤 것을 쓰지 말아야 하나
- OTel 컬렉터의
tail_sampling프로세서로 테일 기반 샘플링 구성 - OTel 시맨틱 컨벤션(
SemanticAttributes.*) 변화 추적과 마이그레이션 전략 - W3C Trace Context와 OTel의 컨텍스트 전파 메커니즘
- OTel 로그-추적 상관관계(log-trace correlation) 자동 주입 동작
관련 개념
출처
자바 최적화 2판 (오라일리), Ben Evans 외 저, Chapter 11: 자바에서 관측성 구현