10배 빠른 분산 시스템 디버깅: Micrometer Tracing으로 Trace ID 통합하기
분산 추적으로 마이크로서비스의 복잡한 로그를 단 하나의 Trace ID로 추적하세요
목차
- 개요: 분산 추적의 필요성
- Spring Micrometer Tracing 핵심 이해
- 주요 환경별 Trace ID 활용
- 로그 통합 및 실전 활용
- 실무 활용 꿀팁
- 최종 구현 및 트러블슈팅
1. 개요: 분산 추적의 필요성
배경
이런 경험 있으신가요?
시나리오 1: Slack 장애 알림
#server-error 채널
🚨 [결제 API 처리 실패]
Error: Payment gateway timeout
Time: 2025-11-19 10:30:45
개발자: (OpenSearch/Kibana 접속)
개발자: (시간대 검색)
개발자: (에러 키워드 검색)
개발자: (수 많은 로그 중 관련 로그 찾기... 😓)
시나리오 2: 동시 실행 Batch Job 혼돈
[02:00:00] INFO - dailySettlementJob started
[02:00:01] INFO - monthlyReportJob started
[02:00:02] INFO - Processing settlement for 2025-11
[02:00:02] INFO - Generating report for 2025-11
[02:00:03] INFO - Settlement completed
[02:00:03] INFO - Report generation failed ← 어느 Job의 로그?
[02:00:04] ERROR - Database connection timeout
개발자: "어느 Job에서 에러가 났지?"
개발자: "Settlement? Report? 둘 다 02시 처리 중이네..."
개발자: (로그 타임스탬프 하나하나 맞춰가며 추적... 😵)
시나리오 3: Kafka Consumer 메시지 추적 불가
[10:30:45.123] INFO - Received product event: productId=12345
[10:30:45.234] INFO - Updating inventory
[10:30:45.456] INFO - Received product event: productId=67890
[10:30:45.567] INFO - Publishing notification
[10:30:45.678] ERROR - Inventory update failed ← 어떤 productId?
개발자: "12345 업데이트 실패? 67890 실패?"
개발자: "타임스탬프로 추론하면... 아니 동시 처리라 헷갈려 😫"
개발자: (동시 처리된 메시지들의 로그가 뒤섞여서 추적 불가능...)
이제는 이렇게 바뀌었습니다
Trace ID 하나로 모든 로그 추적
✅ 시나리오 1 해결: API/Slack 에러 추적
API 에러 응답:
{
"message": "결제 처리 실패",
"traceId": "690dae8f00000000de283d7148306199" ← 이거 하나만 있으면!
}
Slack 알림:
🚨 [결제 처리 실패] (log - 690dae8f00000000de283d7148306199)
↑ 클릭 시 OpenSearch 자동 검색!
OpenSearch:
검색어: "690dae8f00000000de283d7148306199"
결과: 해당 요청의 모든 로그 (15개) - 1초 만에 조회 완료! ✨
✅ 시나리오 2 해결: Batch Job 로그 구분
[02:00:00] [trace=abc123...] INFO - dailySettlementJob started
[02:00:01] [trace=def456...] INFO - monthlyReportJob started
[02:00:02] [trace=abc123...] INFO - Processing settlement for 2025-11
[02:00:02] [trace=def456...] INFO - Generating report for 2025-11
[02:00:03] [trace=abc123...] INFO - Settlement completed
[02:00:03] [trace=def456...] INFO - Report generation failed ← def456으로 즉시 식별!
[02:00:04] [trace=def456...] ERROR - Database connection timeout
OpenSearch 검색: "def456"
→ monthlyReportJob의 모든 로그만 필터링! (5초 완료) ✨
✅ 시나리오 3 해결: Kafka 메시지별 로그 추적
[10:30:45.123] [trace=aaa111...] INFO - Received product event: productId=12345
[10:30:45.234] [trace=aaa111...] INFO - Updating inventory
[10:30:45.456] [trace=bbb222...] INFO - Received product event: productId=67890
[10:30:45.567] [trace=bbb222...] INFO - Publishing notification
[10:30:45.678] [trace=aaa111...] ERROR - Inventory update failed ← aaa111 = productId 12345!
OpenSearch 검색: "aaa111"
→ productId=12345 처리 과정의 모든 로그만 추출! ✨
Trace ID 활용 목적
- API 요청 추적: 클라이언트 요청부터 응답까지의 전체 흐름
- 로그 통합: 동일 요청에서 발생한 모든 로그를 하나의 ID로 필터링
- 장애 대응: 에러 발생 시 관련 로그를 신속하게 검색
- 성능 모니터링: Zipkin 등에서 성능 병목 지점 식별
2. Spring Micrometer Tracing 핵심 이해
2.1. Spring Micrometer Tracing 소개
Spring Micrometer Tracing을 사용하여 분산 추적 시스템을 구축할 수 있습니다.
Micrometer Tracing이란?
- Spring Boot의 공식 분산 추적 추상화 레이어
- APM 도구에 종속되지 않는 벤더 중립적 설계
- Zipkin, Jaeger 등 다양한 백엔드 지원
시스템 구성
애플리케이션 코드
↓
Micrometer Tracing API (벤더 중립)
↓
Brave 구현체 (Zipkin 기반)
↓
┌─────────────┬──────────────┬──────────────┐
│ Zipkin │ Jaeger │ Elastic APM │ ← 선택 가능!
└─────────────┴──────────────┴──────────────┘
이 글에서는 Zipkin을 예시로 사용하지만 다른 도구로 교체 가능합니다.
핵심 가치
Micrometer 추상화 → APM 도구 독립성 → 요청/Job/메시지별 고유 ID
→ 로그 추적 간소화 → 장애 대응 시간 단축 → 개발 생산성 향상
통합 메커니즘: W3C TraceContext 표준 기반
- APM agent가 HTTP 요청에
traceparent헤더 주입 - Brave (Micrometer 구현체)가 이 헤더를 읽어서 동일한 trace ID 사용
- 코드 변경 없이 표준 헤더만으로 APM 통합 완성
- APM 도구 교체 코드 변경 없이 가능
2.2. Trace ID vs Span ID
Trace ID
정의: 전체 요청 흐름을 식별하는 고유 ID
특징
- 요청 전체 라이프사이클 동안 동일
- 여러 서비스를 거쳐도 변경되지 않음
- 128-bit hex 형식:
690dae8f00000000de283d7148306199
사용 시나리오
HTTP Request → Service A → Service B → Service C
└─────────── 동일 Trace ID ─────────────┘
Span ID
정의: 요청 내 개별 작업 단위를 식별하는 ID
특징
- 각 작업마다 다른 ID
- Thread가 바뀌면 새로운 Span 생성
- 64-bit hex 형식:
0123456789abcdef
사용 시나리오
HTTP Request (span: a1b2c3d4)
├─ DB Query (span: e5f6g7h8)
├─ External API Call (span: i9j0k1l2)
└─ Cache Access (span: m3n4o5p6)
→ Trace ID: 동일
→ Span ID: 각각 다름
코드로 확인하기
@RestController
class ExampleController(
private val tracer: Tracer, // Micrometer Tracer
) {
@GetMapping("/api/example")
fun example(): Map<String, String> {
val currentSpan = tracer.currentSpan()
return mapOf(
"traceId" to currentSpan.context().traceId(), // 690dae8f00000000de283d7148306199
"spanId" to currentSpan.context().spanId(), // 0123456789abcdef
"parentSpanId" to (currentSpan.context().parentId() ?: "none"),
)
}
}
응답 예시
{
"traceId": "690dae8f00000000de283d7148306199",
"spanId": "0123456789abcdef",
"parentSpanId": "fedcba9876543210"
}
2.3. 전체 아키텍처 개요
Micrometer Tracing의 핵심은 추상화 레이어를 통해 APM 도구에 종속되지 않는 구조입니다.
레이어 아키텍처
┌─────────────────────────────────────────────────────────┐
│ Layer 1: Application Code │
│ ┌──────────────────────────────────────────────────┐ │
│ │ @RestController, @Service, @Repository │ │
│ │ - 비즈니스 로직 작성 │ │
│ │ - Micrometer API만 사용 (Tracer, Span) │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────────┘
│ 의존성: Micrometer Tracer 인터페이스
▼
┌─────────────────────────────────────────────────────────┐
│ Layer 2: Micrometer Tracing (추상화 레이어) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ io.micrometer.tracing.Tracer │ │
│ │ io.micrometer.tracing.Span │ │
│ │ - 벤더 중립적 API 제공 │ │
│ │ - W3C TraceContext 표준 지원 │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────────┘
│ 구현체 선택 (둘 중 하나)
▼
┌─────────────────────────────────────────────────────────┐
│ Layer 3: Implementation Bridge (구현체) │
│ ┌────────────────────┐ ┌─────────────────────────┐ │
│ │ Brave Bridge │ │ OpenTelemetry Bridge │ │
│ │ (Zipkin 기반) │ OR│ (OTEL 기반) │ │
│ │ │ │ │ │
│ │ - Trace 생성 │ │ - Trace 생성 │ │
│ │ - Span 관리 │ │ - Span 관리 │ │
│ │ - MDC 자동 설정 │ │ - MDC 자동 설정 │ │
│ │ - W3C 헤더 생성 │ │ - W3C 헤더 생성 │ │
│ └────────────────────┘ └─────────────────────────┘ │
└────────────────────────┬────────────────────────────────┘
│ W3C TraceContext 표준 헤더
│ (traceparent: 00-<trace-id>-...)
▼
┌─────────────────────────────────────────────────────────┐
│ Layer 4: APM Backend (자유롭게 선택 가능 🔄) │
│ ┌──────┬──────┬──────────┬────────┬─────────────┐ │
│ │Zipkin│Jaeger│ Datadog │ Elastic│New Relic 등 │ │
│ │ │ │ APM │ APM │ │ │
│ └──────┴──────┴──────────┴────────┴─────────────┘ │
│ - W3C 표준 헤더로 trace 수신 │
│ - Trace 저장/집계/시각화 │
│ - 알림 및 분석 기능 │
└─────────────────────────────────────────────────────────┘
핵심 설계 원칙
1️⃣ APM 도구는 자유롭게 교체 가능
Micrometer Tracing을 사용하면 애플리케이션 코드를 변경하지 않고 APM 도구를 교체할 수 있습니다.
// ✅ 이 코드는 어떤 APM 백엔드를 사용하든 동일!
@RestController
class UserController(
private val tracer: Tracer, // Micrometer 인터페이스
) {
fun getUser(): UserResponse {
val traceId = tracer.currentSpan().context().traceId()
logger.info("Fetching user, traceId: $traceId")
// ...
}
}
APM 백엔드 교체 시나리오:
개발 환경: Zipkin (로컬 Docker)
↓
스테이징: Jaeger (쿠버네티스 클러스터)
↓
운영 환경: Elastic APM (매니지드 서비스)
→ 코드 변경 없이 설정만 변경!
2️⃣ W3C TraceContext 표준 기반
모든 APM 백엔드는 W3C 표준 헤더(traceparent)를 통해 통합됩니다.
┌─────────────────────────────────────────────────────────┐
│ 1. HTTP Request 진입 │
│ GET /api/users/12345 │
│ traceparent: 00-690dae8f...-fedcba...-01 │
└────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 2. Micrometer Tracing │
│ - traceparent 헤더 파싱 ✅ │
│ - Trace ID 추출: 690dae8f... │
│ - 새로운 Span 생성 (parent: fedcba...) │
│ - MDC.put("traceId", "690dae8f...") │
│ - MDC.put("spanId", "0123456789...") │
└────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 3. Application Code │
│ @RestController │
│ fun getUser() { │
│ logger.info("...") ← MDC의 trace ID 자동 포함 │
│ // 비즈니스 로직 실행 │
│ } │
└────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 4. 외부 서비스 호출 (RestTemplate/WebClient) │
│ GET https://api.external.com/users/12345 │
│ traceparent: 00-690dae8f...-1122334455...-01 │
│ └─ 동일한 Trace ID 자동 전파! ✅ │
└────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 5. APM Backend │
│ - 모든 서비스의 로그를 Trace ID로 그룹화 │
│ - 분산 환경 전체 플로우 시각화 │
└─────────────────────────────────────────────────────────┘
W3C traceparent 헤더 형식:
traceparent: 00-690dae8f00000000de283d7148306199-fedcba9876543210-01
│ └─ 32자리 hex (128-bit) └─ 16자리 hex (64-bit)
│ Trace ID Span ID
└─ version
3️⃣ 구현체 선택의 자유
| 구현체 | 선택 기준 | APM 백엔드 예시 |
|---|---|---|
| Brave | Spring Boot 중심 프로젝트 | Zipkin, Jaeger, Elastic APM, Tempo |
| OpenTelemetry | 다중 언어/플랫폼 환경 | Jaeger, Tempo, Datadog, Elastic APM, New Relic |
→ 핵심: 어떤 구현체를 선택하든 모든 주요 APM 백엔드와 호환됩니다!
2.4. Micrometer Tracing 핵심 이해
Micrometer Tracing이란?
Micrometer Tracing은 분산 추적(Distributed Tracing)을 위한 Spring Boot 공식 라이브러리입니다.
Spring Cloud Sleuth → Micrometer Tracing 전환
왜 Sleuth가 아니라 Micrometer Tracing인가?
| 항목 | Spring Cloud Sleuth | Micrometer Tracing |
|---|---|---|
| 지원 상태 | ⚠️ 유지보수 모드 (2021~) | ✅ 활발한 개발 |
| Spring Boot 3.x | ❌ 공식 지원 중단 | ✅ 공식 채택 |
| 통합 | 독립 프로젝트 | Micrometer 생태계 통합 |
| 벤더 중립성 | 부분적 | 완전한 추상화 |
| 커뮤니티 | 감소 추세 | 성장 중 |
전환 배경
- Spring Boot 3.0 정책: Sleuth 공식 지원 중단, Micrometer Tracing 권장
- 통합 관측성: 메트릭(Micrometer Metrics) + 추적(Micrometer Tracing) 단일 프로젝트로 통합
- 표준 준수: W3C TraceContext, OpenTelemetry 등 최신 표준 적극 지원
- 장기 지원: Spring 팀의 직접 관리로 안정적인 유지보수 보장
결론: 새 프로젝트는 반드시 Micrometer Tracing 사용, 기존 Sleuth 프로젝트는 마이그레이션 권장
핵심 장점
1. 벤더 중립성 (Vendor Neutrality)
코드는 Micrometer 에서 제공하는 인터페이스만 사용!
// ✅ 벤더 중립적 코드 - 어떤 APM 도구든 동작
@RestController
class UserController(
private val tracer: Tracer, // Micrometer 인터페이스
) {
@GetMapping("/api/users/{userId}")
fun getUser(@PathVariable userId: Long): UserResponse {
val currentSpan = tracer.currentSpan() // Micrometer API
val traceId = currentSpan.context().traceId()
logger.info("Fetching user: $userId, traceId: $traceId")
// ...
}
}
2. 자동 계측 (Auto Instrumentation)
Spring MVC, WebFlux, Kafka 등 자동 지원
@RestController
class OrderController {
@GetMapping("/api/orders/{orderId}")
fun getOrder(@PathVariable orderId: Long): Order {
// ✅ 별도 코드 없이도 자동으로 Span 생성!
return orderService.findById(orderId)
}
}
3. MDC 통합
SLF4J MDC와 자동 통합
logger.info("Processing order")
// 출력: [traceId=690dae8f... spanId=0123456789...] Processing order
// ✅ MDC에 자동으로 trace ID 설정됨!
Trace ID 전파 (Propagation)
Micrometer는 W3C TraceContext 표준을 지원하여 서비스 간 trace ID를 자동으로 전파합니다.
HTTP Client 호출 예시
@Service
class ExternalApiService(
private val restTemplate: RestTemplate,
) {
private val logger = LoggerFactory.getLogger(javaClass)
fun callExternalApi(userId: Long): ExternalUserResponse {
logger.info("Calling external API for user: $userId")
// ✅ RestTemplate이 자동으로 W3C traceparent 헤더 추가!
val response = restTemplate.getForObject(
"https://api.external.com/users/$userId",
ExternalUserResponse::class.java
)
logger.info("External API call completed")
return response
}
}
실제 HTTP 요청
GET /users/12345 HTTP/1.1
Host: api.external.com
traceparent: 00-690dae8f00000000de283d7148306199-fedcba9876543210-01
└─ Micrometer가 자동으로 추가한 trace ID!
W3C traceparent 헤더 형식
traceparent: 00-<trace-id>-<parent-id>-<trace-flags>
│ │ │ │
│ │ │ └─ 01 (sampled)
│ │ └─ 16-digit hex span ID
│ └─ 32-digit hex trace ID (128-bit)
└─ version
2.5. Brave vs OpenTelemetry: 구현체 선택
Micrometer Tracing은 2가지 구현체를 지원하며, 하나를 선택해야 합니다.
| 구분 | Brave | OpenTelemetry |
|---|---|---|
| 의존성 | micrometer-tracing-bridge-brave |
micrometer-tracing-bridge-otel |
| 백엔드 | Zipkin (기본) | OTLP Collector, Jaeger 등 |
| 성숙도 | ✅ 성숙, 프로덕션 검증됨 | ⚠️ 빠르게 발전 중 |
| 성능 | 매우 빠름 (경량) | 다소 무거움 |
| 생태계 | Zipkin 중심 | OpenTelemetry 표준 |
| 추천 | Spring Boot 프로젝트 | 다중 언어/플랫폼 표준화 필요 시 |
이 가이드의 선택: Brave
이유:
- ✅ Spring 팀이 추천하는 성숙하고 안정적인 솔루션
- ✅ 경량이며 성능 오버헤드가 적음
- ✅ Zipkin과의 완벽한 통합
- ✅ Spring Boot 생태계에서 검증된 track record
Spring 공식 블로그 인용:
"Spring believes Micrometer and Brave are essential tools in the Spring Boot observability toolkit as mature, production tested solutions."
OpenTelemetry를 선택해야 하는 경우:
- 다중 언어 환경 (Java, Python, Go 등)
- OpenTelemetry 표준 준수가 필수인 조직
- Jaeger를 APM 백엔드로 사용
중요: 두 구현체 모두 동일한 Micrometer API를 사용하므로, 나중에 교체 가능합니다.
3. 주요 환경별 Trace ID 활용
이 섹션에서는 Spring MVC, Kafka Consumer, Spring Batch 환경에서 Micrometer Tracing을 활용하는 방법을 설명합니다.
3.1. Spring MVC (HTTP 요청)
자동 계측
Spring MVC에서는 아무것도 하지 않아도 Micrometer가 자동으로 trace를 생성합니다.
흐름
1. HTTP Request 진입
│
▼
2. Micrometer Auto-Configuration
├─ W3C traceparent 헤더 읽기 (있는 경우)
├─ 또는 새 Trace ID 생성
├─ Root Span 생성
└─ MDC에 traceId/spanId 자동 설정
│
▼
3. Spring MVC Controller
├─ @RestController 메서드 실행
└─ 모든 로그에 trace ID 자동 포함
│
▼
4. Response 반환
├─ Span 종료
└─ APM 백엔드로 전송 (설정된 경우)
실제 코드 예시
@RestController
@RequestMapping("/api/users")
class UserController(
private val userService: UserService,
) {
private val logger = LoggerFactory.getLogger(javaClass)
@GetMapping("/{userId}")
fun getUser(@PathVariable userId: Long): UserResponse {
// ✅ 이 로그에는 trace ID가 자동으로 포함됨!
logger.info("Fetching user: $userId")
val user = userService.findById(userId)
return UserResponse.from(user)
}
}
@Service
class UserService(
private val userRepository: UserRepository,
) {
private val logger = LoggerFactory.getLogger(javaClass)
fun findById(userId: Long): User {
// ✅ Service 내부 로그도 Controller와 동일한 trace ID!
logger.info("Querying database for user: $userId")
return userRepository.findById(userId)
.orElseThrow { UserNotFoundException(userId) }
}
}
3.2. Kafka Consumer
자동 계측
Kafka Consumer도 자동으로 trace가 생성됩니다.
흐름
1. Kafka Message 수신
│
▼
2. Micrometer Kafka Instrumentation
├─ Kafka 헤더에서 trace 정보 읽기 (Producer가 전파한 경우)
├─ 또는 새 Trace ID 생성
├─ Span 생성
└─ MDC에 traceId/spanId 자동 설정
│
▼
3. @KafkaListener 메서드 실행
└─ 모든 로그에 trace ID 자동 포함
│
▼
4. Message 처리 완료
├─ Span 종료
└─ APM 백엔드로 전송 (설정된 경우)
실제 코드 예시
@Component
class ProductEventConsumer(
private val productService: ProductService,
private val eventPublisher: ApplicationEventPublisher,
) {
private val logger = LoggerFactory.getLogger(javaClass)
@Transactional
@KafkaListener(
topics = ["\${spring.kafka.topics.product-events}"],
groupId = "\${spring.kafka.group.product-consumer}",
)
fun consumeProductEvent(@Payload eventJson: String) {
val event = parseEvent(eventJson)
// ✅ 메시지마다 고유한 trace ID 자동 생성됨!
logger.info(
"Processing product event - productId: ${event.productId}, eventType: ${event.eventType}"
)
when (event.eventType) {
CREATE, UPDATE -> {
productService.upsertProduct(
id = event.productId,
name = event.productName,
isActive = event.isActive,
)
eventPublisher.publishEvent(ProductChangedEvent(event.productId))
}
else -> logger.debug("Ignoring event type: ${event.eventType}")
}
}
}
핵심
- 각 Kafka 메시지마다 새로운 trace ID 생성
- 동일 메시지 처리 중 발생하는 모든 로그는 동일 trace ID 사용
- 코드 수정 불필요 - Micrometer가 자동 처리
3.3. Spring Batch (수동 계측)
문제 상황
Spring Batch는 자동 계측이 되지 않습니다!
이유
- Batch Job은 HTTP 진입점이 아님
- Kafka 메시지도 아님
- Scheduler 또는 수동 실행 → Micrometer가 자동 감지 못함
해결: JobTracingListener 구현
핵심 아이디어: Job 시작 시 수동으로 Span을 생성하고 MDC에 trace ID 설정
구현 코드
@Component
class JobTracingListener(
private val tracer: Tracer, // ✅ Micrometer Tracer
) : JobExecutionListener {
private val logger = LoggerFactory.getLogger(javaClass)
private var currentSpan: Span? = null
override fun beforeJob(jobExecution: JobExecution) {
// 1️⃣ Micrometer로 새로운 Span 생성 (trace 시작)
val span = tracer.nextSpan()
.name("batch.job.${jobExecution.jobInstance.jobName}")
.tag("job.name", jobExecution.jobInstance.jobName)
.tag("job.execution.id", jobExecution.id.toString())
.tag("service.name", "batch-service")
.start() // ← 여기서 실제로 trace ID 생성!
// 2️⃣ Trace ID와 Span ID 추출
val traceId = span.context().traceId()
val spanId = span.context().spanId()
// 3️⃣ MDC에 수동으로 설정
// ✅ Application Log에 trace ID 포함하기 위해 MDC 설정
MDC.put("traceId", traceId)
MDC.put("spanId", spanId)
MDC.put("job.name", jobExecution.jobInstance.jobName)
// 4️⃣ JobExecutionContext에 저장 (Step 간 공유용)
jobExecution.executionContext.put("traceId", traceId)
jobExecution.executionContext.put("spanId", spanId)
this.currentSpan = span
logger.info(
"Started batch job trace - job: {}, traceId: {}, spanId: {}",
jobExecution.jobInstance.jobName,
traceId,
spanId,
)
}
override fun afterJob(jobExecution: JobExecution) {
// 5️⃣ Job 종료 시 태그 추가 및 Span 종료
currentSpan?.tag("job.status", jobExecution.status.toString())
currentSpan?.tag("exit.code", jobExecution.exitStatus.exitCode)
currentSpan?.end() // ← APM 백엔드에 전송
logger.info(
"Completed batch job trace - job: {}, status: {}",
jobExecution.jobInstance.jobName,
jobExecution.status,
)
// 6️⃣ MDC 정리
MDC.remove("traceId")
MDC.remove("spanId")
MDC.remove("job.name")
currentSpan = null
}
}
Step Listener도 추가
class StepExecutionLoggingListener : StepExecutionListener {
private val logger = LoggerFactory.getLogger(javaClass)
override fun beforeStep(stepExecution: StepExecution): Unit? {
val traceId = MDC.get("traceId") ?: "N/A"
val spanId = MDC.get("spanId") ?: "N/A"
logger.info(
"Step starting - name: {}, jobExecutionId: {}, traceId: {}, spanId: {}",
stepExecution.stepName,
stepExecution.jobExecutionId,
traceId,
spanId,
)
return null
}
override fun afterStep(stepExecution: StepExecution): Unit? {
logger.info(
"Step completed - name: {}, readCount: {}, writeCount: {}, status: {}",
stepExecution.stepName,
stepExecution.readCount,
stepExecution.writeCount,
stepExecution.status,
)
return null
}
}
Job 설정에 Listener 등록
@Configuration
class DailySettlementJobConfig(
private val jobTracingListener: JobTracingListener, // ← 주입
) {
@Bean
fun dailySettlementJob(
jobRepository: JobRepository,
settlementStep: Step,
notificationStep: Step,
): Job {
return JobBuilder("dailySettlementJob", jobRepository)
.listener(jobTracingListener) // ✅ 등록
.start(settlementStep)
.next(notificationStep)
.build()
}
@Bean
fun settlementStep(
jobRepository: JobRepository,
transactionManager: PlatformTransactionManager,
settlementTasklet: SettlementTasklet,
): Step {
return StepBuilder("settlementStep", jobRepository)
.tasklet(settlementTasklet, transactionManager)
.listener(StepExecutionLoggingListener()) // ✅ Step Listener 등록
.build()
}
}
핵심:
- ✅ Job 전체가 하나의 trace ID로 추적됨
- ✅ Step별로 다른 span ID 사용 (세부 작업 추적)
- ✅ MDC Key는 traceId/spanId 사용
4. 로그 통합 및 실전 활용
Micrometer Tracing이 생성한 trace ID를 로그에 통합하여 강력한 디버깅 도구로 활용하는 방법을 안내합니다.
4.1. Logback 설정
Spring Boot는 Micrometer Tracing이 활성화되면 자동으로 MDC에 traceId와 spanId를 설정합니다.
이를 로그에 포함하려면 Logback 설정을 조정해야 합니다.
기본 Console 로그 패턴
application.yml:
logging:
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level [%X{traceId},%X{spanId}] %logger{36} - %msg%n"
로그 출력 예시:
2025-11-21 10:30:45 [http-nio-8080-exec-1] INFO [690dae8f00000000de283d7148306199,0123456789abcdef] c.e.a.UserController - Fetching user: 12345
JSON 로그 형식 (운영 환경 권장)
logback-spring.xml:
<springProfile name="dev | prd">
<appender name="JSON_CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<!-- MDC (✅ Micrometer가 자동 설정한 trace ID) -->
<mdc>
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
</mdc>
<!-- 메시지에 trace ID 포함 -->
<pattern>
<pattern>
{
"message": "[traceId=%mdc{traceId:-none} spanId=%mdc{spanId:-none}] %message"
}
</pattern>
</pattern>
<!-- 로그 레벨, 타임스탬프, 로거 등 -->
<timestamp>
<timeZone>Asia/Seoul</timeZone>
<fieldName>timestamp</fieldName>
</timestamp>
<logLevel>
<fieldName>level</fieldName>
</logLevel>
<loggerName>
<fieldName>logger</fieldName>
</loggerName>
</providers>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON_CONSOLE"/>
</root>
</springProfile>
JSON 로그 출력 예시:
{
"timestamp": "2025-11-21 10:30:45.123",
"traceId": "690dae8f00000000de283d7148306199",
"spanId": "0123456789abcdef",
"level": "INFO",
"logger": "com.example.app.api.UserController",
"message": "[traceId=690dae8f00000000de283d7148306199 spanId=0123456789abcdef] Fetching user: 12345"
}
4.2. 로그 기반 트러블슈팅 워크플로우
시나리오: 에러 알림 수신
1. Slack/이메일로 에러 알림 수신
"API 에러 발생: NullPointerException"
│
▼
2. 로그 시스템 (OpenSearch/Kibana/ELK) 접속
│
▼
3. Trace ID로 검색
검색어: "690dae8f00000000de283d7148306199"
│
▼
4. 해당 요청의 전체 로그 확인
├─ HTTP 요청 파라미터
├─ 실행된 비즈니스 로직
├─ DB 쿼리 및 결과
├─ 외부 API 호출
└─ 에러 스택 트레이스
│
▼
5. 원인 파악 및 수정
핵심 가치
- ✅ 빠른 원인 파악: Trace ID 하나로 전체 요청 흐름 추적
- ✅ 정확한 로그 검색: 관련 없는 로그에 방해받지 않음
- ✅ 동시 요청 구분: 여러 요청이 동시 처리되어도 각각 추적 가능
- ✅ 마이크로서비스 추적: 서비스 경계를 넘어 전체 플로우 확인
5. 실무 활용 꿀팁
5.1. API 에러 응답에 Trace ID 포함
TraceIdProvider 구현
Micrometer에서 trace ID를 가져오는 Provider 패턴:
fun interface TraceIdProvider {
fun provide(): String
}
구현체 (Micrometer 기반)
@Component
class MicrometerTraceIdProvider(
private val tracer: Tracer, // ✅ Micrometer Tracer
) : TraceIdProvider {
override fun provide(): String {
return tracer.currentSpan()?.let { span ->
span.context().traceId() // Micrometer API
} ?: run {
// Batch: MDC fallback (JobTracingListener가 설정)
// ✅ 운영 환경 기준 traceId 사용
MDC.get("traceId") ?: ""
}
}
}
에러 응답 DTO
data class ApiErrorResponse(
val httpStatus: HttpStatus,
val errorCode: String?,
val message: String,
val timestamp: ZonedDateTime = ZonedDateTime.now(),
val traceId: String, // ✅ trace ID 포함!
)
에러 핸들러
@RestControllerAdvice
class GlobalExceptionHandler(
private val traceIdProvider: TraceIdProvider, // ✅ 주입
) {
private val logger = LoggerFactory.getLogger(javaClass)
@ExceptionHandler(UserNotFoundException::class)
fun handleUserNotFound(e: UserNotFoundException): ResponseEntity<ApiErrorResponse> {
val traceId = traceIdProvider.provide()
logger.error("User not found: userId=${e.userId}, traceId=$traceId", e)
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(
ApiErrorResponse(
httpStatus = HttpStatus.NOT_FOUND,
errorCode = "error.user.not-found",
message = "사용자를 찾을 수 없습니다.",
traceId = traceId,
)
)
}
}
에러 응답 예시
{
"httpStatus": "NOT_FOUND",
"errorCode": "error.user.not-found",
"message": "사용자를 찾을 수 없습니다.",
"timestamp": "2025-11-19T10:30:45.123+09:00",
"traceId": "690dae8f00000000de283d7148306199"
}
5.2. Slack 알림에 OpenSearch 바로 랜딩
@Service
class SlackNotificationService(
private val traceIdProvider: TraceIdProvider, // ✅ Micrometer 기반
private val slackWebHookClient: SlackWebHookClient,
@Value("\${logging.opensearch-url}")
val logUrl: String,
) {
private val logger = LoggerFactory.getLogger(javaClass)
fun sendErrorNotification(
title: String,
message: String,
) {
val traceId = traceIdProvider.provide() // ✅ Micrometer에서 trace ID
val logSearchUrl = buildLogUrl(traceId)
logger.warn("Sending error notification: title=$title, traceId=$traceId")
slackWebHookClient.sendWebHook(
text = "*[$title]* (<$logSearchUrl|log - $traceId>)\n$message",
)
}
private fun buildLogUrl(traceId: String): String =
if (traceId.isNotEmpty()) {
logUrl.replace("{keyword}", "\"$traceId\"")
} else {
logUrl.replace("{keyword}", "*")
}
}
Slack 메시지 예시
api-service (prd)
[결제 처리 실패] (log - 690dae8f00000000de283d7148306199)
Order ID: 123456
Error: Payment gateway timeout
"log - 690dae8f..." 클릭 시
- OpenSearch가 자동으로 열림
- 검색어:
"690dae8f00000000de283d7148306199" - 해당 요청의 모든 로그 자동 필터링!
6. 최종 구현 및 트러블슈팅
🏗️ 전체 아키텍처
┌──────────────────────────────────────────────────────────────┐
│ 클라이언트 요청 │
│ (iOS, Android, Web, Postman) │
└────────────────────────┬─────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Micrometer Tracing Auto-Configuration │
│ - Brave Tracer (Zipkin 기반) │
│ - W3C TraceContext 표준 지원 │
│ - MDC 자동 설정 (traceId/spanId) │
└────────────────────────┬─────────────────────────────────────┘
│
┌───────────────┼───────────────┬──────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌─────────┐ ┌──────────┐
│ API │ │Consumer│ │ Batch │ │ Logger │
│ Error │ │ Event │ │ Job │ │ (MDC) │
│Response│ │ │ │ Listener│ │ │
└────┬───┘ └────┬───┘ └────┬────┘ └────┬─────┘
│ │ │ │
└──────────────┴──────────────┴──────────────┘
│
▼
┌───────────────────────────────────┐
│ TraceIdProvider │
│ (Micrometer 기반) │
│ - provide(): String │
└───────────────┬───────────────────┘
│
┌───────────────┴───────────────┐
│ │
▼ ▼
┌─────────┐ ┌────────┐
│ Slack │ │OpenSearch│
│ Alert │ │ Logs │
└─────────┘ └────────┘
📦 핵심 컴포넌트
1. Micrometer Tracing
의존성 (build.gradle.kts)
dependencies {
// Spring Boot Actuator (Micrometer 포함)
implementation("org.springframework.boot:spring-boot-starter-actuator")
// Micrometer Tracing
api("io.micrometer:micrometer-tracing-bridge-brave")
// Zipkin Reporter (Brave)
implementation("io.zipkin.reporter2:zipkin-reporter-brave")
// JSON 로그
implementation("net.logstash.logback:logstash-logback-encoder:7.4")
}
설정 (application.yml)
management:
tracing:
sampling:
probability: 1.0 # 100% 샘플링
logging:
opensearch-url: "https://opensearch.example.com/app/discover#/?_g=(filters:!(),query:(language:kuery,query:'{keyword}'))&_a=(columns:!(message,level),filters:!(),index:'logs-*')"
2. TraceIdProvider
역할: Micrometer에서 trace ID 추출
@Component
class MicrometerTraceIdProvider(
private val tracer: Tracer, // Micrometer Tracer
) : TraceIdProvider {
override fun provide(): String {
return tracer.currentSpan()?.context()?.traceId()
?: MDC.get("traceId") ?: "" // ✅ traceId 사용
}
}
3. JobTracingListener (Batch)
역할: Micrometer로 Batch Job trace 생성
@Component
class JobTracingListener(
private val tracer: Tracer, // Micrometer Tracer
) : JobExecutionListener {
private val logger = LoggerFactory.getLogger(javaClass)
override fun beforeJob(jobExecution: JobExecution) {
val span = tracer.nextSpan().start() // Micrometer API
val traceId = span.context().traceId()
// ✅ traceId 사용 (운영 환경 통일)
MDC.put("traceId", traceId)
MDC.put("spanId", span.context().spanId())
}
}
트러블슈팅
문제 1: Trace ID가 로그에 없음
증상
2025-11-19 10:30:45.123 INFO 12345 --- [nio-8080-exec-1] com.example.app.api.UserController : Fetching user
원인
- 로컬 환경에서 실행 (정상 - Micrometer만 동작)
- logback-spring.xml에서 MDC Key 설정 누락
해결
- Micrometer 기본 키(traceId/spanId) 또는 설정 안 됨
- 필요 시 logback에서
traceId/spanId포함<mdc> <includeMdcKeyName>traceId</includeMdcKeyName> <includeMdcKeyName>spanId</includeMdcKeyName> </mdc>
문제 2: Batch Job에서 Trace ID가 없음
증상
{
"timestamp": "2025-11-19 02:00:00.123",
"level": "INFO",
"message": "[traceId=none spanId=none] Starting settlement job"
}
원인
JobTracingListener가 Job에 등록되지 않음
해결
1) JobTracingListener 빈 등록 확인
@Component
class JobTracingListener(
private val tracer: Tracer,
) : JobExecutionListener {
// ...
}
2) Job에 Listener 등록 확인
@Bean
fun dailySettlementJob(
jobRepository: JobRepository,
jobTracingListener: JobTracingListener, // ← 주입
): Job {
return JobBuilder("dailySettlementJob", jobRepository)
.listener(jobTracingListener) // ← 등록
.start(settlementStep)
.build()
}
3) MDC Key 확인
// ✅ 운영 환경 기준
MDC.put("traceId", traceId)
MDC.put("spanId", spanId)
문제 3: APM에 Trace가 안 보임
증상
- 로그에는 trace ID 있음
- Zipkin UI에는 없음
원인
- APM agent 없음 (로컬 환경)
- Sampling 설정 0%
해결
management:
tracing:
sampling:
probability: 1.0 # 100%
참고 자료
Micrometer
W3C TraceContext
Zipkin
'Programming > SpringBoot' 카테고리의 다른 글
| @Transactional만으로 DB 읽기/쓰기 분산하기 (Writer/Reader Datasource 자동 라우팅) (0) | 2024.10.31 |
|---|---|
| Spring Logging (2) : Console Log? AWS CloudWatch? 실행환경별로 로그 남기기 (0) | 2021.10.31 |
| Spring Logging (1) : HTTP Request/Response 로그 남기기 (5) | 2021.10.29 |
| Filter, Interceptor, AOP의 간단 용도 (0) | 2021.10.23 |
| Spring JSR 380 Validation 적용과 테스트 코드 작성 (0) | 2020.11.01 |
댓글
이 글 공유하기
다른 글
-
@Transactional만으로 DB 읽기/쓰기 분산하기 (Writer/Reader Datasource 자동 라우팅)
@Transactional만으로 DB 읽기/쓰기 분산하기 (Writer/Reader Datasource 자동 라우팅)
2024.10.31 -
Spring Logging (2) : Console Log? AWS CloudWatch? 실행환경별로 로그 남기기
Spring Logging (2) : Console Log? AWS CloudWatch? 실행환경별로 로그 남기기
2021.10.31 -
Spring Logging (1) : HTTP Request/Response 로그 남기기
Spring Logging (1) : HTTP Request/Response 로그 남기기
2021.10.29 -
Filter, Interceptor, AOP의 간단 용도
Filter, Interceptor, AOP의 간단 용도
2021.10.23