PART 1. OpenTelemetry 개념 학습
1. 왜 OpenTelemetry가 필요한가
2. 관측성(Observability)이란 무엇인가
3. OpenTelemetry는 무엇이고, 무엇이 아닌가
4. Trace / Span / Context 개념 완전 정리
5. 좋은 Span과 나쁜 Span의 차이
PART 2. 실전 ① — DDD + OpenTelemetry
6. DDD 관점에서 OTEL을 바라보기
7. Aggregate란 무엇인가
8. CPU 집약 로직이 있을 때의 대응 전략
9. DDD 기반 OTEL Span 설계 예제
PART 3. 실전 ② — SQS + OpenTelemetry
10. 비동기 시스템에서 Trace는 왜 어려운가
11. SQS Producer / Consumer Trace 모델
12. Parent vs Link 선택 기준
13. SQS + OTEL Trace 설계 예제
PART 4. 실전 ③ — Kotlin Coroutine + OpenTelemetry
14. Coroutine이 ThreadLocal을 깨뜨리는 이유
15. CoroutineContext란 무엇인가
16. "Trace를 CoroutineContext로 전달한다"의 의미
17. withContext와 Trace
18. Dispatcher 변경과 Trace 유지
19. launch vs withContext — Trace 경계 설계
20. async + await는 어디에 속하는가
PART 5. 전체 구조 정리 및 마무리
21. ECS + Spring Boot + OTEL 전체 아키텍처
22. 실무에서 자주 깨지는 Trace 패턴 TOP 5
23. 정리: Trace는 코드가 아니라 "의도"를 기록한다
대부분의 팀은 이미 로그를 남기고 있습니다. CloudWatch에 메트릭도 쌓고 있고, 알람도 설정해뒀습니다. 그런데 왜 장애가 나면 원인을 찾는 데 시간이 오래 걸릴까요?
┌─────────────────────────────────────────────────────────────┐
│ 장애 발생 시 흔한 상황 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 알람 발생: "500 에러 급증!" │
│ ↓ │
│ 2. 로그 확인: "NullPointerException at OrderService:142" │
│ ↓ │
│ 3. 질문: "이 요청은 어디서 왔지? 어떤 사용자? 어떤 흐름?" │
│ ↓ │
│ 4. 수동 추적: request_id로 grep... 여러 서비스 로그 뒤지기 │
│ ↓ │
│ 5. 시간 소요: 30분 ~ 2시간 │
│ │
└─────────────────────────────────────────────────────────────┘
로그는 "무엇이 일어났는지"를 알려줍니다. 하지만 "왜 일어났는지", "어떤 흐름으로 일어났는지"는 알려주지 않습니다.
더 심각한 문제가 있습니다. 대부분의 모니터링은 사후 대응입니다.
기존 모니터링의 한계:
CPU 80% 초과 → 알람 → 이미 늦음
메모리 부족 → 알람 → 이미 OOM 직전
응답 시간 3초 → 알람 → 이미 사용자가 이탈 중
문제:
- "왜 CPU가 올랐는지"는 모름
- "어떤 요청이 메모리를 먹었는지"는 모름
- "어떤 외부 API가 느린지"는 모름
결국, 장애가 터지고 나서야 원인을 추적하기 시작합니다. 그리고 그 추적은 대부분 수동입니다.
OpenTelemetry는 이 문제를 구조적으로 해결하려 합니다.
┌─────────────────────────────────────────────────────────────┐
│ OpenTelemetry의 접근 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ❌ 문제: 로그는 흩어져 있고, 연결되지 않음 │
│ ✅ 해결: 모든 로그/메트릭을 하나의 Trace로 연결 │
│ │
│ ❌ 문제: "느리다"는 건 알지만, 어디가 느린지 모름 │
│ ✅ 해결: 각 단계별 소요 시간을 Span으로 기록 │
│ │
│ ❌ 문제: 서비스 간 호출을 추적하기 어려움 │
│ ✅ 해결: Context 전파로 서비스 경계를 넘어 추적 │
│ │
└─────────────────────────────────────────────────────────────┘
핵심은 "연결"입니다. 흩어진 정보를 하나의 맥락으로 연결하는 것. 그것이 OpenTelemetry가 하는 일입니다.
두 개념은 자주 혼용되지만, 근본적으로 다릅니다.
┌─────────────────────────────────────────────────────────────┐
│ Monitoring │
├─────────────────────────────────────────────────────────────┤
│ │
│ 질문: "시스템이 정상인가?" │
│ │
│ 방식: │
│ - 미리 정의된 지표를 수집 (CPU, 메모리, 에러율) │
│ - 임계값 설정, 알람 발생 │
│ - 대시보드에서 상태 확인 │
│ │
│ 한계: │
│ - "알고 있는 문제"만 감지 가능 │
│ - "모르는 문제"는 놓침 │
│ - 원인 파악은 별도 작업 │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Observability │
├─────────────────────────────────────────────────────────────┤
│ │
│ 질문: "시스템이 왜 이렇게 동작하는가?" │
│ │
│ 방식: │
│ - 시스템의 내부 상태를 외부에서 추론 가능하게 함 │
│ - 예상치 못한 질문에도 답할 수 있음 │
│ - 데이터를 수집해두고, 필요할 때 분석 │
│ │
│ 장점: │
│ - "모르는 문제"도 탐색 가능 │
│ - 원인과 결과를 연결해서 이해 │
│ - 사후 분석뿐 아니라 예방도 가능 │
│ │
└─────────────────────────────────────────────────────────────┘
비유하자면, Monitoring은 자동차 계기판입니다. 속도, 연료, 엔진 경고등. Observability는 차량 블랙박스 + OBD 진단기입니다. 문제가 생기면 "왜 그랬는지" 역추적이 가능합니다.
Monitoring: "CPU가 90%입니다"
→ 그래서 뭘 해야 하지?
Observability: "CPU가 90%인데, 원인은 OrderService의
calculateDiscount() 메서드가 10만 번 호출되었고,
그 호출은 PromotionBatch에서 시작되었습니다"
→ 아, PromotionBatch 로직을 최적화해야겠군
Monitoring은 증상을 보여주고, Observability는 맥락을 보여줍니다.
Observability(줄여서 o11y)는 세 가지 신호(Signal)로 구성됩니다.
┌─────────────────────────────────────────────────────────────┐
│ o11y의 3요소 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 📊 Metrics (메트릭) │
│ ├─ 정의: 집계된 수치 데이터 │
│ ├─ 예시: 요청 수, 에러율, 응답 시간 백분위 │
│ ├─ 특징: 저장 비용 낮음, 트렌드 파악에 적합 │
│ └─ 질문: "얼마나 많이?", "평균은?" │
│ │
│ 📝 Logs (로그) │
│ ├─ 정의: 개별 이벤트 기록 │
│ ├─ 예시: "주문 생성 완료", "결제 실패" │
│ ├─ 특징: 상세 정보, 저장 비용 높음 │
│ └─ 질문: "무엇이 일어났나?", "언제?" │
│ │
│ 🔗 Traces (트레이스) │
│ ├─ 정의: 요청의 전체 여정 │
│ ├─ 예시: API 호출 → 서비스A → DB → 서비스B → 응답 │
│ ├─ 특징: 인과관계 파악, 병목 식별 │
│ └─ 질문: "어떤 경로로?", "어디서 느려졌나?" │
│ │
└─────────────────────────────────────────────────────────────┘
세 가지는 서로 보완합니다.
상황: 응답 시간이 느림
1. Metric으로 확인: "p99 응답 시간이 3초로 올랐다"
2. Trace로 추적: "OrderService → InventoryService 구간이 2.8초"
3. Log로 상세 확인: "InventoryService에서 DB 락 대기 중이었음"
이 글에서는 Trace에 집중합니다. Trace야말로 분산 시스템에서 "흐름"을 이해하는 핵심이기 때문입니다.
OpenTelemetry는 관측성 데이터를 수집하는 표준입니다.
┌─────────────────────────────────────────────────────────────┐
│ OpenTelemetry가 제공하는 것 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 📐 표준 (Specification) │
│ Trace, Metric, Log의 데이터 모델 정의 │
│ Context 전파 방식 정의 │
│ → 벤더에 상관없이 동일한 방식으로 데이터 수집 │
│ │
│ 📦 SDK (Software Development Kit) │
│ Java, Kotlin, Python, Go 등 언어별 라이브러리 │
│ 자동 계측(Auto-instrumentation) 지원 │
│ → 코드 몇 줄로 Trace 수집 시작 │
│ │
│ 🔌 Collector │
│ 데이터 수집, 가공, 전송을 담당하는 에이전트 │
│ 여러 백엔드로 동시 전송 가능 │
│ → 애플리케이션과 백엔드를 분리 │
│ │
└─────────────────────────────────────────────────────────────┘
많은 사람들이 OTEL을 "또 다른 APM 도구"로 오해합니다. 아닙니다.
┌─────────────────────────────────────────────────────────────┐
│ APM vs OpenTelemetry │
├─────────────────────────────────────────────────────────────┤
│ │
│ APM (Datadog, New Relic, Dynatrace...) │
│ ├─ 역할: 데이터 저장, 분석, 시각화 │
│ ├─ 제공: 대시보드, 알람, 이상 탐지 │
│ └─ 특징: 완성된 "제품" │
│ │
│ OpenTelemetry │
│ ├─ 역할: 데이터 수집, 표준화, 전송 │
│ ├─ 제공: SDK, Collector, 표준 프로토콜 │
│ └─ 특징: 벤더 중립적 "파이프라인" │
│ │
│ 관계: │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ Application │ ──→ │ OpenTelemetry│ ──→ │ APM │ │
│ │ (OTEL SDK)│ │ (Collector) │ │ (분석/시각화)│ │
│ └─────────────┘ └──────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
OTEL은 데이터를 모으는 표준이고, APM은 그 데이터를 분석하는 도구입니다. 경쟁 관계가 아니라 협력 관계입니다.
OpenTelemetry가 하지 않는 것도 명확히 알아야 합니다.
OpenTelemetry가 하지 않는 것:
❌ 데이터 저장 (Storage)
→ Jaeger, Tempo, X-Ray 등 별도 백엔드 필요
❌ 시각화 (Visualization)
→ Grafana, Datadog UI 등 별도 도구 필요
❌ 알람/알림 (Alerting)
→ CloudWatch Alarm, PagerDuty 등 필요
❌ 이상 탐지 (Anomaly Detection)
→ APM 제품의 ML 기능 등 필요
OTEL만 도입하면 끝이 아닙니다. 백엔드 시스템과 시각화 도구까지 함께 구성해야 완전한 관측성 파이프라인이 됩니다. 이 점을 미리 인지하고 시작해야 "OTEL 붙였는데 왜 아무것도 안 보이지?"라는 혼란을 피할 수 있습니다.
Trace는 하나의 요청이 시스템을 통과하는 전체 여정입니다.
┌─────────────────────────────────────────────────────────────┐
│ 예시: 주문 생성 요청의 Trace │
├─────────────────────────────────────────────────────────────┤
│ │
│ 사용자 요청: POST /orders │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Trace ID: abc-123-xyz │ │
│ │ │ │
│ │ ├─ API Gateway (10ms) │ │
│ │ │ └─ 인증 확인 │ │
│ │ │ │ │
│ │ ├─ Order Service (150ms) │ │
│ │ │ ├─ 주문 유효성 검증 (20ms) │ │
│ │ │ ├─ Inventory Service 호출 (80ms) │ │
│ │ │ └─ DB 저장 (50ms) │ │
│ │ │ │ │
│ │ └─ Notification Service (30ms) │ │
│ │ └─ 이메일 발송 │ │
│ │ │ │
│ │ Total: 190ms │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
하나의 Trace ID가 요청의 시작부터 끝까지 따라다닙니다. 이 ID로 전체 흐름을 추적할 수 있습니다.
Span은 하나의 작업 단위입니다. Trace는 여러 개의 Span으로 구성됩니다.
┌─────────────────────────────────────────────────────────────┐
│ Span의 구성 요소 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Span { │
│ trace_id: "abc-123-xyz" // 소속 Trace │
│ span_id: "span-001" // 이 Span의 고유 ID │
│ parent_span_id: "span-000" // 부모 Span (없으면 Root) │
│ name: "OrderService.create" // 작업 이름 │
│ start_time: 1699000000000 // 시작 시간 │
│ end_time: 1699000000150 // 종료 시간 │
│ status: OK // 성공/실패 │
│ attributes: { // 추가 정보 │
│ "user.id": "user-123", │
│ "order.amount": 50000 │
│ } │
│ } │
│ │
└─────────────────────────────────────────────────────────────┘
Span이 생성되는 시점:
Context는 Trace 정보를 운반하는 가방입니다.
┌─────────────────────────────────────────────────────────────┐
│ Context의 역할 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 문제: │
│ - Span A가 끝나고 Span B가 시작될 때 │
│ - "B는 A의 자식이다"를 어떻게 알 수 있나? │
│ │
│ 해결: │
│ - Context에 현재 Span 정보를 담아서 전달 │
│ - 새 Span 생성 시 Context를 참조 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Context │ │
│ │ trace_id: "abc-123-xyz" │ │
│ │ span_id: "span-001" (현재 활성 Span) │ │
│ │ baggage: { "tenant": "company-a" } │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 전파 방식: │
│ - 동일 프로세스: ThreadLocal / CoroutineContext │
│ - 서비스 간: HTTP Header (traceparent) │
│ - 메시지 큐: Message Attribute │
│ │
└─────────────────────────────────────────────────────────────┘
Context가 제대로 전파되지 않으면, Span들이 연결되지 않습니다. 그러면 Trace가 끊깁니다. 이것이 비동기 환경에서 흔히 발생하는 문제입니다.
Span 간의 관계는 세 가지로 표현됩니다.
┌─────────────────────────────────────────────────────────────┐
│ Span 관계 유형 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Parent-Child (부모-자식) │
│ ├─ 의미: "A가 B를 직접 호출했다" │
│ ├─ 예시: OrderService → InventoryService.check() │
│ └─ 특징: 동기적 호출, 인과관계 명확 │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ Parent: OrderService.create │ │
│ │ └─ Child: InventoryService.check │ │
│ │ └─ Child: DB.query │ │
│ └───────────────────────────────────────┘ │
│ │
│ Link (연결) │
│ ├─ 의미: "A와 B는 관련이 있지만, 직접 호출은 아니다" │
│ ├─ 예시: SQS Producer → SQS Consumer │
│ └─ 특징: 비동기 메시지, 배치 처리 │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Trace A │ ─Link─│ Trace B │ │
│ │ (Producer) │ │ (Consumer) │ │
│ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
언제 Parent-Child를 쓰고, 언제 Link를 쓰는가?
이것은 단순한 API 선택이 아니라, 흐름을 어떻게 이해할 것인가의 문제입니다. PART 3에서 자세히 다룹니다.
흔한 실수: "메서드마다 Span을 만들면 되겠지?"
// ❌ 나쁜 예: 메서드마다 Span
class OrderService(private val tracer: Tracer) {
fun createOrder(request: OrderRequest): Order {
tracer.spanBuilder("createOrder").startSpan().use { span ->
val validated = validateOrder(request) // Span 생성
val inventory = checkInventory(validated) // Span 생성
val order = saveOrder(validated) // Span 생성
val notification = sendNotification(order) // Span 생성
return order
}
}
private fun validateOrder(request: OrderRequest): ValidatedOrder {
tracer.spanBuilder("validateOrder").startSpan().use { span ->
// 10줄의 검증 로직
return ValidatedOrder(...)
}
}
// ... 모든 private 메서드에 Span
}
이렇게 하면 무슨 일이 생기나요?
문제점:
1. Trace 타임라인이 지저분해짐
└─ 수십 개의 작은 Span이 나열
└─ 정작 중요한 정보가 묻힘
2. 오버헤드 증가
└─ Span 생성/종료마다 비용 발생
└─ 고빈도 호출 시 성능 저하
3. "어디가 느린지" 파악 어려움
└─ 모든 게 다 보여서 오히려 아무것도 안 보임
좋은 Span은 비즈니스 관점에서 의미 있는 단위를 기록합니다.
┌─────────────────────────────────────────────────────────────┐
│ 의미 있는 실행 단위의 기준 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ✅ Span으로 만들어야 하는 것: │
│ │
│ 1. 외부 경계 (External Boundary) │
│ - HTTP 요청 수신 │
│ - HTTP 요청 발신 (다른 서비스 호출) │
│ - DB 쿼리 │
│ - 메시지 큐 발행/수신 │
│ │
│ 2. UseCase 단위 (비즈니스 흐름) │
│ - "주문 생성" UseCase │
│ - "재고 확인" UseCase │
│ - "결제 처리" UseCase │
│ │
│ 3. 성능 병목 후보 │
│ - 무거운 계산 로직 │
│ - 외부 API 호출 │
│ - 파일 I/O │
│ │
│ ❌ Span으로 만들지 말아야 하는 것: │
│ │
│ - 단순 getter/setter │
│ - 유틸리티 함수 │
│ - 검증 로직 (너무 빠름) │
│ - 순수 함수 (부수 효과 없음) │
│ │
└─────────────────────────────────────────────────────────────┘
핵심 질문: "이 Span을 보고 무엇을 알 수 있어야 하는가?"
// ✅ 좋은 예: 의미 있는 단위만 Span
class CreateOrderUseCase(
private val tracer: Tracer,
private val inventoryClient: InventoryClient, // 외부 호출은 자동 계측
private val orderRepository: OrderRepository // DB는 자동 계측
) {
fun execute(request: OrderRequest): Order {
// UseCase 레벨에서 하나의 Span
return tracer.spanBuilder("CreateOrder")
.setAttribute("order.items.count", request.items.size)
.startSpan().use { span ->
val validated = validateOrder(request) // 빠름, Span 불필요
// 외부 호출은 InventoryClient 내부에서 자동 계측됨
val inventoryResult = inventoryClient.check(validated.items)
if (!inventoryResult.available) {
span.setStatus(StatusCode.ERROR, "재고 부족")
throw InsufficientInventoryException()
}
// DB 저장은 자동 계측됨
val order = orderRepository.save(Order.from(validated))
span.setAttribute("order.id", order.id)
return order
}
}
// Span 없음 - 빠른 검증 로직
private fun validateOrder(request: OrderRequest): ValidatedOrder {
require(request.items.isNotEmpty()) { "주문 항목이 비어있습니다" }
// ... 검증 로직
return ValidatedOrder(request)
}
}
이렇게 하면:
Trace 타임라인:
┌───────────────────────────────────────────────────────────┐
│ CreateOrder (150ms) │
│ ├─ HTTP GET /inventory/check (80ms) ← 자동 계측 │
│ │ └─ DB SELECT inventory (30ms) ← 자동 계측 │
│ └─ DB INSERT orders (50ms) ← 자동 계측 │
└───────────────────────────────────────────────────────────┘
→ 한눈에 "재고 확인이 80ms로 가장 느리구나" 파악 가능
자동 계측(Auto-instrumentation)을 적극 활용하세요. Spring WebClient, JPA, JDBC 등은 OTEL SDK가 알아서 Span을 만들어줍니다. 우리가 직접 만들어야 하는 건 비즈니스 컨텍스트를 담은 UseCase 레벨 Span입니다.
DDD(Domain-Driven Design)에서 각 계층은 명확한 책임을 가집니다. OTEL을 어디에 넣을지 결정하려면 이 책임을 먼저 이해해야 합니다.
┌─────────────────────────────────────────────────────────────┐
│ DDD 계층 구조와 책임 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Presentation Layer (Controller) │ │
│ │ - HTTP 요청/응답 처리 │ │
│ │ - 입력 검증 (형식) │ │
│ │ - DTO 변환 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Application Layer (UseCase) │ │
│ │ - 비즈니스 흐름 조율 (Orchestration) │ │
│ │ - 트랜잭션 경계 │ │
│ │ - 도메인 객체 조합 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Domain Layer (Aggregate, Entity, Value Object) │ │
│ │ - 비즈니스 규칙 (불변식) │ │
│ │ - 상태 변경 로직 │ │
│ │ - 순수 도메인 로직 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Infrastructure Layer (Repository, Client) │ │
│ │ - DB 접근 │ │
│ │ - 외부 API 호출 │ │
│ │ - 메시지 큐 발행/수신 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
OTEL Span은 어디에 들어가야 할까요?
┌─────────────────────────────────────────────────────────────┐
│ 계층별 OTEL 적용 원칙 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Presentation Layer │
│ └─ OTEL: ❌ 직접 안 함 │
│ └─ 이유: Spring이 자동 계측 (HTTP 요청 Span) │
│ │
│ Application Layer ⭐ │
│ └─ OTEL: ✅ UseCase 단위 Span 생성 │
│ └─ 이유: 비즈니스 흐름의 시작점 │
│ └─ 예시: CreateOrderUseCase, ProcessPaymentUseCase │
│ │
│ Domain Layer │
│ └─ OTEL: ❌ 넣지 않음 │
│ └─ 이유: 도메인은 인프라에 의존하면 안 됨 │
│ └─ 예외: 별도 Domain Service로 분리한 경우에만 │
│ │
│ Infrastructure Layer │
│ └─ OTEL: ❌ 직접 안 함 │
│ └─ 이유: 자동 계측 활용 (DB, HTTP Client) │
│ └─ 예외: 커스텀 클라이언트는 직접 계측 │
│ │
└─────────────────────────────────────────────────────────────┘
핵심: Application Layer(UseCase)에서만 직접 Span을 생성하고, 나머지는 자동 계측에 맡깁니다.
Aggregate는 일관성 경계입니다.
// Order Aggregate 예시
class Order private constructor(
val id: OrderId,
val customerId: CustomerId,
private val _items: MutableList<OrderItem>,
private var _status: OrderStatus,
private var _totalAmount: Money
) {
val items: List<OrderItem> get() = _items.toList()
val status: OrderStatus get() = _status
val totalAmount: Money get() = _totalAmount
// 비즈니스 규칙: 주문 항목 추가
fun addItem(product: Product, quantity: Int) {
require(_status == OrderStatus.DRAFT) {
"확정된 주문에는 항목을 추가할 수 없습니다"
}
require(quantity > 0) { "수량은 1 이상이어야 합니다" }
val item = OrderItem(product, quantity)
_items.add(item)
_totalAmount = calculateTotal() // 불변식 유지
}
// 비즈니스 규칙: 주문 확정
fun confirm() {
require(_items.isNotEmpty()) { "항목이 없는 주문은 확정할 수 없습니다" }
require(_totalAmount >= Money.ZERO) { "금액이 유효하지 않습니다" }
_status = OrderStatus.CONFIRMED
}
private fun calculateTotal(): Money {
return _items.fold(Money.ZERO) { acc, item ->
acc + (item.price * item.quantity)
}
}
}
Aggregate의 핵심:
// ❌ 잘못된 예: Aggregate 내부에 Tracer
class Order(
private val tracer: Tracer // 도메인이 인프라에 의존!
) {
fun addItem(product: Product, quantity: Int) {
tracer.spanBuilder("Order.addItem").startSpan().use { span ->
// ... 로직
}
}
}
이게 왜 문제일까요?
┌─────────────────────────────────────────────────────────────┐
│ Aggregate에 Span을 넣으면 안 되는 이유 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 의존성 역전 위반 │
│ ├─ Domain Layer → Infrastructure Layer 의존 │
│ └─ DDD의 핵심 원칙 위배 │
│ │
│ 2. 테스트 어려움 │
│ ├─ 단위 테스트에 Tracer 모킹 필요 │
│ └─ 순수 도메인 로직 테스트가 복잡해짐 │
│ │
│ 3. 책임 혼란 │
│ ├─ Aggregate는 "상태와 규칙"만 책임 │
│ ├─ 관측은 Aggregate의 책임이 아님 │
│ └─ Single Responsibility 위반 │
│ │
│ 4. 불필요한 Span 증가 │
│ ├─ addItem(), updateStatus() 등 모든 메서드에 Span? │
│ └─ 대부분 마이크로초 단위, 측정 의미 없음 │
│ │
└─────────────────────────────────────────────────────────────┘
실무에서 이런 유혹이 있습니다:
"이 Aggregate 메서드가 느린 것 같은데, 측정하고 싶어요"
그래서 Tracer를 주입하고 싶어집니다. 하지만 이것은 문제 해결이 아니라 문제 회피입니다.
올바른 접근:
문제: "Order.calculateDiscount()가 느린 것 같다"
❌ 잘못된 해결: Aggregate에 Tracer 주입
✅ 올바른 해결:
1. 정말 느린가? → Profiler로 먼저 확인
2. 느리다면 왜?
- 알고리즘 문제 → 알고리즘 개선
- 외부 의존 → Domain Service로 분리
- 데이터 크기 → 설계 재검토
Aggregate가 느리다면, 그건 설계 문제일 가능성이 높습니다. OTEL로 측정하기 전에 먼저 설계를 점검하세요.
현실에서는 Aggregate에 복잡한 계산이 들어갈 수 있습니다.
// 예시: 할인 계산이 복잡한 Order
class Order {
fun calculateFinalPrice(): Money {
var price = totalAmount
// 복잡한 할인 규칙들
price = applyMembershipDiscount(price) // 회원 등급별
price = applyPromotionDiscount(price) // 프로모션
price = applyCouponDiscount(price) // 쿠폰
price = applyBundleDiscount(price) // 묶음 할인
price = applySeasonalDiscount(price) // 시즌 할인
return price
}
// 각 할인 계산이 복잡하다면?
private fun applyPromotionDiscount(price: Money): Money {
// 프로모션 10개를 순회하며 조건 확인
// 각 프로모션마다 복잡한 조건 체크
// O(n * m) 복잡도...
}
}
이런 로직이 느리다면 어떻게 해야 할까요?
원칙: Aggregate 내부가 아니라, Aggregate를 호출하는 쪽에서 측정
// UseCase에서 측정
class CalculateFinalPriceUseCase(
private val tracer: Tracer,
private val orderRepository: OrderRepository
) {
fun execute(orderId: OrderId): Money {
return tracer.spanBuilder("CalculateFinalPrice")
.setAttribute("order.id", orderId.value)
.startSpan().use { span ->
val order = orderRepository.findById(orderId)
?: throw OrderNotFoundException(orderId)
// Aggregate 메서드 호출 - 이 시간이 Span에 포함됨
val finalPrice = order.calculateFinalPrice()
span.setAttribute("price.original", order.totalAmount.value)
span.setAttribute("price.final", finalPrice.value)
return finalPrice
}
}
}
이렇게 하면:
계산 로직이 정말 복잡하고, 별도로 측정해야 한다면 Domain Service로 분리합니다.
// Domain Service로 분리
class DiscountCalculator {
fun calculate(order: Order, promotions: List<Promotion>): DiscountResult {
var totalDiscount = Money.ZERO
val appliedPromotions = mutableListOf<AppliedPromotion>()
for (promotion in promotions) {
if (promotion.isApplicableTo(order)) {
val discount = promotion.calculateDiscount(order)
totalDiscount += discount
appliedPromotions.add(AppliedPromotion(promotion.id, discount))
}
}
return DiscountResult(totalDiscount, appliedPromotions)
}
}
// UseCase에서 Domain Service 사용
class CalculateFinalPriceUseCase(
private val tracer: Tracer,
private val orderRepository: OrderRepository,
private val promotionRepository: PromotionRepository,
private val discountCalculator: DiscountCalculator
) {
fun execute(orderId: OrderId): Money {
return tracer.spanBuilder("CalculateFinalPrice")
.startSpan().use { span ->
val order = orderRepository.findById(orderId)!!
val promotions = promotionRepository.findActivePromotions()
// 별도 Span으로 측정 가능
val discountResult = tracer.spanBuilder("CalculateDiscount")
.setAttribute("promotion.count", promotions.size)
.startSpan().use { discountSpan ->
discountCalculator.calculate(order, promotions)
}
span.setAttribute("discount.total", discountResult.totalDiscount.value)
return order.totalAmount - discountResult.totalDiscount
}
}
}
이 패턴의 장점:
┌─────────────────────────────────────────────────────────────┐
│ Domain Service 분리의 장점 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Aggregate는 여전히 순수 │
│ └─ Order는 Tracer 모름 │
│ │
│ 2. 복잡한 계산 로직 격리 │
│ └─ DiscountCalculator만 테스트 가능 │
│ │
│ 3. 필요시 별도 측정 가능 │
│ └─ "CalculateDiscount" Span으로 병목 식별 │
│ │
│ 4. 확장성 │
│ └─ 캐싱, 병렬 처리 등 최적화 적용 용이 │
│ │
└─────────────────────────────────────────────────────────────┘
실제 주문 생성 흐름을 예제로 봅시다.
@Service
class CreateOrderUseCase(
private val tracer: Tracer,
private val orderRepository: OrderRepository,
private val inventoryClient: InventoryClient,
private val eventPublisher: ApplicationEventPublisher
) {
@Transactional
fun execute(command: CreateOrderCommand): OrderId {
// UseCase 레벨 Span
return tracer.spanBuilder("CreateOrder")
.setSpanKind(SpanKind.INTERNAL)
.setAttribute("customer.id", command.customerId)
.setAttribute("items.count", command.items.size)
.startSpan().use { span ->
try {
// 1. 도메인 객체 생성 (Span 없음 - 순수 도메인)
val order = Order.create(
customerId = CustomerId(command.customerId),
items = command.items.map { it.toDomain() }
)
// 2. 재고 확인 (자동 계측 - HTTP Client)
val inventoryResult = inventoryClient.checkAvailability(
order.items.map { it.productId }
)
if (!inventoryResult.allAvailable) {
span.setStatus(StatusCode.ERROR, "재고 부족")
throw InsufficientInventoryException(
inventoryResult.unavailableItems
)
}
// 3. 주문 저장 (자동 계측 - JPA)
val savedOrder = orderRepository.save(order)
// 4. 도메인 이벤트 발행
eventPublisher.publishEvent(OrderCreatedEvent(savedOrder.id))
span.setAttribute("order.id", savedOrder.id.value)
span.setStatus(StatusCode.OK)
return savedOrder.id
} catch (e: Exception) {
span.recordException(e)
span.setStatus(StatusCode.ERROR, e.message ?: "Unknown error")
throw e
}
}
}
}
직접 Span을 만들지 않아도, OTEL SDK가 자동으로 계측합니다.
# application.yml - OTEL 자동 계측 설정
otel:
instrumentation:
# JDBC 자동 계측
jdbc:
enabled: true
# Spring WebClient 자동 계측
spring-webflux:
enabled: true
# Spring Data JPA 자동 계측
spring-data:
enabled: true
자동 계측되는 Span들:
자동 생성되는 Span:
1. HTTP 요청 수신
Name: "POST /api/orders"
Kind: SERVER
2. DB 쿼리
Name: "SELECT orders"
Kind: CLIENT
Attributes:
db.system: postgresql
db.statement: "SELECT * FROM orders WHERE id = ?"
3. HTTP 요청 발신 (다른 서비스)
Name: "GET /inventory/check"
Kind: CLIENT
Attributes:
http.method: GET
http.url: http://inventory-service/check
최종 Trace가 어떻게 보이는지 확인합니다.
┌─────────────────────────────────────────────────────────────┐
│ Trace: 주문 생성 (trace_id: abc-123) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 시간 ─────────────────────────────────────────────────→ │
│ 0ms 50ms 100ms 150ms 200ms │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ POST /api/orders (200ms) │ │
│ │ [SERVER] - 자동 계측 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ├─┌────────────────────────────────────────────────┐ │
│ │ │ CreateOrder (190ms) │ │
│ │ │ [INTERNAL] - UseCase Span ⭐ │ │
│ │ │ customer.id: cust-001 │ │
│ │ │ items.count: 3 │ │
│ │ │ order.id: order-123 │ │
│ │ └────────────────────────────────────────────────┘ │
│ │ │ │
│ │ ├─┌──────────────────────────┐ │
│ │ │ │ GET /inventory/check │ │
│ │ │ │ [CLIENT] (80ms) │ │
│ │ │ │ - 자동 계측 │ │
│ │ │ └──────────────────────────┘ │
│ │ │ │
│ │ ├─┌─────────────────────────────────────┐ │
│ │ │ │ INSERT orders (50ms) │ │
│ │ │ │ [CLIENT] - 자동 계측 │ │
│ │ │ └─────────────────────────────────────┘ │
│ │ │ │
│ │ └─┌────────────────────────────────────────┐ │
│ │ │ INSERT order_items (40ms) │ │
│ │ │ [CLIENT] - 자동 계측 │ │
│ │ └────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
분석:
- 전체 요청: 200ms
- 재고 확인 API: 80ms (40%) ← 가장 느림
- DB 저장: 90ms (45%)
- 순수 로직: 20ms (10%)
→ 재고 확인 API 최적화 우선 검토
이것이 좋은 Trace 설계입니다:
동기 호출에서는 Trace가 자연스럽게 연결됩니다.
동기 호출:
┌─────────┐ HTTP ┌─────────┐ HTTP ┌─────────┐
│Service A│ ──────→ │Service B│ ──────→ │Service C│
└─────────┘ └─────────┘ └─────────┘
Context 전파: HTTP Header (traceparent)
└─ 자동으로 Parent-Child 관계 형성
메시지 기반 시스템은 다릅니다.
메시지 기반:
┌─────────┐ ┌─────────┐ ┌─────────┐
│Producer │ ──────→ │ Queue │ ──────→ │Consumer │
└─────────┘ └─────────┘ └─────────┘
│ ↓ │
Span A 시간 지연 Span B?
(수초~수분)
문제:
- Producer와 Consumer는 다른 프로세스
- 실행 시간이 다름
- 어떻게 연결할 것인가?
SQS를 사용할 때 Trace가 끊기는 상황들:
┌─────────────────────────────────────────────────────────────┐
│ Trace가 끊기는 일반적인 상황 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Message Attribute 미전달 │
│ Producer가 Trace Context를 메시지에 담지 않음 │
│ → Consumer는 새 Trace를 시작 │
│ │
│ 2. Consumer 자동 계측 미설정 │
│ Spring Cloud AWS가 Context를 추출하도록 설정 안 함 │
│ → 메시지에 Context가 있어도 무시됨 │
│ │
│ 3. 배치 처리 │
│ 여러 메시지를 한 번에 처리 │
│ → 어떤 메시지의 Context를 Parent로 할 것인가? │
│ │
│ 4. 재시도/DLQ │
│ 실패 후 재처리 │
│ → 원본 Trace와 재시도 Trace의 관계는? │
│ │
└─────────────────────────────────────────────────────────────┘
이 문제들을 해결하려면, 메시지 기반 시스템의 Trace 설계를 명확히 해야 합니다.
Producer 관점에서의 Span 설계:
@Service
class OrderEventPublisher(
private val sqsTemplate: SqsTemplate,
private val tracer: Tracer
) {
fun publishOrderCreated(event: OrderCreatedEvent) <{
// Producer Span - 메시지 발행까지만
tracer.spanBuilder("PublishOrderCreated")
.setSpanKind(SpanKind.PRODUCER) // 중요: PRODUCER 타입
.setAttribute("messaging.system", "aws_sqs")
.setAttribute("messaging.destination", "order-events")
.setAttribute("order.id", event.orderId)
.startSpan().use { span ->
// Trace Context를 Message Attribute로 전달
val message = MessageBuilder
.withPayload(event)
.setHeader("traceparent", extractTraceParent(span))
.build()
sqsTemplate.send("order-events", message)
span.setStatus(StatusCode.OK)
}
}
private fun extractTraceParent(span: Span): String {
// W3C Trace Context 형식
val context = span.spanContext
return "00-${context.traceId}-${context.spanId}-01"
}
}
Producer Span의 범위:
┌─────────────────────────────────────────────────────────────┐
│ Producer Span 범위 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 시작: 메시지 생성 시점 │
│ 종료: SQS에 전송 완료 시점 │
│ │
│ ❌ 포함하지 않음: │
│ - Consumer의 처리 시간 │
│ - 큐에서 대기하는 시간 │
│ - 재시도 처리 │
│ │
│ 이유: │
│ - Producer는 Consumer 완료를 기다리지 않음 │
│ - 비동기 시스템의 특성 │
│ - Producer Span이 길어지면 의미 왜곡 │
│ │
└─────────────────────────────────────────────────────────────┘
Consumer 설계의 핵심 결정:
질문: Consumer는 Producer의 Trace를 이어받아야 하는가?
답: 상황에 따라 다름
┌─────────────────────────────────────────────────────────────┐
│ 선택지 1: Parent-Child (같은 Trace) │
├─────────────────────────────────────────────────────────────┤
│ │
│ Producer Span │
│ └─ Consumer Span │
│ │
│ 장점: │
│ - 하나의 Trace로 전체 흐름 파악 │
│ - E2E 지연 시간 측정 가능 │
│ │
│ 단점: │
│ - Trace가 매우 길어짐 (수 시간도 가능) │
│ - Producer Span이 닫혀도 Trace는 계속됨 │
│ - 재시도 시 같은 Trace에 여러 시도가 쌓임 │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 선택지 2: Link (별도 Trace) │
├─────────────────────────────────────────────────────────────┤
│ │
│ Trace A (Producer) ──Link── Trace B (Consumer) │
│ │
│ 장점: │
│ - 각 Trace가 독립적으로 완결 │
│ - Consumer Trace의 시작/종료가 명확 │
│ - 재시도 시 새 Trace로 깔끔하게 분리 │
│ │
│ 단점: │
│ - 두 Trace를 연결해서 봐야 함 │
│ - 일부 APM에서 Link 시각화가 약함 │
│ │
└─────────────────────────────────────────────────────────────┘
대부분의 경우 Link 방식을 권장합니다. 다음 섹션에서 자세히 설명합니다.
Parent-Child를 선택하는 경우:
Parent-Child가 적합한 경우:
1. Request-Reply 패턴
┌─────────┐ ┌─────────┐
│ Client │ ──req──→│ Server │
│ │ ←─reply─│ │
└─────────┘ └─────────┘
- 클라이언트가 응답을 기다림
- 전체가 하나의 논리적 작업
2. 동기적 메시지 패턴
- Producer가 Consumer 완료를 기다리는 경우
- Correlation ID로 응답을 추적하는 경우
3. 매우 짧은 지연
- 메시지 처리가 수백 ms 이내
- 실질적으로 동기 호출과 유사
Link를 선택하는 경우:
Link가 적합한 경우:
1. Fire-and-Forget
┌─────────┐ ┌─────────┐
│Producer │ ──msg──→│Consumer │
└─────────┘ └─────────┘
│
└─ Producer는 Consumer를 기다리지 않음
2. 시간적 분리가 큰 경우
- 메시지가 큐에 오래 대기 (분~시간)
- Producer와 Consumer 실행 시점이 다름
3. 재시도/DLQ 패턴
- 같은 메시지가 여러 번 처리될 수 있음
- 각 시도를 독립적 Trace로 보는 것이 합리적
4. 배치 처리
- 여러 메시지를 모아서 한 번에 처리
- 각 원본 메시지와 Link로 연결
┌─────────────────────────────────────────────────────────────┐
│ 실무 판단 기준 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 질문 1: Producer가 Consumer 완료를 기다리는가? │
│ ├─ Yes → Parent-Child 고려 │
│ └─ No → Link ⭐ (대부분의 경우) │
│ │
│ 질문 2: 메시지 처리에 재시도가 있는가? │
│ ├─ Yes → Link (각 시도를 독립 Trace로) │
│ └─ No → 어느 쪽이든 가능 │
│ │
│ 질문 3: 하나의 Trace로 E2E 지연을 측정해야 하는가? │
│ ├─ Yes → Parent-Child (단, Trace가 길어지는 것 감수) │
│ └─ No → Link │
│ │
│ 기본 권장: Link ⭐ │
│ 이유: 대부분의 메시지 시스템은 비동기 │
│ 재시도와 DLQ 처리가 깔끔 │
│ Trace 수명이 예측 가능 │
│ │
└─────────────────────────────────────────────────────────────┘
Producer 구현:
@Service
class OrderEventPublisher(
private val sqsAsyncClient: SqsAsyncClient,
private val tracer: Tracer,
private val objectMapper: ObjectMapper
) {
private val queueUrl = "https://sqs.ap-northeast-2.amazonaws.com/123456789/order-events"
fun publishOrderCreated(event: OrderCreatedEvent) {
tracer.spanBuilder("SQS.Publish.OrderCreated")
.setSpanKind(SpanKind.PRODUCER)
.setAttribute(SemanticAttributes.MESSAGING_SYSTEM, "aws_sqs")
.setAttribute(SemanticAttributes.MESSAGING_DESTINATION_NAME, "order-events")
.setAttribute("order.id", event.orderId)
.startSpan().use { span ->
val messageBody = objectMapper.writeValueAsString(event)
// Trace Context를 Message Attribute로 주입
val request = SendMessageRequest.builder()
.queueUrl(queueUrl)
.messageBody(messageBody)
.messageAttributes(buildTraceAttributes(span))
.build()
sqsAsyncClient.sendMessage(request).get()
span.setAttribute(SemanticAttributes.MESSAGING_MESSAGE_ID,
request.messageBody().hashCode().toString())
}
}
private fun buildTraceAttributes(span: Span): Map<String, MessageAttributeValue> {
val context = span.spanContext
val traceparent = "00-${context.traceId}-${context.spanId}-01"
return mapOf(
"traceparent" to MessageAttributeValue.builder()
.dataType("String")
.stringValue(traceparent)
.build()
)
}
}
Consumer 구현 (Link 방식):
@Component
class OrderEventConsumer(
private val tracer: Tracer,
private val orderProcessingService: OrderProcessingService
) {
@SqsListener("order-events")
fun handleOrderCreated(
@Payload event: OrderCreatedEvent,
@Headers headers: Map<String, String>
) {
// 원본 Trace Context 추출
val parentContext = extractParentContext(headers)
// 새 Trace 시작하되, Link로 원본과 연결
val spanBuilder = tracer.spanBuilder("SQS.Process.OrderCreated")
.setSpanKind(SpanKind.CONSUMER)
.setAttribute(SemanticAttributes.MESSAGING_SYSTEM, "aws_sqs")
.setAttribute(SemanticAttributes.MESSAGING_DESTINATION_NAME, "order-events")
.setAttribute("order.id", event.orderId)
// Link 추가 (원본 Trace와 연결)
parentContext?.let {
spanBuilder.addLink(it)
}
spanBuilder.startSpan().use { span ->
try {
orderProcessingService.process(event)
span.setStatus(StatusCode.OK)
} catch (e: Exception) {
span.recordException(e)
span.setStatus(StatusCode.ERROR, e.message)
throw e // 재시도를 위해 rethrow
}
}
}
private fun extractParentContext(headers: Map<String, String>): SpanContext? {
val traceparent = headers["traceparent"] ?: return null
return try {
// W3C Trace Context 파싱
// 형식: 00-{trace_id}-{span_id}-{flags}
val parts = traceparent.split("-")
if (parts.size != 4) return null
SpanContext.createFromRemoteParent(
parts[1], // trace_id
parts[2], // span_id
TraceFlags.fromHex(parts[3], 0),
TraceState.getDefault()
)
} catch (e: Exception) {
null
}
}
}
재시도 시 Trace 설계:
@Component
class OrderEventConsumer(
private val tracer: Tracer,
private val orderProcessingService: OrderProcessingService
) {
@SqsListener("order-events")
fun handleOrderCreated(
@Payload event: OrderCreatedEvent,
@Headers headers: Map<String, String>,
@Header("ApproximateReceiveCount") receiveCount: String
) {
val attempt = receiveCount.toInt()
val parentContext = extractParentContext(headers)
val spanBuilder = tracer.spanBuilder("SQS.Process.OrderCreated")
.setSpanKind(SpanKind.CONSUMER)
.setAttribute("messaging.retry.count", attempt)
.setAttribute("order.id", event.orderId)
// 모든 시도는 원본 Producer와 Link로 연결
parentContext?.let {
spanBuilder.addLink(it)
}
spanBuilder.startSpan().use { span ->
if (attempt > 1) {
// 재시도임을 기록
span.addEvent("Retry attempt", Attributes.of(
AttributeKey.longKey("attempt"), attempt.toLong()
))
}
try {
orderProcessingService.process(event)
span.setStatus(StatusCode.OK)
} catch (e: Exception) {
span.recordException(e)
if (attempt >= 3) {
// 최대 재시도 초과 - DLQ로 이동
span.setAttribute("messaging.destination.dlq", true)
span.setStatus(StatusCode.ERROR, "Max retries exceeded, moving to DLQ")
} else {
span.setStatus(StatusCode.ERROR, "Will retry")
}
throw e
}
}
}
}
DLQ 처리:
@Component
class OrderEventDlqConsumer(
private val tracer: Tracer,
private val alertService: AlertService,
private val manualReviewService: ManualReviewService
) {
@SqsListener("order-events-dlq")
fun handleDeadLetter(
@Payload event: OrderCreatedEvent,
@Headers headers: Map<String, String>
) {
val parentContext = extractParentContext(headers)
tracer.spanBuilder("SQS.DLQ.OrderCreated")
.setSpanKind(SpanKind.CONSUMER)
.setAttribute(SemanticAttributes.MESSAGING_DESTINATION_NAME, "order-events-dlq")
.setAttribute("order.id", event.orderId)
.apply { parentContext?.let { addLink(it) } }
.startSpan().use { span ->
// DLQ 도착 알림
alertService.notifyDlq(event)
// 수동 검토 큐에 추가
manualReviewService.addForReview(event)
span.setAttribute("manual_review.queued", true)
span.setStatus(StatusCode.OK, "Queued for manual review")
}
}
}
최종 Trace 구조:
┌─────────────────────────────────────────────────────────────┐
│ SQS 기반 Trace 구조 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Trace A (Producer) │
│ └─ SQS.Publish.OrderCreated (50ms) │
│ └─ order.id: order-123 │
│ │
│ ↓ Link │
│ │
│ Trace B (Consumer - 1st attempt, failed) │
│ └─ SQS.Process.OrderCreated (200ms) │
│ ├─ messaging.retry.count: 1 │
│ └─ status: ERROR │
│ │
│ ↓ Link (같은 원본 메시지) │
│ │
│ Trace C (Consumer - 2nd attempt, failed) │
│ └─ SQS.Process.OrderCreated (150ms) │
│ ├─ messaging.retry.count: 2 │
│ ├─ event: Retry attempt │
│ └─ status: ERROR │
│ │
│ ↓ Link │
│ │
│ Trace D (DLQ Consumer) │
│ └─ SQS.DLQ.OrderCreated (30ms) │
│ ├─ manual_review.queued: true │
│ └─ status: OK │
│ │
└─────────────────────────────────────────────────────────────┘
이렇게 하면 각 Trace가 독립적이면서도, Link를 통해 전체 여정을 추적할 수 있습니다.
전통적인 Java/Spring 애플리케이션에서 Context는 ThreadLocal로 전파됩니다.
// 일반적인 ThreadLocal 기반 Context 전파
public class TraceContext {
private static final ThreadLocal<Span> currentSpan = new ThreadLocal<>();
public static Span getCurrent() {
return currentSpan.get();
}
public static void setCurrent(Span span) {
currentSpan.set(span);
}
}
이 방식은 하나의 요청 = 하나의 Thread를 가정합니다.
Thread 기반 모델:
Request 1 ──→ Thread-1 ──→ 처리 완료 ──→ Response
│
└─ ThreadLocal에 Span 저장
└─ 요청 내내 같은 Thread
Request 2 ──→ Thread-2 ──→ 처리 완료 ──→ Response
│
└─ 별도의 ThreadLocal
문제없이 동작합니다. 그런데...
Coroutine은 Thread를 바꿔가며 실행됩니다.
suspend fun processOrder(orderId: String) {
// Thread-1에서 시작
val order = orderRepository.findById(orderId) // suspend, I/O 대기
// I/O 완료 후 Thread-2에서 재개될 수 있음!
val payment = paymentService.process(order) // suspend, I/O 대기
// 다시 Thread-3에서 재개될 수 있음!
orderRepository.save(order.complete(payment))
}
Coroutine 실행 흐름:
┌─────────────────────────────────────────────────────────────┐
│ Coroutine 실행 흐름 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 시간 ─────────────────────────────────────────────────→ │
│ │
│ Thread-1 ████████░░░░░░░░░░░░████████░░░░░░░░░░░░░░░░░ │
│ ↓ ↑ │
│ suspend resume │
│ ↓ ↑ │
│ Thread-2 ░░░░░░░░░████████████░░░░░░░░░░████████████░░ │
│ ↓ ↑ │
│ suspend resume │
│ ↓ ↑ │
│ Thread-3 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░████████ │
│ │
│ ThreadLocal-1: Span A → ❓ (Thread 바뀜) │
│ ThreadLocal-2: Span B (다른 요청) │
│ ThreadLocal-3: Span C (다른 요청) │
│ │
│ 문제: Thread-2로 재개될 때, ThreadLocal에는 │
│ 다른 요청의 Span이 있을 수 있음! │
│ │
└─────────────────────────────────────────────────────────────┘
결과: Trace가 엉뚱한 요청과 연결되거나, 아예 끊깁니다.
이것이 Coroutine 환경에서 ThreadLocal 기반 Context 전파가 실패하는 이유입니다.
CoroutineContext는 Coroutine의 실행 환경을 담는 불변 집합입니다.
// CoroutineContext는 Key-Element 쌍의 집합
interface CoroutineContext {
operator fun <E : Element> get(key: Key<E>): E?
fun <R> fold(initial: R, operation: (R, Element) -> R): R
operator fun plus(context: CoroutineContext): CoroutineContext
fun minusKey(key: Key<*>): CoroutineContext
}
주요 구성 요소:
┌─────────────────────────────────────────────────────────────┐
│ CoroutineContext 구성 요소 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Job │
│ └─ Coroutine의 생명주기 관리 │
│ └─ 취소, 완료 상태 │
│ │
│ 2. CoroutineDispatcher │
│ └─ 어떤 Thread(s)에서 실행할지 결정 │
│ └─ Dispatchers.Default, IO, Main 등 │
│ │
│ 3. CoroutineName │
│ └─ 디버깅용 이름 │
│ │
│ 4. CoroutineExceptionHandler │
│ └─ 예외 처리 전략 │
│ │
│ 5. 사용자 정의 Element ⭐ │
│ └─ 예: OpenTelemetry Context │
│ └─ 우리가 직접 추가할 수 있음 │
│ │
└─────────────────────────────────────────────────────────────┘
ThreadLocal과 달리, CoroutineContext는 Coroutine과 함께 이동합니다.
// Coroutine 생성 시 Context 지정
launch(Dispatchers.IO + CoroutineName("OrderProcessor")) {
// 이 블록 내에서는
// - Dispatchers.IO에서 실행
// - coroutineContext[CoroutineName] = "OrderProcessor"
suspendFunction() // suspend 후에도 동일한 Context!
}
Thread가 바뀌어도 Context는 유지됩니다:
┌─────────────────────────────────────────────────────────────┐
│ CoroutineContext의 이동 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Coroutine A │
│ ┌──────────────────────────────────┐ │
│ │ CoroutineContext │ │
│ │ ├─ Job │ ─────────────────┐ │
│ │ ├─ Dispatcher: IO │ │ │
│ │ └─ OtelContext: Span-123 │ ←── 우리가 추가 │ │
│ └──────────────────────────────────┘ │ │
│ │ │
│ Thread-1 실행 ──→ suspend ──→ Thread-2 재개 │ │
│ │ │ │ │
│ └─────────── Context 유지 ─────┘ │ │
│ │ │
│ 어떤 Thread에서 실행되든, │ │
│ coroutineContext[OtelContext]로 Span 접근 가능! │ │
│ │
└─────────────────────────────────────────────────────────────┘
이것이 Coroutine 환경에서 Trace를 유지하는 핵심입니다.
CoroutineContext로 Trace를 전달할 때, 정확히 무슨 일이 일어나는지 이해해야 합니다.
// OTEL의 Context Element 정의
class OtelContextElement(
val context: io.opentelemetry.context.Context
) : AbstractCoroutineContextElement(Key) {
companion object Key : CoroutineContext.Key<OtelContextElement>
}
// 사용 예시
launch(OtelContextElement(Context.current())) {
// 이 Coroutine 내에서 Context.current()를 호출하면?
}
세 가지 시나리오를 구분해야 합니다:
┌─────────────────────────────────────────────────────────────┐
│ 시나리오 1: 전달 (Pass) - 올바른 방식 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 부모 Coroutine │
│ ┌───────────────────┐ │
│ │ OtelContext: │ │
│ │ Span-Parent │ ──────→ 자식 Coroutine에 전달 │
│ └───────────────────┘ (참조 전달) │
│ │
│ 자식 Coroutine │
│ ┌───────────────────┐ │
│ │ OtelContext: │ │
│ │ Span-Parent │ ← 같은 Context 참조 │
│ └───────────────────┘ │
│ │
│ → 부모-자식 관계 유지 │
│ → 자식 Span 생성 시 Parent-Child 관계 형성 │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 시나리오 2: 복사 (Copy) - 잘못된 접근 │
├─────────────────────────────────────────────────────────────┤
│ │
│ OtelContext를 "복사"해서 전달하려 할 때... │
│ │
│ 실제로 OTEL Context는 복사 개념이 아님. │
│ Context.current()는 현재 활성 Context를 반환. │
│ 새 Span을 만들면 자동으로 Parent 관계 형성. │
│ │
│ 잘못된 시도: │
│ val copiedContext = Context.current().copy() // ❌ 없음 │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 시나리오 3: 공유 (Share) - 주의 필요 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 여러 Coroutine이 같은 Context를 "활성"으로 공유할 때 │
│ │
│ launch { /* Span A 활성 */ } │
│ launch { /* Span A 활성 */ } // 둘 다 같은 Span? │
│ │
│ 문제: │
│ - 두 Coroutine의 작업이 같은 Span에 기록됨 │
│ - 의미 왜곡 발생 │
│ │
│ 해결: │
│ 각 Coroutine에서 자신만의 Span을 시작하고, │
│ 전달받은 Context의 Span을 Parent로 설정 │
│ │
└─────────────────────────────────────────────────────────────┘
핵심 원칙: Context를 전달하되, 각 Coroutine은 자신만의 Span을 시작해야 합니다.
withContext는 일시적으로 Context를 변경합니다.
suspend fun processOrder(orderId: String) {
// 현재 Context: Dispatchers.Default + OtelContext(Span-A)
withContext(Dispatchers.IO) {
// Context 변경: Dispatchers.IO + OtelContext(Span-A)
// Dispatcher만 바뀜, OtelContext는 유지!
repository.findById(orderId)
}
// 원래 Context로 복원: Dispatchers.Default + OtelContext(Span-A)
}
withContext의 동작:
┌─────────────────────────────────────────────────────────────┐
│ withContext 동작 원리 │
├─────────────────────────────────────────────────────────────┤
│ │
│ coroutineContext = A │
│ │ │
│ ▼ │
│ withContext(B + C) { │
│ │ │
│ ├─ 새 Context = A + B + C (병합) │
│ │ (B, C가 A의 기존 요소를 덮어씀) │
│ │ │
│ ├─ 블록 실행 │
│ │ │
│ └─ 블록 완료 │
│ } │
│ │ │
│ ▼ │
│ coroutineContext = A (복원) │
│ │
└─────────────────────────────────────────────────────────────┘
Trace를 유지하면서 Dispatcher를 바꾸는 안전한 패턴:
@Service
class OrderService(
private val tracer: Tracer,
private val orderRepository: OrderRepository
) {
suspend fun createOrder(request: OrderRequest): Order {
return tracer.spanBuilder("CreateOrder")
.startSpan().use { span ->
// OTEL Context를 Coroutine Context에 포함
withContext(span.asContextElement()) {
// IO 작업은 Dispatchers.IO에서
val validated = withContext(Dispatchers.IO) {
// span은 여전히 활성!
validateWithExternalService(request)
}
// CPU 작업은 Dispatchers.Default에서
val calculated = withContext(Dispatchers.Default) {
// span 계속 활성!
calculateComplexLogic(validated)
}
// DB 저장
withContext(Dispatchers.IO) {
orderRepository.save(Order.from(calculated))
}
}
}
}
}
// Span을 CoroutineContext Element로 변환하는 확장 함수
fun Span.asContextElement(): CoroutineContext {
val otelContext = Context.current().with(this)
return OtelContextElement(otelContext)
}
Dispatchers.Default
├─ CPU 집약 작업용
├─ Thread 수 = CPU 코어 수
└─ 예: 복잡한 계산, 정렬, 변환
Dispatchers.IO
├─ I/O 작업용
├─ Thread 수 = 최대 64개 (또는 코어 수, 더 큰 쪽)
└─ 예: DB, 파일, 네트워크
OTEL Context가 CoroutineContext에 포함되어 있다면, Thread가 바뀌어도 유지됩니다.
suspend fun demonstrateTracePreservation() {
val initialThread = Thread.currentThread().name
println("시작: $initialThread") // 예: DefaultDispatcher-worker-1
withContext(Dispatchers.IO) {
val ioThread = Thread.currentThread().name
println("IO 전환: $ioThread") // 예: DefaultDispatcher-worker-3
// Trace 확인
val currentSpan = Span.current()
println("Span ID: ${currentSpan.spanContext.spanId}") // 동일한 Span!
delay(100) // suspend
}
val afterThread = Thread.currentThread().name
println("복귀: $afterThread") // 예: DefaultDispatcher-worker-5 (다를 수 있음)
// 여전히 같은 Span
println("Span ID: ${Span.current().spanContext.spanId}") // 동일!
}
핵심: Thread는 바뀌어도, CoroutineContext에 있는 OTEL Context는 유지됩니다.
두 API의 근본적인 차이:
// withContext: 순차적, 결과 반환, 같은 흐름
suspend fun sequential() {
val result = withContext(Dispatchers.IO) {
repository.findAll() // 완료까지 대기
}
process(result) // 결과 사용
}
// launch: 비동기, 결과 없음, 분리된 흐름
suspend fun concurrent() {
launch {
repository.findAll() // 완료 안 기다림
}
doSomethingElse() // 바로 실행
}
Trace 관점에서의 차이:
┌─────────────────────────────────────────────────────────────┐
│ withContext - 같은 흐름 유지 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Parent Span │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ ████████████████████████████████████████████████████ │ │
│ │ ↓ ↓ │ │
│ │ withContext(IO) withContext(Default) │ │
│ │ [같은 Span 내에서] [같은 Span 내에서] │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ → Span 하나, Dispatcher만 변경 │
│ → 순차적 실행 │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ launch - 분리된 흐름 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Parent Span │
│ ┌───────────────────────────────────────┐ │
│ │ ████████████████████████████████████ │ │
│ │ ↓ │ │
│ │ launch │ │
│ │ └─────────────────┐ │ │
│ └──────────────────────────│────────────┘ │
│ │ │
│ ▼ │
│ Child Span (launch 내부) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ ████████████████████████████████████████████████ │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ → 별도 Span으로 분리 권장 │
│ → 병렬 실행 │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Trace 경계 설계 기준 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 질문 1: 결과를 기다려야 하는가? │
│ ├─ Yes → withContext │
│ │ └─ 같은 Span 내에서 Dispatcher만 변경 │
│ └─ No → launch + 별도 Span │
│ └─ 새 Span을 시작하고, Parent와 연결 │
│ │
│ 질문 2: 이 작업의 실패가 상위 작업에 영향을 주는가? │
│ ├─ Yes → withContext (예외 전파) │
│ └─ No → launch + SupervisorJob (예외 격리) │
│ │
│ 질문 3: 이 작업을 별도로 추적해야 하는가? │
│ ├─ Yes → 별도 Span │
│ └─ No → 같은 Span │
│ │
└─────────────────────────────────────────────────────────────┘
launch에서 별도 Span 생성하기:
suspend fun processWithBackground() {
tracer.spanBuilder("MainProcess").startSpan().use { parentSpan ->
withContext(parentSpan.asContextElement()) {
// 백그라운드 작업 - 별도 Span으로 분리
launch {
tracer.spanBuilder("BackgroundTask")
.setParent(Context.current()) // 부모 연결
.startSpan().use { childSpan ->
withContext(childSpan.asContextElement()) {
// 백그라운드 로직
sendNotification()
}
}
}
// 메인 로직 - 부모 Span에서 계속
processMainLogic()
}
}
}
async는 결과를 반환하는 비동기 작업입니다.
suspend fun fetchMultipleResources(): CombinedResult {
return coroutineScope {
val userDeferred = async { userService.getUser(userId) }
val ordersDeferred = async { orderService.getOrders(userId) }
val preferencesDeferred = async { preferenceService.get(userId) }
// 세 작업이 병렬로 실행됨
CombinedResult(
user = userDeferred.await(),
orders = ordersDeferred.await(),
preferences = preferencesDeferred.await()
)
}
}
┌─────────────────────────────────────────────────────────────┐
│ async + await의 Trace 구조 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 시간 ─────────────────────────────────────────────────→ │
│ │
│ Parent Span │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ async { getUser } ┌─────────────────────┐ │ │
│ │ ─────────────────────→ │ Child: GetUser │ │ │
│ │ │ 50ms │ │ │
│ │ async { getOrders } └─────────────────────┘ │ │
│ │ ─────────────────────→ ┌───────────────────────┐ │ │
│ │ │ Child: GetOrders │ │ │
│ │ │ 80ms │ │ │
│ │ async { getPrefs } └───────────────────────┘ │ │
│ │ ─────────────────────→ ┌─────────────┐ │ │
│ │ │Child: GetPrefs│ │ │
│ │ │ 30ms │ │ │
│ │ └─────────────┘ │ │
│ │ │ │
│ │ await() await() await() │ │
│ │ ←────────────────────────────────────────────── │ │
│ │ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ → 세 작업이 병렬로 실행 │
│ → 각각 별도 Child Span │
│ → Parent Span의 duration = max(50, 80, 30) = 80ms + α │
│ │
└─────────────────────────────────────────────────────────────┘
async를 사용할 때의 Span 설계:
suspend fun fetchMultipleResources(): CombinedResult {
return tracer.spanBuilder("FetchCombinedData")
.startSpan().use { parentSpan ->
withContext(parentSpan.asContextElement()) {
coroutineScope {
// 각 async는 별도의 Child Span
val userDeferred = async {
tracer.spanBuilder("GetUser")
.startSpan().use { span ->
withContext(span.asContextElement()) {
userService.getUser(userId)
}
}
}
val ordersDeferred = async {
tracer.spanBuilder("GetOrders")
.startSpan().use { span ->
withContext(span.asContextElement()) {
orderService.getOrders(userId)
}
}
}
val preferencesDeferred = async {
tracer.spanBuilder("GetPreferences")
.startSpan().use { span ->
withContext(span.asContextElement()) {
preferenceService.get(userId)
}
}
}
CombinedResult(
user = userDeferred.await(),
orders = ordersDeferred.await(),
preferences = preferencesDeferred.await()
)
}
}
}
}
이렇게 하면 Trace에서 병렬 실행이 명확하게 보입니다:
FetchCombinedData (85ms)
├─ GetUser (50ms) ━━━━━━━━━━━━━━━━
├─ GetOrders (80ms) ━━━━━━━━━━━━━━━━━━━━━━━━
└─ GetPreferences (30ms) ━━━━━━━━━
0ms 80ms
→ 병렬 실행됨을 시각적으로 확인 가능
→ GetOrders가 전체 시간을 결정
┌─────────────────────────────────────────────────────────────┐
│ ECS + Spring Boot + OpenTelemetry 아키텍처 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ECS Cluster │ │
│ │ │ │
│ │ ┌────────────────────┐ ┌────────────────────┐ │ │
│ │ │ Task: API Service │ │ Task: Worker │ │ │
│ │ │ ┌────────────────┐ │ │ ┌────────────────┐ │ │ │
│ │ │ │ Spring Boot │ │ │ │ Spring Boot │ │ │ │
│ │ │ │ + OTEL SDK │ │ │ │ + OTEL SDK │ │ │ │
│ │ │ └───────┬────────┘ │ │ └───────┬────────┘ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ ┌───────▼────────┐ │ │ ┌───────▼────────┐ │ │ │
│ │ │ │ OTEL Collector │ │ │ │ OTEL Collector │ │ │ │
│ │ │ │ (Sidecar) │ │ │ │ (Sidecar) │ │ │ │
│ │ │ └───────┬────────┘ │ │ └───────┬────────┘ │ │ │
│ │ └─────────│──────────┘ └─────────│──────────┘ │ │
│ │ │ │ │ │
│ └────────────│───────────────────────│─────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ AWS X-Ray / CloudWatch │ │
│ │ (또는 Jaeger, Tempo, Datadog 등) │ │
│ │ │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Traces │ │ Metrics │ │ Logs │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 컴포넌트별 역할 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Spring Boot Application + OTEL SDK │
│ ├─ 비즈니스 로직 실행 │
│ ├─ Span 생성 및 관리 │
│ ├─ 자동 계측 (HTTP, DB, 메시지) │
│ └─ Trace Context 전파 │
│ │
│ 2. OTEL Collector (Sidecar) │
│ ├─ SDK에서 데이터 수신 (OTLP) │
│ ├─ 데이터 가공 (샘플링, 배치) │
│ ├─ 백엔드로 전송 (X-Ray, Jaeger 등) │
│ └─ 애플리케이션과 백엔드 분리 │
│ │
│ 3. AWS X-Ray / CloudWatch │
│ ├─ Trace 저장 및 인덱싱 │
│ ├─ 시각화 (Service Map, Timeline) │
│ ├─ 검색 및 분석 │
│ └─ 알람 설정 │
│ │
└─────────────────────────────────────────────────────────────┘
build.gradle.kts:
dependencies {
// OTEL SDK
implementation("io.opentelemetry:opentelemetry-api:1.32.0")
implementation("io.opentelemetry:opentelemetry-sdk:1.32.0")
implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.32.0")
// Spring Boot 자동 계측
implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter:2.0.0")
// Kotlin Coroutine 지원
implementation("io.opentelemetry:opentelemetry-extension-kotlin:1.32.0")
}
application.yml:
otel:
service:
name: order-service
exporter:
otlp:
endpoint: http://localhost:4317 # Sidecar Collector
traces:
sampler:
probability: 1.0 # 개발환경 100%, 운영은 조정
ECS Task Definition (발췌):
{
"containerDefinitions": [
{
"name": "app",
"image": "order-service:latest",
"environment": [
{
"name": "OTEL_EXPORTER_OTLP_ENDPOINT",
"value": "http://localhost:4317"
}
]
},
{
"name": "otel-collector",
"image": "otel/opentelemetry-collector:latest",
"portMappings": [
{ "containerPort": 4317, "protocol": "tcp" }
],
"command": ["--config=/etc/otel-collector-config.yaml"]
}
]
}
// ❌ 잘못된 패턴
suspend fun process() {
tracer.spanBuilder("Parent").startSpan().use { span ->
launch { // Context 전달 안 됨!
childOperation() // 새 Trace 시작됨
}
}
}
// ✅ 올바른 패턴
suspend fun process() {
tracer.spanBuilder("Parent").startSpan().use { span ->
withContext(span.asContextElement()) {
launch { // Context 상속됨
tracer.spanBuilder("Child")
.startSpan().use { child ->
withContext(child.asContextElement()) {
childOperation()
}
}
}
}
}
}
// ❌ 잘못된 패턴: 모든 곳에 launch
suspend fun createOrder(request: OrderRequest): Order {
launch { validate(request) } // 결과 안 기다림
launch { checkInventory(request) } // 결과 안 기다림
return Order() // 검증 전에 반환?!
}
// ✅ 올바른 패턴: 적절한 API 선택
suspend fun createOrder(request: OrderRequest): Order {
val validated = withContext(Dispatchers.Default) {
validate(request) // 결과 기다림
}
val inventory = withContext(Dispatchers.IO) {
checkInventory(validated) // 결과 기다림
}
// 백그라운드 알림은 launch 가능
launch { sendNotification(order) }
return order
}
// ❌ 잘못된 패턴: 모든 메서드에 Span
class OrderService {
fun createOrder(request: OrderRequest): Order {
return tracer.span("createOrder") {
val a = tracer.span("step1") { step1() }
val b = tracer.span("step2") { step2(a) }
val c = tracer.span("step3") { step3(b) }
val d = tracer.span("step4") { step4(c) }
// 10개의 Span...
}
}
}
// ✅ 올바른 패턴: UseCase 단위 + 자동 계측
class CreateOrderUseCase {
fun execute(request: OrderRequest): Order {
return tracer.span("CreateOrder") {
val validated = validate(request) // Span 불필요
val inventory = inventoryClient.check(validated) // 자동 계측
orderRepository.save(order) // 자동 계측
}
}
}
// ❌ 잘못된 패턴: Aggregate에 Tracer
class Order(private val tracer: Tracer) {
fun addItem(item: Item) {
tracer.span("Order.addItem") {
_items.add(item)
}
}
}
// ✅ 올바른 패턴: Aggregate는 순수하게
class Order {
fun addItem(item: Item) {
_items.add(item)
}
}
// UseCase에서 측정
class AddItemUseCase(private val tracer: Tracer) {
fun execute(orderId: OrderId, item: Item) {
tracer.span("AddItem") {
val order = repository.findById(orderId)
order.addItem(item) // 순수 도메인
repository.save(order)
}
}
}
// ❌ 잘못된 패턴: SQS 메시지에 Context 미전달
fun publishEvent(event: Event) {
sqsClient.sendMessage(
SendMessageRequest.builder()
.messageBody(event.toJson())
// traceparent 없음!
.build()
)
}
// ✅ 올바른 패턴: Context 명시적 전달
fun publishEvent(event: Event) {
tracer.span("Publish.${event.type}") { span ->
sqsClient.sendMessage(
SendMessageRequest.builder()
.messageBody(event.toJson())
.messageAttributes(mapOf(
"traceparent" to span.context.toTraceParent()
))
.build()
)
}
}
Trace를 붙이는 것은 단순한 라이브러리 적용이 아닙니다.
┌─────────────────────────────────────────────────────────────┐
│ Observability 설계 시 고려사항 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 무엇을 보고 싶은가? │
│ ├─ 전체 요청 흐름? │
│ ├─ 병목 구간? │
│ ├─ 에러 원인? │
│ └─ 서비스 간 의존성? │
│ │
│ 2. 어떤 단위로 볼 것인가? │
│ ├─ UseCase 단위? │
│ ├─ 서비스 호출 단위? │
│ └─ 개별 연산 단위? │
│ │
│ 3. 비동기 경계를 어떻게 다룰 것인가? │
│ ├─ 메시지 큐 → Link │
│ ├─ Coroutine → Context 전달 │
│ └─ 배치 작업 → 별도 Trace │
│ │
│ 4. 비용과 성능의 균형은? │
│ ├─ 샘플링 비율 │
│ ├─ Span 개수 │
│ └─ Attribute 크기 │
│ │
└─────────────────────────────────────────────────────────────┘
Kotlin Coroutine을 쓴다면, API 선택 자체가 Trace 설계입니다.
┌─────────────────────────────────────────────────────────────┐
│ Coroutine API별 Trace 설계 │
├─────────────────────────────────────────────────────────────┤
│ │
│ withContext │
│ ├─ Trace: 같은 Span 내에서 계속 │
│ ├─ 용도: Dispatcher 변경, 순차 실행 │
│ └─ Context: 자동 유지 │
│ │
│ launch │
│ ├─ Trace: 새 Span 시작 권장 │
│ ├─ 용도: 백그라운드 작업, Fire-and-forget │
│ └─ Context: 명시적 전달 필요 │
│ │
│ async │
│ ├─ Trace: 병렬 작업 각각 Span │
│ ├─ 용도: 병렬 실행 후 결과 취합 │
│ └─ Context: 자동 유지 (coroutineScope 내) │
│ │
│ runBlocking │
│ ├─ Trace: 일반적으로 진입점에서만 │
│ ├─ 용도: 테스트, main 함수 │
│ └─ Context: 새로 시작 │
│ │
└─────────────────────────────────────────────────────────────┘
OpenTelemetry를 도입했다는 것은 단순히 라이브러리를 추가한 게 아닙니다.
OTEL 도입의 의미:
1. "우리 시스템의 흐름을 이해하겠다"는 선언
└─ 코드가 아닌 비즈니스 흐름을 추적
2. "문제를 사후가 아닌 사전에 파악하겠다"는 의지
└─ 모니터링에서 관측성으로 전환
3. "표준을 따르겠다"는 결정
└─ 벤더 종속 탈피, 유연한 백엔드 선택
4. "팀 전체가 같은 언어로 시스템을 이야기하겠다"는 합의
└─ Trace ID로 소통, Span으로 원인 분석
OTEL을 도입할 때 확인해야 할 것들:
┌─────────────────────────────────────────────────────────────┐
│ OTEL 도입 체크리스트 │
├─────────────────────────────────────────────────────────────┤
│ │
│ [ ] 자동 계측 설정 (HTTP, DB, 메시지 큐) │
│ [ ] UseCase 단위 Span 설계 │
│ [ ] Aggregate에는 Tracer 주입하지 않음 │
│ [ ] Coroutine Context 전파 패턴 정립 │
│ [ ] SQS 등 비동기 경계에서 Context 전달 방식 결정 │
│ [ ] 샘플링 비율 결정 (개발 100%, 운영 조정) │
│ [ ] 백엔드 선택 (X-Ray, Jaeger, Tempo, Datadog 등) │
│ [ ] Collector 배포 방식 결정 (Sidecar, DaemonSet 등) │
│ [ ] 팀 교육 (Trace 읽는 법, Span 만드는 기준) │
│ [ ] 대시보드 및 알람 설정 │
│ │
└─────────────────────────────────────────────────────────────┘
이 글에서 다룬 핵심 내용을 정리합니다:
PART 1: 개념
PART 2: DDD + OTEL
PART 3: SQS + OTEL
PART 4: Coroutine + OTEL
PART 5: 전체 정리
Trace 설계가 어렵다면, 이 질문으로 시작하세요:
"이 요청이 우리 시스템을 통과할 때, 어떤 일이 일어났는지 나중에 어떻게 이해할 것인가?"
그 답이 여러분의 Span 설계가 됩니다.