관찰성 — Health Check, Metrics, OpenTelemetry로 운영 준비하기
서비스가 정상인지 어떻게 알 수 있을까? 로그를 뒤져보기 전에, 서비스 스스로가 "나 괜찮아" 또는 "나 지금 좀 이상해"라고 알려줄 수 있다면?
관찰성(Observability)이란
관찰성은 시스템의 내부 상태를 외부에서 파악할 수 있는 능력입니다. 보통 세 가지 축(pillar)으로 나눕니다.
- Metrics: 수치로 표현되는 측정값 (요청 수, 응답 시간, 메모리 사용량)
- Traces: 요청이 여러 서비스를 거치는 경로 추적
- Logs: 이벤트 기록
Quarkus는 이 세 가지를 모두 프레임워크 수준에서 지원합니다. 확장만 추가하면 대부분의 기본 설정이 자동으로 잡히기 때문에 초기 구축이 빠릅니다.
MicroProfile Health — 헬스 체크
MicroProfile Health는 애플리케이션의 상태를 HTTP 엔드포인트로 노출하는 표준 스펙입니다.
./mvnw quarkus:add-extension -Dextensions="smallrye-health"
확장을 추가하는 것만으로 세 개의 엔드포인트가 생깁니다.
| 엔드포인트 | 용도 | 어노테이션 |
|---|---|---|
/q/health/live | 프로세스가 살아있는지 | @Liveness |
/q/health/ready | 요청을 처리할 준비가 되었는지 | @Readiness |
/q/health/started | 시작이 완료되었는지 | @Startup |
/q/health | 위 세 가지 모두 통합 | - |
기본적으로 데이터소스, 메시지 브로커 등의 연결 상태를 자동으로 체크합니다. 확장만 추가하면 별도의 코드 없이도 이렇게 응답합니다.
// GET /q/health
{
"status": "UP",
"checks": [
{
"name": "Database connections health check",
"status": "UP",
"data": {
"<default>": "UP"
}
}
]
}
커스텀 Health Check 구현
비즈니스 로직에 맞는 헬스 체크를 직접 만들 수 있습니다.
@Liveness
@ApplicationScoped
public class ExternalApiHealthCheck implements HealthCheck {
@Inject
ExternalApiClient apiClient;
@Override
public HealthCheckResponse call() {
try {
apiClient.ping();
return HealthCheckResponse.up("외부 API 연결");
} catch (Exception e) {
return HealthCheckResponse.named("외부 API 연결")
.down()
.withData("error", e.getMessage())
.build();
}
}
}
@Readiness
@ApplicationScoped
public class CacheHealthCheck implements HealthCheck {
@Inject
CacheManager cacheManager;
@Override
public HealthCheckResponse call() {
boolean cacheAvailable = cacheManager.isAvailable();
return HealthCheckResponse.named("캐시 서비스")
.status(cacheAvailable)
.withData("provider", "Redis")
.build();
}
}
@Liveness는 프로세스가 정상인지를 판단합니다. 이 체크가 실패하면 Kubernetes가 Pod를 재시작합니다. 반면 @Readiness는 트래픽을 받을 준비가 되었는지를 판단합니다. 실패하면 로드밸런서에서 제외되지만 Pod가 재시작되지는 않습니다.
Liveness 체크에 외부 의존성(DB, 캐시 등)을 넣으면 위험할 수 있습니다. DB가 잠깐 느려졌을 때 Pod가 불필요하게 재시작될 수 있기 때문입니다. 외부 의존성은 Readiness에 넣는 것이 일반적인 패턴입니다.
Kubernetes Probe 연동
Kubernetes 확장과 함께 사용하면 Probe가 자동으로 매핑됩니다.
# Health Check + Kubernetes 확장이 함께 있으면 자동 설정
# 필요시 커스터마이징
quarkus.kubernetes.liveness-probe.initial-delay=5s
quarkus.kubernetes.liveness-probe.period=10s
quarkus.kubernetes.readiness-probe.initial-delay=5s
quarkus.kubernetes.readiness-probe.failure-threshold=3
quarkus.kubernetes.startup-probe.initial-delay=0s
quarkus.kubernetes.startup-probe.period=2s
quarkus.kubernetes.startup-probe.failure-threshold=30
Micrometer Metrics — 메트릭 수집
Quarkus는 Micrometer를 메트릭 수집 라이브러리로 사용합니다. Spring Boot에서도 같은 라이브러리를 쓰기 때문에 익숙한 분들이 많을 겁니다.
./mvnw quarkus:add-extension -Dextensions="micrometer-registry-prometheus"
이 확장 하나로 /q/metrics 엔드포인트가 활성화되고, Prometheus가 스크래핑할 수 있는 포맷으로 메트릭이 노출됩니다.
자동 수집 메트릭
확장만 추가하면 다음 메트릭들이 자동으로 수집됩니다.
- **JVM 메트릭 **: 힙 메모리, GC, 스레드 수, 클래스 로딩
- **HTTP 서버 메트릭 **: 요청 수, 응답 시간, 상태 코드별 카운트
- ** 시스템 메트릭 **: CPU 사용률, 파일 디스크립터
- ** 데이터소스 메트릭 **: 커넥션 풀 상태 (Agroal 사용 시)
# GET /q/metrics 응답 일부
# HTTP 요청 메트릭
http_server_requests_seconds_count{method="GET",uri="/api/users",status="200"} 1523.0
http_server_requests_seconds_sum{method="GET",uri="/api/users",status="200"} 45.3
# JVM 힙 메모리
jvm_memory_used_bytes{area="heap",id="G1 Eden Space"} 52428800.0
jvm_memory_max_bytes{area="heap",id="G1 Old Gen"} 536870912.0
# 커넥션 풀
agroal_active_count{datasource="default"} 5
agroal_available_count{datasource="default"} 15
커스텀 메트릭
비즈니스 메트릭을 직접 정의할 수도 있습니다.
@ApplicationScoped
@Path("/orders")
public class OrderResource {
@Inject
MeterRegistry registry;
// Counter: 주문 생성 횟수
@POST
public Response createOrder(OrderDto order) {
Order created = orderService.create(order);
registry.counter("orders.created",
"type", order.getType(),
"region", order.getRegion()
).increment();
return Response.created(URI.create("/orders/" + created.getId())).build();
}
// Timer: 외부 결제 API 호출 시간 측정
public PaymentResult processPayment(PaymentRequest request) {
return registry.timer("payment.processing")
.record(() -> paymentClient.process(request));
}
// Gauge: 현재 처리 대기 중인 주문 수
@PostConstruct
void initGauges() {
registry.gauge("orders.pending", orderQueue, Queue::size);
}
}
어노테이션 기반으로도 메트릭을 추가할 수 있습니다.
@ApplicationScoped
public class NotificationService {
@Counted(value = "notifications.sent", description = "발송된 알림 수")
@Timed(value = "notifications.duration", description = "알림 발송 소요 시간")
public void sendNotification(String userId, String message) {
// 알림 발송 로직
}
}
Prometheus 설정
Prometheus에서 Quarkus 메트릭을 스크래핑하려면 prometheus.yml에 타겟을 추가합니다.
# prometheus.yml
scrape_configs:
- job_name: 'quarkus-app'
metrics_path: '/q/metrics'
scrape_interval: 15s
static_configs:
- targets: ['my-app:8080']
Kubernetes 환경에서는 ServiceMonitor를 사용하는 것이 일반적입니다.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: my-app-monitor
spec:
selector:
matchLabels:
app.kubernetes.io/name: my-app
endpoints:
- port: http
path: /q/metrics
interval: 15s
OpenTelemetry — 분산 추적
마이크로서비스 환경에서 하나의 요청이 여러 서비스를 거칠 때, 어디서 지연이 발생하는지 추적하려면 분산 추적(Distributed Tracing)이 필요합니다.
./mvnw quarkus:add-extension -Dextensions="opentelemetry"
# application.properties
# OTLP 엔드포인트 설정 (Jaeger, Tempo 등)
quarkus.otel.exporter.otlp.endpoint=http://jaeger:4317
# 서비스 이름
quarkus.otel.resource.attributes=service.name=my-app
# 샘플링 비율 (1.0 = 100%, 0.1 = 10%)
quarkus.otel.traces.sampler=parentbased_traceidratio
quarkus.otel.traces.sampler.arg=1.0
자동 계측
OpenTelemetry 확장을 추가하면 다음 항목들이 자동으로 계측됩니다.
- **HTTP 서버 **: 들어오는 모든 요청에 트레이스 생성
- **HTTP 클라이언트 **: RestClient/WebClient로 나가는 요청에 트레이스 전파
- gRPC: gRPC 요청/응답 트레이싱
- ** 데이터베이스 **: JDBC 쿼리 트레이싱
- ** 메시징 **: Kafka, AMQP 메시지 트레이싱
별도의 코드 없이도 서비스 간 트레이스가 전파됩니다. 서비스 A가 서비스 B를 호출하면, 같은 Trace ID로 묶여서 Jaeger/Tempo에서 하나의 요청 흐름으로 볼 수 있습니다.
커스텀 Span 추가
자동 계측으로 부족한 경우, 특정 비즈니스 로직에 Span을 직접 추가할 수 있습니다.
@ApplicationScoped
public class RecommendationService {
@Inject
Tracer tracer;
public List<Product> getRecommendations(String userId) {
Span span = tracer.spanBuilder("recommendations.compute")
.setAttribute("user.id", userId)
.startSpan();
try (Scope scope = span.makeCurrent()) {
// 추천 알고리즘 실행
List<Product> candidates = fetchCandidates(userId);
span.addEvent("후보 상품 조회 완료", Attributes.of(
AttributeKey.longKey("candidate.count"), (long) candidates.size()
));
List<Product> ranked = rankProducts(candidates, userId);
span.addEvent("랭킹 완료");
return ranked;
} catch (Exception e) {
span.setStatus(StatusCode.ERROR, e.getMessage());
span.recordException(e);
throw e;
} finally {
span.end();
}
}
}
더 간단하게 어노테이션으로도 가능합니다.
@ApplicationScoped
public class PaymentService {
@WithSpan("payment.process")
public PaymentResult processPayment(
@SpanAttribute("payment.method") String method,
@SpanAttribute("payment.amount") double amount) {
// 결제 로직
return new PaymentResult(true);
}
}
Jaeger/Tempo로 트레이스 확인
Jaeger를 로컬에서 빠르게 확인하려면 다음과 같이 실행합니다.
docker run -d --name jaeger \
-p 16686:16686 \ # Jaeger UI
-p 4317:4317 \ # OTLP gRPC
jaegertracing/all-in-one:latest
브라우저에서 http://localhost:16686에 접속하면 트레이스를 시각적으로 확인할 수 있습니다. 각 Span의 소요 시간, 속성, 이벤트 등을 상세히 볼 수 있어서 성능 병목을 찾는 데 유용합니다.
로깅
Quarkus는 JBoss Logging 을 기본 로깅 프레임워크로 사용합니다. 개발자 입장에서는 java.util.logging, SLF4J, Log4j 등 어떤 로깅 API를 사용해도 내부적으로 JBoss Logging으로 통합됩니다.
import io.quarkus.logging.Log;
@ApplicationScoped
public class UserService {
public User findUser(String id) {
Log.infof("사용자 조회: id=%s", id);
// ...
}
}
JSON 포맷 로깅
프로덕션에서는 구조화된 로그가 중요합니다. 로그 수집 도구(ELK, Loki 등)가 파싱하기 쉽기 때문입니다.
./mvnw quarkus:add-extension -Dextensions="logging-json"
# JSON 로깅 활성화
quarkus.log.console.json=true
# 추가 필드
quarkus.log.console.json.additional-field.service.value=my-app
quarkus.log.console.json.additional-field.environment.value=production
출력 결과가 이렇게 바뀝니다.
{
"timestamp": "2026-03-28T10:15:30.123Z",
"level": "INFO",
"loggerName": "c.e.UserService",
"message": "사용자 조회: id=user-123",
"service": "my-app",
"environment": "production",
"traceId": "abc123def456",
"spanId": "789ghi"
}
OpenTelemetry 확장과 함께 사용하면 traceId와 spanId가 자동으로 로그에 포함됩니다. 로그에서 특정 트레이스 ID를 검색하면 해당 요청의 모든 로그를 한눈에 볼 수 있어서, 메트릭-트레이스-로그 세 축을 연결하는 핵심 고리가 됩니다.
로그 레벨 설정
# 전체 로그 레벨
quarkus.log.level=INFO
# 패키지별 로그 레벨
quarkus.log.category."com.example".level=DEBUG
quarkus.log.category."org.hibernate.SQL".level=DEBUG
# 파일 출력
quarkus.log.file.enable=true
quarkus.log.file.path=/var/log/my-app.log
quarkus.log.file.rotation.max-file-size=10M
quarkus.log.file.rotation.max-backup-index=5
DevServices for Observability
공부하면서 특히 편리했던 기능이 DevServices입니다. Dev Mode에서 관찰성 도구를 자동으로 띄워줍니다.
# Dev Mode 실행
./mvnw quarkus:dev
관련 확장이 추가되어 있으면 Dev Mode 시작 시 다음을 자동으로 실행합니다.
- Grafana: 대시보드 UI (기본 대시보드 포함)
- Prometheus: 메트릭 수집 (스크래핑 설정 자동)
- Jaeger/OTLP Collector: 트레이스 수집
Dev UI(http://localhost:8080/q/dev-ui)에서 이 도구들의 링크를 바로 확인할 수 있습니다. 로컬 개발 중에 실제 프로덕션과 유사한 관찰성 환경을 별도의 설정 없이 경험할 수 있다는 점이 인상적이었습니다.
# DevServices 관련 설정 (기본값으로도 충분하지만 커스터마이징 가능)
quarkus.otel.exporter.otlp.endpoint=http://localhost:4317
# Dev Mode에서만 전체 트레이스 수집
%dev.quarkus.otel.traces.sampler.arg=1.0
# 프로덕션에서는 10%만 수집
%prod.quarkus.otel.traces.sampler.arg=0.1
Spring Actuator와 비교
Spring Boot에서 넘어온 개발자라면 Actuator와 자연스럽게 비교하게 됩니다.
| 항목 | Spring Boot Actuator | Quarkus |
|---|---|---|
| Health 엔드포인트 | /actuator/health | /q/health |
| Metrics 엔드포인트 | /actuator/metrics | /q/metrics |
| 메트릭 라이브러리 | Micrometer | Micrometer (동일) |
| 분산 추적 | Micrometer Tracing + Brave/OTel | OpenTelemetry |
| 로깅 | Logback/Log4j2 | JBoss Logging |
| Info 엔드포인트 | /actuator/info | /q/info (확장 필요) |
| 커스텀 엔드포인트 | @Endpoint 어노테이션 | 없음 (REST 엔드포인트로 직접 구현) |
큰 차이점 중 하나는 Quarkus가 OpenTelemetry를 1등 시민(first-class citizen)으로 지원한다는 것입니다. Spring Boot도 3.x부터 OpenTelemetry를 지원하지만, Quarkus는 처음부터 OTel 중심으로 설계되었기 때문에 자동 계측의 범위가 넓고 설정이 간결합니다.
반면 Spring Boot Actuator는 더 많은 종류의 엔드포인트(beans, env, configprops 등)를 기본 제공합니다. Quarkus에서는 이런 정보를 Dev UI에서 확인할 수 있지만, 프로덕션 엔드포인트로는 노출되지 않습니다.
실전 관찰성 스택 예시
프로덕션에서 Quarkus 관찰성 스택을 구성하면 보통 이런 형태가 됩니다.
Quarkus App
├── /q/health → Kubernetes Probe
├── /q/metrics → Prometheus → Grafana (대시보드)
└── OTLP export → Tempo/Jaeger (트레이싱)
→ Loki (로그 수집)
필요한 확장과 설정을 한 번에 정리하면 이렇습니다.
# application.properties — 관찰성 통합 설정
# Health
# (smallrye-health 확장만 추가하면 자동 설정)
# Metrics → Prometheus
# (micrometer-registry-prometheus 확장)
quarkus.micrometer.export.prometheus.path=/q/metrics
# OpenTelemetry → Tempo/Jaeger
quarkus.otel.exporter.otlp.endpoint=http://tempo:4317
quarkus.otel.resource.attributes=service.name=my-app,service.version=1.0.0
# 프로파일별 샘플링 비율
%dev.quarkus.otel.traces.sampler.arg=1.0
%prod.quarkus.otel.traces.sampler.arg=0.1
# JSON 로깅
%prod.quarkus.log.console.json=true
정리
관찰성은 "있으면 좋은 것"이 아니라 프로덕션 운영의 필수 요소입니다. Quarkus는 확장 몇 개를 추가하는 것만으로 Health Check, Metrics, Tracing, 구조화 로깅까지 갖출 수 있게 해줍니다.
기억해둘 포인트를 정리하면 이렇습니다.
- Health Check: Liveness에는 자체 상태만, Readiness에 외부 의존성 체크를 넣는 것이 안전한 패턴
- Metrics: Micrometer 기반이므로 Spring Boot와 코드 호환성이 높음.
@Counted,@Timed,MeterRegistry모두 사용 가능 - OpenTelemetry: 확장 추가만으로 HTTP, DB, 메시징의 자동 계측이 활성화됨.
@WithSpan으로 커스텀 Span 추가 - ** 로깅 **: JSON 포맷 + OpenTelemetry 연동으로 traceId가 로그에 자동 포함되어 메트릭-트레이스-로그 연결이 가능
- DevServices: Dev Mode에서 Grafana, Prometheus, Jaeger가 자동 시작되어 로컬에서도 관찰성을 체험할 수 있음