이 글은 Vultr 서버에서 운영 중인 Spring Boot 4 애플리케이션에 풀 옵저버빌리티 스택(메트릭, 로그, 트레이스)을 구축한 과정을 기록한 것이다. Grafana LGTM 스택을 기반으로 Grafana(시각화), Prometheus(메트릭), Loki(로그), Tempo(트레이스)를 구성했다.
환경
| 구성요소 | 버전 |
|---|---|
| OS | AlmaLinux 10.1 |
| 서버 사양 | Vultr (2 CPU cores, 4GB RAM) |
| Spring Boot | 4.x |
| Java | 21 (Amazon Corretto) |
| Grafana | OSS latest |
| Loki | latest |
| Tempo | 2.6.1 |
| Prometheus | latest |
아키텍처

앱은 컨테이너가 아닌 호스트에서 직접 실행되기 때문에 모니터링 인프라만 Docker로 구성했다.
1. 서버 환경 구성
Docker 설치 (AlmaLinux 10)
sudo dnf install -y docker
sudo systemctl start docker
sudo systemctl enable docker
# docker 그룹에 유저 추가 (재로그인 필요)
sudo usermod -aG docker $USER
newgrp docker
방화벽 포트 오픈
sudo dnf install -y firewalld
sudo systemctl start firewalld
sudo systemctl enable firewalld
sudo firewall-cmd --permanent --add-port=3000/tcp # Grafana
sudo firewall-cmd --reload
sudo firewall-cmd --list-ports
Grafana만 열어줬는데, 나중에는 Nginx로 프록시 라우팅 처리해주면서 지금은 저 것도 닫았다(...)
Vultr 콘솔의 Firewall Group에서도 동일한 포트를 열어줘야 외부에서 접근 가능하다. Firewall Group을 서버에 연결하는 것도 필수.
2. Docker Compose 구성
version: '3.9'
services:
grafana:
image: grafana/grafana-oss:latest
container_name: grafana
volumes:
- ./grafana/data:/var/lib/grafana
ports:
- "3000:3000"
expose:
- 3000
environment:
- TZ=Asia/Seoul
- GF_SERVER_ROOT_URL=http://localhost/grafana
- GF_SERVER_SERVE_FROM_SUB_PATH=true
- GF_SERVER_DOMAIN=localhost
- GF_SECURITY_ALLOW_EMBEDDING=true
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_NAME=Main Org.
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
- GF_AUTH_ANONYMOUS_HIDE_VERSION=true
- GF_USERS_VIEWERS_CAN_EDIT=false
extra_hosts:
- 'host.docker.internal:host-gateway'
depends_on:
- prometheus
- loki
- grafana_tempo
networks:
- sre-net
mem_limit: 256m
cpus: "0.25"
prometheus:
image: prom/prometheus:latest
container_name: prometheus
volumes:
- ./prometheus/config:/etc/prometheus
- ./prometheus/volume:/prometheus
ports:
- "9100:9090"
expose:
- 9100
command:
- '--web.enable-lifecycle'
- '--enable-feature=exemplar-storage'
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.retention.time=7d'
restart: always
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
- TZ=Asia/Seoul
networks:
- sre-net
mem_limit: 256m
cpus: "0.75"
loki:
image: grafana/loki:latest
container_name: loki
volumes:
- ./loki/config:/mnt/config
- ./loki/volume:/loki
ports:
- "3100:3100"
expose:
- 3100
command:
- '--config.file=/mnt/config/loki-config.yml'
restart: always
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
- TZ=Asia/Seoul
networks:
- sre-net
mem_limit: 512m
cpus: "0.50"
grafana_tempo:
image: grafana/tempo:2.6.1
container_name: grafana_tempo
volumes:
- ./tempo/etc:/etc
command:
- '--target=all'
- '--storage.trace.backend=local'
- '--storage.trace.local.path=/var/tempo'
- '--config.file=/etc/tempo.yaml'
- '--auth.enabled=false'
ports:
- "14251:14250"
- "4317:4317"
- "4318:4318"
- "3200:3200"
expose:
- 14251
- 4317
- 4318
- 3200
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
- TZ=Asia/Seoul
networks:
- sre-net
mem_limit: 384m
cpus: "0.25"
networks:
sre-net:
driver: bridge
서버 사양이 넉넉하지 않아 각 컨테이너에 mem_limit을 지정해 메모리 초과를 방지한다. Spring Boot JVM 힙도 반드시 제한해야 한다.
java -Xms256m -Xmx512m -jar app.jar
3. Prometheus 설정
prometheus.yml:
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'spring-boot'
metrics_path: '/actuator/prometheus'
scrape_interval: 5s
static_configs:
- targets: ['host.docker.internal:8080']
Docker 컨테이너에서 호스트의 앱을 바라볼 때는 host.docker.internal을 사용한다.
4. Loki 설정
loki-config.yml:
auth_enabled: false # 반드시 명시 필요
server:
http_listen_port: 3100
grpc_listen_port: 9096
ingester:
wal:
enabled: true
dir: /loki/wal
lifecycler:
address: 127.0.0.1
ring:
kvstore:
store: inmemory
replication_factor: 1
final_sleep: 0s
chunk_idle_period: 1h
max_chunk_age: 1h
chunk_target_size: 1048576
chunk_retain_period: 30s
chunk_encoding: snappy
schema_config:
configs:
- from: 2024-08-10
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
storage_config:
tsdb_shipper:
active_index_directory: /loki/tsdb-index
cache_location: /loki/tsdb-cache
filesystem:
directory: /loki/chunks
compactor:
working_directory: /loki/compactor
delete_request_store: filesystem
limits_config:
reject_old_samples: true
reject_old_samples_max_age: 168h
allow_structured_metadata: true
삽질 포인트
Loki 설정에서 꽤 많은 삽질을 했다. 버전이 그동안 올라가서인지, 회사에서 재작년 구축했을 때와는 또 여러가지가 달라져 있었다.
no org id 오류: Grafana에서 Loki 데이터소스 연결 시 X-Scope-OrgID 헤더 누락으로 발생한다. auth_enabled: false를 명시하면 해결.
schema 버전 오류: 최신 Loki는 schema v13 + tsdb store 조합이 필요하다. 기존에 많이 쓰던 boltdb-shipper + v11 조합은 더 이상 권장되지 않는다.
allow_structured_metadata 오류: loki4j appender가 자동으로 structured metadata를 전송하는데, Loki 설정에서 이를 허용해줘야 한다.
5. Tempo 설정
Tempo v2.10 다운그레이드 이유
처음에는 최신 버전인 v2.10을 사용했는데, empty ring 오류가 계속 발생했다. 알고 보니 v2.10부터 Rhythm이라는 새로운 아키텍처가 도입되면서 Kafka가 필수 의존성이 되었다. 서버 사양 문제로 Kafka를 추가하기 어려워 v2.6.1로 다운그레이드했다. 나중에 Aiven Cloud의 Kafka로 재도전해보기로!
tempo.yaml:
stream_over_http_enabled: true
server:
http_listen_port: 3200
memberlist:
join_members: []
distributor:
receivers:
jaeger:
protocols:
thrift_http:
endpoint: 0.0.0.0:14268
grpc:
endpoint: 0.0.0.0:14250
zipkin:
endpoint: 0.0.0.0:9411
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
grpc:
endpoint: 0.0.0.0:4317
ingester:
max_block_duration: 5m
lifecycler:
ring:
kvstore:
store: memberlist
replication_factor: 1
compactor:
compaction:
block_retention: 24h
storage:
trace:
backend: local
local:
path: /tmp/tempo/blocks
wal:
path: /tmp/tempo/wal
overrides:
defaults:
metrics_generator:
processors: []
6. Spring Boot 4 설정
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-opentelemetry'
implementation 'io.micrometer:micrometer-tracing-bridge-otel'
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation 'com.github.loki4j:loki-logback-appender:1.5.2'
// JDBC SQL 트레이싱 (선택, 나는 OpenTelemetry Agent를 사용했기 때문에 잠깐 넣었다가 뺐다.)
implementation 'io.opentelemetry.instrumentation:opentelemetry-jdbc:2.25.0-alpha'
}
application-live.yml
spring:
application:
name: lifelog
management:
endpoints:
web:
exposure:
include: health, info, prometheus
metrics:
tags:
application: ${spring.application.name}
distribution:
percentiles-histogram:
http.server.requests: true
percentiles:
http.server.requests: 0.5, 0.9, 0.95, 0.99
prometheus:
metrics:
export:
enabled: true
# Spring Boot 4의 OTLP 메트릭 자동 export 비활성화 (Prometheus 스크랩 방식 사용)
otlp:
metrics:
export:
enabled: false
defaults:
metrics:
export:
enabled: false
tracing:
sampling:
probability: 1.0 # 운영환경에서는 0.1 권장
opentelemetry:
tracing:
export:
otlp:
endpoint: http://서버IP:4318/v1/traces
Spring Boot 4 메트릭 삽질 포인트
Spring Boot 4는 OTLP 메트릭 export가 기본으로 활성화된다. Prometheus로 메트릭을 수집하는 경우 OTLP export를 명시적으로 꺼야 한다. 끄지 않으면 Tempo로 메트릭을 보내려다 404 오류가 계속 발생한다.
JDBC SQL 트레이싱 (Kotlin)
spring-boot-starter-opentelemetry는 HTTP 레벨만 자동 계측한다. JDBC SQL을 트레이스에 포함하려면 DataSource를 래핑해야 한다.
@Configuration
class OpenTelemetryJdbcConfig(
private val openTelemetry: OpenTelemetry
) {
@Bean
fun otelDataSourceBeanPostProcessor(): BeanPostProcessor {
return object : BeanPostProcessor {
override fun postProcessAfterInitialization(bean: Any, beanName: String): Any {
if (bean is DataSource) {
return JdbcTelemetry.create(openTelemetry).wrap(bean)
}
return bean
}
}
}
}
이렇게 하면 Grafana Tempo에서 HTTP 요청 span 아래에 실행된 SQL 쿼리가 하위 span으로 표시된다.
이 것은 OpenTelemetry Agent를 사용하면 설정할 필요는 없다.
7. 로그 설정 (logback-spring.xml)
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<springProperty scope="context" name="appName" source="spring.application.name"/>
<property name="LOG_PATH" value="/product/lifelog/log"/>
<property name="LOG_FILE" value="lifelog"/>
<property name="LOG_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} trace_id=%X{traceId} span_id=%X{spanId} [%thread] %-5level %logger{60} - %msg%n"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- local/dev: 콘솔 출력만 -->
<springProfile name="default | dev">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
<!-- live: 콘솔 + 파일 + Loki -->
<springProfile name="live">
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${LOG_FILE}.log</file>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${LOG_FILE}.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
</appender>
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${LOG_FILE}-error.log</file>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${LOG_FILE}-error.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
</appender>
<appender name="LOKI" class="com.github.loki4j.logback.Loki4jAppender">
<http>
<url>http://서버IP:3100/loki/api/v1/push</url>
<connectionTimeoutMs>5000</connectionTimeoutMs>
<requestTimeoutMs>5000</requestTimeoutMs>
</http>
<format>
<label>
<pattern>application=${appName},host=${HOSTNAME},level=%level</pattern>
</label>
<message>
<pattern>${LOG_PATTERN}</pattern>
</message>
<sortByTime>true</sortByTime>
</format>
<sendQueueMaxBytes>41943040</sendQueueMaxBytes>
<batchMaxItems>1000</batchMaxItems>
<batchTimeoutMs>5000</batchTimeoutMs>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
<appender-ref ref="ERROR_FILE"/>
<appender-ref ref="LOKI"/>
</root>
</springProfile>
</configuration>
MDC 키 이름 주의
spring-boot-starter-opentelemetry + micrometer-tracing-bridge-otel 조합에서 MDC에 주입되는 키 이름은 traceId, spanId이다(camelCase). 흔히 볼 수 있는 trace_id, span_id (snake_case)가 아니기 때문에 패턴을 %X{traceId}로 써야 한다.
이 부분도 Spring Boot 2.x, 3.x 과 달라진 점이다.
8. Nginx 리버스 프록시 설정
/etc/nginx/conf.d/grafana.conf:
server {
location /grafana {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://localhost:3000/grafana;
proxy_redirect off;
}
}
Grafana 서브패스 삽질 포인트
GF_SERVER_ROOT_URL에는 프로토콜과 호스트를 포함한 전체 URL을 입력해야 한다:
GF_SERVER_ROOT_URL=http://서버IP/grafana ← 올바름
GF_SERVER_ROOT_URL=/grafana ← 리다이렉트 루프 발생
9. Grafana 데이터소스 연동
spring-boot-observability
위 Github Repository의 README.md에 다 나와있다. 그대로 따라하면 된다.
Loki → Tempo 연동 (로그에서 트레이스로)
Grafana → Connections → Loki 데이터소스 → Derived fields:
Name: TraceID
Regex: trace_id=(\w+)
Internal link: ON
Data source: Tempo
URL: ${__value.raw}
이 설정을 하면 Loki에서 로그를 볼 때 trace_id 값을 클릭해 바로 Tempo로 이동할 수 있다.
Tempo → Loki 연동 (트레이스에서 로그로)
Grafana → Connections → Tempo 데이터소스 → Trace to logs:
Data source: Loki
Tags: service.name → application
Filter by trace ID: ON
추천 대시보드
| 대시보드 | ID | 특징 |
|---|---|---|
| Spring Boot Observability | 17175 | Metrics + Traces + Logs 통합 |
| JVM (Micrometer) Spring Boot 3/4 | 19004 | Micrometer 2.x 호환, Spring Boot 4 권장 |
기존에 많이 사용하던 ID 4701은 Spring Boot 4 / Micrometer 2.x에서 일부 패널이 동작하지 않는다. jvm_threads_states → jvm_threads_states_threads 등 메트릭 이름이 변경되었기 때문이다.
10. 트러블슈팅 정리
구축 과정에서 겪은 오류들을 정리한다.
| 오류 | 원인 | 해결 |
|---|---|---|
no org id |
Loki auth_enabled 기본값 문제 |
auth_enabled: false 명시 |
loki4j 400 allow_structured_metadata |
loki4j가 structured metadata 자동 전송 | allow_structured_metadata: true 설정 |
| OTLP metrics 404 | Spring Boot 4 OTLP 자동 활성화 | management.otlp.metrics.export.enabled: false |
Tempo empty ring |
v2.10 Rhythm 아키텍처 Kafka 의존성 | Tempo v2.6.1로 다운그레이드 |
| trace_id 로그에 빈값 | MDC 키 이름 불일치 | %X{traceId} 사용 (camelCase) |
| Grafana 무한 리다이렉트 | proxy_pass 경로 중복 | proxy_pass 끝에 /만, /grafana 제거 |
OTLP unexpected end of stream |
HTTP/gRPC 프로토콜 불일치 | HTTP는 4318, gRPC는 4317 |
Grafana application files 로드 실패 |
GF_SERVER_ROOT_URL 불완전 |
프로토콜+호스트 포함 전체 URL 입력 |
마치며
재작년에 회사에서 이미 직접 구축했기 때문에 금방 끝날 줄 알았는데, Spring Boot 4의 변경사항(OTLP 기본 활성화, Micrometer 2.x 메트릭명 변경)과 최신 버전들의 breaking change(Loki schema v13, Tempo v2.10 Rhythm 아키텍처)가 맞물려 상당한 시간이 걸렸다.
완성된 스택은 https://furaiki-lifelog.com/sre에서 확인할 수 있으며, 다음 기능을 제공한다.
- 메트릭: JVM, HTTP 요청률, 레이턴시(p50/p95/p99), 에러율 (Prometheus)
- 로그: 레벨 필터링, 키워드 검색, trace_id 연동 (Loki)
- 트레이스: HTTP + JDBC SQL span 계측 (Tempo)
- 상호 연동: 로그 → 트레이스, 트레이스 → 로그 클릭 드릴다운