개념 정리 → DDD / SQS / Coroutine 실무 패턴


목차

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는 코드가 아니라 "의도"를 기록한다


PART 1. OpenTelemetry 개념 학습


1. 왜 OpenTelemetry가 필요한가

1.1. 로그와 모니터링의 한계

대부분의 팀은 이미 로그를 남기고 있습니다. CloudWatch에 메트릭도 쌓고 있고, 알람도 설정해뒀습니다. 그런데 왜 장애가 나면 원인을 찾는 데 시간이 오래 걸릴까요?

┌─────────────────────────────────────────────────────────────┐
│ 장애 발생 시 흔한 상황                                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 알람 발생: "500 에러 급증!"                               │
│     ↓                                                       │
│  2. 로그 확인: "NullPointerException at OrderService:142"   │
│     ↓                                                       │
│  3. 질문: "이 요청은 어디서 왔지? 어떤 사용자? 어떤 흐름?"      │
│     ↓                                                       │
│  4. 수동 추적: request_id로 grep... 여러 서비스 로그 뒤지기   │
│     ↓                                                       │
│  5. 시간 소요: 30분 ~ 2시간                                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘

로그는 "무엇이 일어났는지"를 알려줍니다. 하지만 "왜 일어났는지", "어떤 흐름으로 일어났는지"는 알려주지 않습니다.

1.2. "장애는 나중에 알게 된다"는 문제

더 심각한 문제가 있습니다. 대부분의 모니터링은 사후 대응입니다.

기존 모니터링의 한계:

CPU 80% 초과 → 알람 → 이미 늦음
메모리 부족 → 알람 → 이미 OOM 직전
응답 시간 3초 → 알람 → 이미 사용자가 이탈 중

문제:
- "왜 CPU가 올랐는지"는 모름
- "어떤 요청이 메모리를 먹었는지"는 모름
- "어떤 외부 API가 느린지"는 모름

결국, 장애가 터지고 나서야 원인을 추적하기 시작합니다. 그리고 그 추적은 대부분 수동입니다.

1.3. OTEL이 해결하려는 문제

OpenTelemetry는 이 문제를 구조적으로 해결하려 합니다.

┌─────────────────────────────────────────────────────────────┐
│ OpenTelemetry의 접근                                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ❌ 문제: 로그는 흩어져 있고, 연결되지 않음                    │
│  ✅ 해결: 모든 로그/메트릭을 하나의 Trace로 연결               │
│                                                             │
│  ❌ 문제: "느리다"는 건 알지만, 어디가 느린지 모름             │
│  ✅ 해결: 각 단계별 소요 시간을 Span으로 기록                  │
│                                                             │
│  ❌ 문제: 서비스 간 호출을 추적하기 어려움                     │
│  ✅ 해결: Context 전파로 서비스 경계를 넘어 추적               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

핵심은 "연결"입니다. 흩어진 정보를 하나의 맥락으로 연결하는 것. 그것이 OpenTelemetry가 하는 일입니다.


2. 관측성(Observability)이란 무엇인가

2.1. Monitoring vs Observability

두 개념은 자주 혼용되지만, 근본적으로 다릅니다.

┌─────────────────────────────────────────────────────────────┐
│                    Monitoring                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  질문: "시스템이 정상인가?"                                   │
│                                                             │
│  방식:                                                       │
│  - 미리 정의된 지표를 수집 (CPU, 메모리, 에러율)              │
│  - 임계값 설정, 알람 발생                                    │
│  - 대시보드에서 상태 확인                                    │
│                                                             │
│  한계:                                                       │
│  - "알고 있는 문제"만 감지 가능                               │
│  - "모르는 문제"는 놓침                                      │
│  - 원인 파악은 별도 작업                                     │
│                                                             │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                   Observability                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  질문: "시스템이 왜 이렇게 동작하는가?"                        │
│                                                             │
│  방식:                                                       │
│  - 시스템의 내부 상태를 외부에서 추론 가능하게 함              │
│  - 예상치 못한 질문에도 답할 수 있음                          │
│  - 데이터를 수집해두고, 필요할 때 분석                        │
│                                                             │
│  장점:                                                       │
│  - "모르는 문제"도 탐색 가능                                  │
│  - 원인과 결과를 연결해서 이해                                │
│  - 사후 분석뿐 아니라 예방도 가능                             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

비유하자면, Monitoring은 자동차 계기판입니다. 속도, 연료, 엔진 경고등. Observability는 차량 블랙박스 + OBD 진단기입니다. 문제가 생기면 "왜 그랬는지" 역추적이 가능합니다.

2.2. "보는 것"과 "이해하는 것"의 차이

Monitoring: "CPU가 90%입니다"
→ 그래서 뭘 해야 하지?

Observability: "CPU가 90%인데, 원인은 OrderService의 
calculateDiscount() 메서드가 10만 번 호출되었고,
그 호출은 PromotionBatch에서 시작되었습니다"
→ 아, PromotionBatch 로직을 최적화해야겠군

Monitoring은 증상을 보여주고, Observability는 맥락을 보여줍니다.

2.3. o11y의 3요소: Trace / Metric / Log

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야말로 분산 시스템에서 "흐름"을 이해하는 핵심이기 때문입니다.


3. OpenTelemetry는 무엇이고, 무엇이 아닌가

3.1. OTEL = 표준, SDK, 규약

OpenTelemetry는 관측성 데이터를 수집하는 표준입니다.

┌─────────────────────────────────────────────────────────────┐
│ OpenTelemetry가 제공하는 것                                  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  📐 표준 (Specification)                                     │
│     Trace, Metric, Log의 데이터 모델 정의                    │
│     Context 전파 방식 정의                                   │
│     → 벤더에 상관없이 동일한 방식으로 데이터 수집             │
│                                                             │
│  📦 SDK (Software Development Kit)                           │
│     Java, Kotlin, Python, Go 등 언어별 라이브러리            │
│     자동 계측(Auto-instrumentation) 지원                     │
│     → 코드 몇 줄로 Trace 수집 시작                           │
│                                                             │
│  🔌 Collector                                                │
│     데이터 수집, 가공, 전송을 담당하는 에이전트               │
│     여러 백엔드로 동시 전송 가능                              │
│     → 애플리케이션과 백엔드를 분리                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

3.2. APM과의 관계

많은 사람들이 OTEL을 "또 다른 APM 도구"로 오해합니다. 아닙니다.

┌─────────────────────────────────────────────────────────────┐
│ APM vs OpenTelemetry                                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  APM (Datadog, New Relic, Dynatrace...)                     │
│  ├─ 역할: 데이터 저장, 분석, 시각화                           │
│  ├─ 제공: 대시보드, 알람, 이상 탐지                           │
│  └─ 특징: 완성된 "제품"                                      │
│                                                             │
│  OpenTelemetry                                              │
│  ├─ 역할: 데이터 수집, 표준화, 전송                           │
│  ├─ 제공: SDK, Collector, 표준 프로토콜                      │
│  └─ 특징: 벤더 중립적 "파이프라인"                           │
│                                                             │
│  관계:                                                       │
│  ┌─────────────┐     ┌──────────────┐     ┌─────────────┐   │
│  │ Application │ ──→ │ OpenTelemetry│ ──→ │    APM      │   │
│  │   (OTEL SDK)│     │  (Collector) │     │ (분석/시각화)│   │
│  └─────────────┘     └──────────────┘     └─────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

OTEL은 데이터를 모으는 표준이고, APM은 그 데이터를 분석하는 도구입니다. 경쟁 관계가 아니라 협력 관계입니다.

3.3. 왜 "플랫폼"이 아닌가

OpenTelemetry가 하지 않는 것도 명확히 알아야 합니다.

OpenTelemetry가 하지 않는 것:

❌ 데이터 저장 (Storage)
   → Jaeger, Tempo, X-Ray 등 별도 백엔드 필요

❌ 시각화 (Visualization)  
   → Grafana, Datadog UI 등 별도 도구 필요

❌ 알람/알림 (Alerting)
   → CloudWatch Alarm, PagerDuty 등 필요

❌ 이상 탐지 (Anomaly Detection)
   → APM 제품의 ML 기능 등 필요

OTEL만 도입하면 끝이 아닙니다. 백엔드 시스템과 시각화 도구까지 함께 구성해야 완전한 관측성 파이프라인이 됩니다. 이 점을 미리 인지하고 시작해야 "OTEL 붙였는데 왜 아무것도 안 보이지?"라는 혼란을 피할 수 있습니다.


4. Trace / Span / Context 개념 완전 정리

4.1. Trace란 무엇인가

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로 전체 흐름을 추적할 수 있습니다.

4.2. Span은 언제 생기는가

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이 생성되는 시점:

  • HTTP 요청 수신/발신
  • DB 쿼리 실행
  • 외부 API 호출
  • 의미 있는 비즈니스 로직 시작/종료

4.3. Context는 무엇을 연결하는가

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에서 자세히 다룹니다.


5. 좋은 Span과 나쁜 Span의 차이

5.1. Span은 코드 단위가 아니다

흔한 실수: "메서드마다 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. "어디가 느린지" 파악 어려움
   └─ 모든 게 다 보여서 오히려 아무것도 안 보임

5.2. "의미 있는 실행 단위"란 무엇인가

좋은 Span은 비즈니스 관점에서 의미 있는 단위를 기록합니다.

┌─────────────────────────────────────────────────────────────┐
│ 의미 있는 실행 단위의 기준                                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ✅ Span으로 만들어야 하는 것:                                │
│                                                             │
│  1. 외부 경계 (External Boundary)                           │
│     - HTTP 요청 수신                                        │
│     - HTTP 요청 발신 (다른 서비스 호출)                      │
│     - DB 쿼리                                               │
│     - 메시지 큐 발행/수신                                    │
│                                                             │
│  2. UseCase 단위 (비즈니스 흐름)                             │
│     - "주문 생성" UseCase                                   │
│     - "재고 확인" UseCase                                   │
│     - "결제 처리" UseCase                                   │
│                                                             │
│  3. 성능 병목 후보                                           │
│     - 무거운 계산 로직                                       │
│     - 외부 API 호출                                         │
│     - 파일 I/O                                              │
│                                                             │
│  ❌ Span으로 만들지 말아야 하는 것:                           │
│                                                             │
│  - 단순 getter/setter                                       │
│  - 유틸리티 함수                                             │
│  - 검증 로직 (너무 빠름)                                     │
│  - 순수 함수 (부수 효과 없음)                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

5.3. Span을 어디까지 쪼개야 하는가

핵심 질문: "이 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입니다.


PART 2. 실전 ① — DDD + OpenTelemetry


6. DDD 관점에서 OTEL을 바라보기

6.1. 계층별 책임 다시 정리

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 호출                                       │   │
│  │ - 메시지 큐 발행/수신                                 │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

6.2. Application / Domain / Infra 역할 분리

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을 생성하고, 나머지는 자동 계측에 맡깁니다.


7. Aggregate란 무엇인가

7.1. Aggregate의 본질: 상태와 규칙

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의 핵심:

  • 상태를 캡슐화
  • 규칙(불변식)을 보장
  • 일관성 경계 역할

7.2. 왜 Aggregate에는 Span을 넣지 않는가

// ❌ 잘못된 예: 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?      │
│     └─ 대부분 마이크로초 단위, 측정 의미 없음                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

7.3. Domain 오염이 발생하는 이유

실무에서 이런 유혹이 있습니다:

"이 Aggregate 메서드가 느린 것 같은데, 측정하고 싶어요"

그래서 Tracer를 주입하고 싶어집니다. 하지만 이것은 문제 해결이 아니라 문제 회피입니다.

올바른 접근:

문제: "Order.calculateDiscount()가 느린 것 같다"

❌ 잘못된 해결: Aggregate에 Tracer 주입

✅ 올바른 해결:
1. 정말 느린가? → Profiler로 먼저 확인
2. 느리다면 왜? 
   - 알고리즘 문제 → 알고리즘 개선
   - 외부 의존 → Domain Service로 분리
   - 데이터 크기 → 설계 재검토

Aggregate가 느리다면, 그건 설계 문제일 가능성이 높습니다. OTEL로 측정하기 전에 먼저 설계를 점검하세요.


8. CPU 집약 로직이 있을 때의 대응 전략

8.1. Aggregate에 무거운 로직이 있는 경우

현실에서는 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) 복잡도...
    }
}

이런 로직이 느리다면 어떻게 해야 할까요?

8.2. 측정은 어디서 해야 하는가

원칙: 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
            }
    }
}

이렇게 하면:

  • Aggregate는 순수하게 유지
  • UseCase Span의 duration에 계산 시간이 포함됨
  • 필요시 Profiler로 세부 분석 가능

8.3. Domain Service 분리 패턴

계산 로직이 정말 복잡하고, 별도로 측정해야 한다면 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. 확장성                                                   │
│     └─ 캐싱, 병렬 처리 등 최적화 적용 용이                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

9. DDD 기반 OTEL Span 설계 예제

9.1. UseCase 단위 Span 중심

실제 주문 생성 흐름을 예제로 봅시다.

@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
                }
            }
    }
}

9.2. Repository / External 호출 자동 계측

직접 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

9.3. Trace 타임라인 해석

최종 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 설계입니다:

  • UseCase 단위로 비즈니스 컨텍스트 제공
  • 자동 계측으로 세부 병목 식별
  • 불필요한 Span 없이 깔끔한 구조

PART 3. 실전 ② — SQS + OpenTelemetry


10. 비동기 시스템에서 Trace는 왜 어려운가

10.1. 동기 호출 vs 메시지 기반 시스템

동기 호출에서는 Trace가 자연스럽게 연결됩니다.

동기 호출:

┌─────────┐  HTTP   ┌─────────┐  HTTP   ┌─────────┐
│Service A│ ──────→ │Service B│ ──────→ │Service C│
└─────────┘         └─────────┘         └─────────┘

Context 전파: HTTP Header (traceparent)
└─ 자동으로 Parent-Child 관계 형성

메시지 기반 시스템은 다릅니다.

메시지 기반:

┌─────────┐         ┌─────────┐         ┌─────────┐
│Producer │ ──────→ │  Queue  │ ──────→ │Consumer │
└─────────┘         └─────────┘         └─────────┘
     │                  ↓                    │
   Span A          시간 지연             Span B?
                  (수초~수분)

문제:
- Producer와 Consumer는 다른 프로세스
- 실행 시간이 다름
- 어떻게 연결할 것인가?

10.2. Trace가 자연스럽게 끊기는 지점

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 설계를 명확히 해야 합니다.


11. SQS Producer / Consumer Trace 모델

11.1. Producer에서 Span을 어디까지 가져갈 것인가

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이 길어지면 의미 왜곡                     │
│                                                             │
└─────────────────────────────────────────────────────────────┘

11.2. Consumer에서 새 Trace를 시작해야 하는 이유

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 방식을 권장합니다. 다음 섹션에서 자세히 설명합니다.


12.1. 언제 하나의 Trace인가

Parent-Child를 선택하는 경우:

Parent-Child가 적합한 경우:

1. Request-Reply 패턴
   ┌─────────┐         ┌─────────┐
   │ Client  │ ──req──→│ Server  │
   │         │ ←─reply─│         │
   └─────────┘         └─────────┘
   
   - 클라이언트가 응답을 기다림
   - 전체가 하나의 논리적 작업

2. 동기적 메시지 패턴
   - Producer가 Consumer 완료를 기다리는 경우
   - Correlation ID로 응답을 추적하는 경우

3. 매우 짧은 지연
   - 메시지 처리가 수백 ms 이내
   - 실질적으로 동기 호출과 유사

12.2. 언제 연결된 Trace인가

Link를 선택하는 경우:

Link가 적합한 경우:

1. Fire-and-Forget
   ┌─────────┐         ┌─────────┐
   │Producer │ ──msg──→│Consumer │
   └─────────┘         └─────────┘
         │
         └─ Producer는 Consumer를 기다리지 않음
   
2. 시간적 분리가 큰 경우
   - 메시지가 큐에 오래 대기 (분~시간)
   - Producer와 Consumer 실행 시점이 다름

3. 재시도/DLQ 패턴
   - 같은 메시지가 여러 번 처리될 수 있음
   - 각 시도를 독립적 Trace로 보는 것이 합리적

4. 배치 처리
   - 여러 메시지를 모아서 한 번에 처리
   - 각 원본 메시지와 Link로 연결

12.3. 실무에서의 판단 기준

┌─────────────────────────────────────────────────────────────┐
│ 실무 판단 기준                                               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  질문 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 수명이 예측 가능                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

13. SQS + OTEL Trace 설계 예제

13.1. Message Attribute에 Context 전달

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()
        )
    }
}

13.2. Consumer Span 생성 패턴

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
        }
    }
}

13.3. 재시도 / DLQ에서의 Trace 처리

재시도 시 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를 통해 전체 여정을 추적할 수 있습니다.


PART 4. 실전 ③ — Kotlin Coroutine + OpenTelemetry


14. Coroutine이 ThreadLocal을 깨뜨리는 이유

14.1. Thread 기반 실행 모델의 한계

전통적인 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

문제없이 동작합니다. 그런데...

14.2. Coroutine 실행 모델 이해

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 전파가 실패하는 이유입니다.


15. CoroutineContext란 무엇인가

15.1. CoroutineContext의 구성 요소

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                           │
│     └─ 우리가 직접 추가할 수 있음                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

15.2. 왜 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를 유지하는 핵심입니다.


16. "Trace를 CoroutineContext로 전달한다"의 의미

16.1. 전달 ≠ 복사 ≠ 공유

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()를 호출하면?
}

16.2. Context 전달 개념 이해하기

세 가지 시나리오를 구분해야 합니다:

┌─────────────────────────────────────────────────────────────┐
│ 시나리오 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을 시작해야 합니다.


17. withContext와 Trace

17.1. withContext는 무엇을 바꾸는가

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)
}

17.2. Context 스코프와 복원

withContext의 동작:

┌─────────────────────────────────────────────────────────────┐
│ withContext 동작 원리                                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  coroutineContext = A                                       │
│       │                                                     │
│       ▼                                                     │
│  withContext(B + C) {                                       │
│       │                                                     │
│       ├─ 새 Context = A + B + C (병합)                      │
│       │   (B, C가 A의 기존 요소를 덮어씀)                    │
│       │                                                     │
│       ├─ 블록 실행                                          │
│       │                                                     │
│       └─ 블록 완료                                          │
│  }                                                          │
│       │                                                     │
│       ▼                                                     │
│  coroutineContext = A (복원)                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

17.3. 안전한 패턴

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)
}

18. Dispatcher 변경과 Trace 유지

18.1. Dispatchers.Default vs IO

Dispatchers.Default
├─ CPU 집약 작업용
├─ Thread 수 = CPU 코어 수
└─ 예: 복잡한 계산, 정렬, 변환

Dispatchers.IO
├─ I/O 작업용
├─ Thread 수 = 최대 64(또는 코어 수, 더 큰 쪽)
└─ 예: DB, 파일, 네트워크

18.2. Thread 변경에도 Span이 유지되는 이유

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는 유지됩니다.


19. launch vs withContext — Trace 경계 설계

19.1. 흐름을 유지할 것인가, 분리할 것인가

두 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으로 분리 권장                                   │
│  → 병렬 실행                                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

19.2. 비동기 작업 설계 기준

┌─────────────────────────────────────────────────────────────┐
│ 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()
        }
    }
}

20. async + await는 어디에 속하는가

20.1. async의 역할

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()
        )
    }
}

20.2. 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 + α     │
│                                                             │
└─────────────────────────────────────────────────────────────┘

20.3. 병렬 작업에서의 Trace 구조

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가 전체 시간을 결정

PART 5. 전체 구조 정리 및 마무리


21. ECS + Spring Boot + OTEL 전체 아키텍처

21.1. 전체 구성도

┌─────────────────────────────────────────────────────────────┐
│ 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    │     │   │
│  │  └────────────┘  └────────────┘  └────────────┘     │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

21.2. 각 컴포넌트의 역할

┌─────────────────────────────────────────────────────────────┐
│ 컴포넌트별 역할                                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  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)                       │
│     ├─ 검색 및 분석                                         │
│     └─ 알람 설정                                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

21.3. 구성 예시

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"]
    }
  ]
}

22. 실무에서 자주 깨지는 Trace 패턴 TOP 5

22.1. Coroutine Context 미전달

// ❌ 잘못된 패턴
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()
                        }
                    }
            }
        }
    }
}

22.2. launch 남용

// ❌ 잘못된 패턴: 모든 곳에 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
}

22.3. Span 과분해

// ❌ 잘못된 패턴: 모든 메서드에 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)  // 자동 계측
        }
    }
}

22.4. Aggregate 오염

// ❌ 잘못된 패턴: 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)
        }
    }
}

22.5. 비동기 경계 무시

// ❌ 잘못된 패턴: 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()
        )
    }
}

23. 정리: Trace는 코드가 아니라 "의도"를 기록한다

23.1. Observability는 설계다

Trace를 붙이는 것은 단순한 라이브러리 적용이 아닙니다.

┌─────────────────────────────────────────────────────────────┐
│ Observability 설계 시 고려사항                               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 무엇을 보고 싶은가?                                      │
│     ├─ 전체 요청 흐름?                                      │
│     ├─ 병목 구간?                                           │
│     ├─ 에러 원인?                                           │
│     └─ 서비스 간 의존성?                                    │
│                                                             │
│  2. 어떤 단위로 볼 것인가?                                   │
│     ├─ UseCase 단위?                                        │
│     ├─ 서비스 호출 단위?                                    │
│     └─ 개별 연산 단위?                                      │
│                                                             │
│  3. 비동기 경계를 어떻게 다룰 것인가?                        │
│     ├─ 메시지 큐 → Link                                     │
│     ├─ Coroutine → Context 전달                             │
│     └─ 배치 작업 → 별도 Trace                               │
│                                                             │
│  4. 비용과 성능의 균형은?                                    │
│     ├─ 샘플링 비율                                          │
│     ├─ Span 개수                                            │
│     └─ Attribute 크기                                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

23.2. Coroutine API 선택 = Trace 설계

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: 새로 시작                                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

23.3. OTEL을 붙였다는 것의 진짜 의미

OpenTelemetry를 도입했다는 것은 단순히 라이브러리를 추가한 게 아닙니다.

OTEL 도입의 의미:

1. "우리 시스템의 흐름을 이해하겠다"는 선언
   └─ 코드가 아닌 비즈니스 흐름을 추적

2. "문제를 사후가 아닌 사전에 파악하겠다"는 의지
   └─ 모니터링에서 관측성으로 전환

3. "표준을 따르겠다"는 결정
   └─ 벤더 종속 탈피, 유연한 백엔드 선택

4. "팀 전체가 같은 언어로 시스템을 이야기하겠다"는 합의
   └─ Trace ID로 소통, Span으로 원인 분석

23.4. 마지막 체크리스트

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: 개념

  • OpenTelemetry는 모니터링 도구가 아닌 관측성 표준
  • Trace는 요청의 전체 여정, Span은 의미 있는 실행 단위
  • Context는 Span 간의 연결고리

PART 2: DDD + OTEL

  • UseCase 단위로 Span 생성
  • Aggregate는 순수하게 유지 (Tracer 주입 금지)
  • 자동 계측 적극 활용

PART 3: SQS + OTEL

  • 비동기 경계에서는 Link 방식 권장
  • Message Attribute로 Context 전달
  • 재시도/DLQ는 별도 Trace로 깔끔하게

PART 4: Coroutine + OTEL

  • ThreadLocal이 아닌 CoroutineContext로 전파
  • withContext: 같은 흐름 유지
  • launch: 새 Span으로 분리 권장
  • API 선택이 곧 Trace 설계

PART 5: 전체 정리

  • Collector Sidecar 패턴으로 애플리케이션과 백엔드 분리
  • 자주 발생하는 실수 5가지 주의
  • Trace는 코드가 아닌 의도를 기록

Trace 설계가 어렵다면, 이 질문으로 시작하세요:

"이 요청이 우리 시스템을 통과할 때, 어떤 일이 일어났는지 나중에 어떻게 이해할 것인가?"

그 답이 여러분의 Span 설계가 됩니다.

profile
일하며 겪은 문제를 나눠요

0개의 댓글