코드 저장소.

ContextSnapshot과 OTLP를 활용한 분산 추적(Distributed Tracing) 구축기 본문

포폴/일정관리 프로젝트 vol.02

ContextSnapshot과 OTLP를 활용한 분산 추적(Distributed Tracing) 구축기

slown 2026. 6. 25. 02:23

목차

1. 문제 제기

2. 원인 분석

3. 해결 방안 

4. 적용

5.소감

 

1. 문제 제기

일정관리 프로젝트 Daily Line은 초기에 단일 LightSail 인프라에서 핵심 기능을 구현후, 시스템 고도화를 위해서 분산 환경(EC2 + Kafka + Redis)으로 아키텍처를 확장했습니다. 이 과정에서 비동기 알림 파이프라인(Kafka)과 분산 스케줄러 중복 방지 솔루션(ShedLock)이 복잡하게 얽히기 시작했습니다.

 

단일 서버 환경에서는 유저의 요청부터 로깅까지 하나의 스레드가 책임졌기에 ThreadLocal 기반의 MDC(Mapped Diagnostic Context) 로그 추적이 수월했습니다. 하지만 서버가 쪼개지고, ShedLock과 카프카를 통한 비동기 장벽이 생기자 에러가 터져도 어떤 스레드와 이벤트를 타고 장애가 전파되었는지 추적하기가 불가능한 Observability(관측 가능성)의 치명적인 한계에 직면했습니다.   

2. 원인 분석

초기에는 MDC만으로도 요청 단위의 로그를 추적할 수 있다고 생각했습니다. 하지만 분산 환경으로 전환하면서 기존 방식에는 명확한 한계가 존재한다는 것을 확인했습니다.

 

MDC는 내부적으로 ThreadLocal 기반으로 동작합니다. 즉, 하나의 요청을 처리하는 동일한 스레드에서는 Trace ID와 같은 컨텍스트를 유지할 수 있지만, 스레드가 변경되거나 프로세스가 달라지는 순간 해당 정보는 자동으로 전달되지 않는 단점이 있습니다.

 

이번 프로젝트에서는 크게 세 가지 구간에서 컨텍스트가 단절되었습니다.

 

2-1. @Async 비동기 스레드

 

Spring의 @Async는 별도의 ThreadPool에서 작업을 수행합니다. 기존 HTTP 요청 스레드에서 저장했던 MDC 정보는 새로운 Worker Thread까지 자동으로 전달되지 않았고, 결국 비동기 작업에서는 Trace ID가 사라지는 문제가 발생했습니다.

 

2-2. ShedLock Scheduler

 

분산 환경에서는 여러 서버가 동일한 스케줄러를 실행하지 않도록 ShedLock을 적용했습니다.

하지만 ShedLock 역시 Spring의 TaskScheduler에서 새로운 스레드를 생성하여 작업을 수행하기 때문에 기존 요청 스레드와는 완전히 분리되어 있습니다. 즉 Scheduler가 시작되는 순간 기존 MDC 정보는 이어지지 않았습니다.

 

 

2-3. Kafka 메시지 처리

 

가장 큰 문제는 Kafka였습니다. Producer가 메시지를 발행하면 Kafka Broker를 거쳐 다른 EC2의 Consumer가 메시지를 처리하게 됩니다. 이 과정에서는

  • Thread도 달라지고
  • JVM도 달라지고
  • 서버도 달라집니다.

 

 

ThreadLocal 기반 MDC는 메모리 내부에서만 유지되기 때문에 Kafka Broker를 통과하는 순간 기존 Trace 정보는 모두 사라지게 됩니다.

 

결국 기존 MDC만으로는 분산 환경 전체를 하나의 요청 흐름으로 추적하는 것이 불가능했다고 판단이 들었습니다. 그래서 ThreadLocal 기반 MDC만 사용하는 방식에서 벗어나 분산 추적을 도입을 하기로 했습니다. 

 

3. 해결 방안

우선은 분산추적을 도입을 하기 위해서 분산추적에 필요한 라이브러리에 관한 선정 및 트레이드오프에 대해 검토를 해봤습니다.

 

3-1. MDC만 사용하는 방식

 

장점

  • 구현이 단순하다.
  • 로그 추적이 쉽다.
  • 별도 인프라가 필요 없다.

단점

  • ThreadLocal 기반이라 스레드 변경 시 Context가 사라진다.
  • Kafka와 같은 프로세스 간 통신을 추적할 수 없다.
  • 전체 요청 흐름을 시각화할 수 없다.

 

3-2. Spring Cloud Sleuth

 

장점

  • Spring Boot 환경에서 자동 Trace 전파를 지원한다.
  • MDC 연동이 편리하다.

단점

  • Spring Boot 3부터 공식 지원이 종료되었다.
  • OpenTelemetry 중심으로 생태계가 변경되었다.

 

3-3. OpenTelemetry + Micrometer Tracing 선택

 

프로젝트는 Spring Boot 3.x 기반으로 구성되어 있었습니다.  Spring 진영의 공식 방향성이 OpenTelemetry와 Micrometer Tracing으로 이동한 만큼 장기적인 유지보수와 확장성을 고려하여 OpenTelemetry를 선택했습니다.

 

장점

  • Spring Boot 3 공식 지원
  • Context Propagation 지원
  • Kafka, HTTP, Scheduler 등 다양한 환경에서 Trace 전파 가능
  • Prometheus, Grafana, Tempo와 연동 가능

단점

  • 초기 설정 복잡
  • Trace, Span, Observation 개념 학습 필요
  • Collector 및 Trace 저장소 필요

 

3-4. Grafana Tempo 선택

 

분산 추적을 도입하면서 Trace 데이터를 저장할 저장소가 필요했습니다. 프로젝트에서는 이미 Prometheus와 Grafana를 사용하여 메트릭을 수집하고 있었고, 로그 저장소로 Loki도 운영 중이었습니다. 따라서 동일한 Grafana 생태계에서 관리할 수 있는 Tempo를 선택했습니다. 이를 통해 다음과 같은 구조를 구성할 수 있었습니다.

  • Metrics : Prometheus
  • Logs : Loki
  • Traces : Tempo

이를 통해서 하나의 Grafana Dashboard에서 함께 분석할 수 있었으며, 장애 발생 시 메트릭 → 로그 → Trace를 하나의 화면에서 연계하여 분석할 수 있는 Observability 환경을 구축했습니다. 메모리 단절을 극복하고 네트워크 장벽을 넘어 Trace ID를 끝까지 전파(Context Propagation)하기 위해 다음과 같이 고도화 작업을 진행했습니다.

 

아래의 사진은 첫번째는 단일 인스턴스의 모니터링 구조이고 두번째는 분산서버로 전환후의 모니터링 아키텍처입니다 

 

분산서버로 고도화전 모니터링 구조
분산서버로 전환후의 모니터링 아키텍처

 

변경된 모니터링 아키텍처의 설명을 하면 다음과 같습니다.

  • 주황색 실선/점선 (Tracing): 애플리케이션 내의 OTel Agent(OpenTelemetry)가 분산 트레이스 컨텍스트(Trace ID)를 수집하여 모니터링 서버의 Grafana Tempo로 전송하는 경로입니다. 카프카 메시징 비동기 추적의 핵심 파이프라인입니다.
  • 하늘색 실선 (Core Business): 서비스 서버의 스프링 부트 애플리케이션이 실제 비즈니스 로직 처리를 위해 infra server의 Redis CacheKafka Cluster와 통신하는 핵심 백엔드 데이터 링커입니다.
  • 보라색 실선 (Logging): 각 서비스 서버에 경량 에이전트로 심어진 Promtail이 스프링 부트의 MDC 로그 파일을 실시간으로 스크래핑하여 모니터링 서버의 Grafana Loki로 중앙 집중화하는 경로입니다.
  • 초록색 점선 (Metrics): 모니터링 서버의 Prometheus가 각 서비스 서버의 actuator/prometheus 엔드포인트(exporter)를 주기적으로 풀링(Pull)하여 힙 메모리, 스레드 풀, 인프라 메트릭 데이터를 수집하는 경로입니다.
  • 검은색 실선 (Routing): 클라이언트의 요청이 외부 게이트웨이인 Nginx를 거쳐 각 service server로 로드밸런싱되어 라우팅되는 진입 경로입니다.

 

4. 적용

 

분산 추적을 하기 위해서 프로젝트에서 아래와 같은 방법으로 적용을 했습니다. 

 

4-1. 라이브러리 적용

dependencies {
    // Micrometer Observation & Tracing 핵심 엔진
    implementation 'io.micrometer:micrometer-observation'
    implementation 'io.micrometer:micrometer-tracing-bridge-otel'
    implementation 'io.micrometer:context-propagation'
    
    // OTLP Exporter (Grafana Tempo 연동)
    implementation 'io.opentelemetry:opentelemetry-exporter-otlp'

    implementation 'org.springframework.boot:spring-boot-starter-aop'
    implementation 'org.springframework.boot:spring-boot-starter-logging'
}

 

 

  • micrometer-observation & micrometer-tracing-bridge-otel: Spring Boot 3.x부터는 기존 Spring Cloud Sleuth가 deprecated되고 Micrometer Observation이 글로벌 표준 엔진으로 정착했습니다. 본 프로젝트에서는 이를 OpenTelemetry(OTel) 생태계와 연결하기 위해 전용 브릿지 라이브러리를 채택했습니다.
  • context-propagation: 서로 다른 스레드 로컬(ThreadLocal) 간의 컨텍스트 스냅샷을 캡처하고 복구하기 위한 핵심 reactive 라이브러리입니다. 이 도구가 있어야 비동기 장벽을 넘어 관측 컨텍스트를 안정적으로 배달할 수 있습니다.
  • opentelemetry-exporter-otlp: 수집된 트레이스(Trace) 데이터를 W3C 글로벌 표준 프로토콜인 OTLP 규격으로 변환하여, 백엔드 모니터링 인프라인 Grafana Tempo 서버로 유기적으로 푸시(Push)하기 위해 도입했습니다.

 

 

4-2. application.yml 인프라 수집 설정

management:
  metrics:
    enable:
      all: true
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,tracing
      base-path: /actuator
  endpoint:
    prometheus:
      enabled: true
  tracing:
    sampling:
      probability: 1.0 # 100% 샘플링 수집
  otlp:
    tracing:
      endpoint: "http://10.0.1.73:4318/v1/traces" # 사내 가상망 내 Tempo 수집 서버 주소

 

 

  • exposure.include: health,info,prometheus,tracing: Actuator 엔드포인트를 열어 Metrics와 Tracing 데이터를 외부 수집기가 긁어갈 수 있도록 개방했습니다.
  • sampling.probability: 1.0: 테스트 및 인프라 검증 환경인 만큼, 단 하나의 트레이스 누수도 허용하지 않고 카프카 메시징의 모든 End-to-End 흐름을 관측하기 위해 100% 샘플링 수집률로 튜닝했습니다.
  • otlp.tracing.endpoint: 애플리케이션의 오버헤드를 최소화하기 위해 수집된 분산 트레이스 데이터를 가상망(10.0.1.73) 내에 격리 구축된 Grafana Tempo OTLP 수집 엔드포인트(포트 4318)로 다이렉트 라우팅하도록 명시했습니다.

 

 

4-3. AsyncConfig.java 커스텀 스레드 데코레이터 구현

 

    /**
     * 비동기 스레드 실행 시, 부모 스레드의 MDC와 관측(Observation) 컨텍스트를
     * 안전하게 복사하여 자식 스레드로 전파하는 TaskDecorator.
     */
    @Bean
    public TaskDecorator mdcTaskDecorator() {
        return runnable -> {
            // [부모 스레드] 현재의 Trace 정보와 로깅 컨텍스트(MDC) 스냅샷 캡처
            var contextSnapshot = ContextSnapshot.captureAll();
            Map<String, String> contextMap = MDC.getCopyOfContextMap();
            
            return () -> {
                // [자식 스레드] 실행 시점에 캡처했던 컨텍스트 복구
                // try-with-resources 종료 시 자동으로 ThreadLocal 자원 정리
                try (var scope = contextSnapshot.setThreadLocals()) {
                    if (contextMap != null) MDC.setContextMap(contextMap);
                    // 실제 비즈니스 로직 실행
                    runnable.run();
                } finally {
                    // 스레드 풀 재사용 시 오염 방지를 위한 명시적 초기화
                    MDC.clear();
                }
            };
        };
    }

 

  • TaskDecorator (mdcTaskDecorator): 스프링의 스레드 이그제큐터가 자식 스레드를 띄우기 직전, 부모 스레드의 상태를 랩핑(Wrapping)할 수 있는 가로채기 메커니즘입니다.
  • ContextSnapshot.captureAll(): 부모 스레드가 가진 관측(Trace/Span) 정보와 로깅 생태계의 MDC 컨텍스트를 통째로 스냅샷으로 복사하여 비동기 스레드로 인계하는 핵심 트리거입니다.
  • try (var scope = contextSnapshot.setThreadLocals()): AutoCloseable 패턴을 활용하여, 비동기 로직이 정상 종료되거나 예외가 발생하더라도 try-with-resources 구문에 의해 자식 스레드의 ThreadLocal 자원이 안전하게 스코프 아웃(Close)되도록 안전장치를 마련했습니다.
  • finally { MDC.clear(); }: 톰캣이나 스레드 풀(ThreadPoolTaskExecutor)은 스레드를 소멸시키지 않고 재사용합니다. 만약 스레드가 반환될 때 MDC를 명시적으로 비워주지 않으면, 다른 유저의 요청을 처리하는 스레드에 이전 유저의 Trace ID가 섞여 출력되는 심각한 '로그 오염 및 데이터 누수(Data Leak)' 버그가 발생합니다. 이를 finally 블록으로 원천 차단했습니다.
@Bean(name = "threadPoolTaskExecutor")
public Executor threadPoolTaskExecutor(TaskDecorator mdcTaskDecorator) {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setTaskDecorator(mdcTaskDecorator);
        executor.setCorePoolSize(20);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("AsyncExecutor-");
        // 풀이 혼잡할 시 호출한 스레드에서 직접 처리하도록 Backpressure 전략 설정
        executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy()); 
        executor.initialize();
        return executor;
 }

 

 

  • CallerRunsPolicy() 정책 채택: 만약 스레드 풀의 대기 큐(500개)까지 가득 차는 극단적인 트래픽 폭주가 발생할 경우, 시스템이 즉시 다운(TaskRejectedException)되는 대신 작업을 요청한 메인 스레드(톰캣 혹은 ShedLock 스케줄러 스레드)가 직접 비즈니스 로직을 동기적으로 처리하게 함으로써 시스템의 연속성과 안정성(Backpressure)을 확보하도록 프로덕션 레벨로 설계했습니다.

 

5.소감

분산 환경으로 시스템을 확장하면 무조건 빨라지고 좋을 줄만 알았습니다. 하지만 인프라를 쪼개고 비동기 장벽(ShedLock, Kafka)을 세우는 순간, 눈앞이 캄캄해지는 '모니터링의 지옥(Observability의 부재)'이 열린다는 기술적 트레이드오프를 온몸으로 겪었습니다.

단순히 오픈소스 툴을 띄울 줄 안다는 것을 넘어, 스레드 풀 전환 시의 로그 오염 맹점이나 네트워크 장벽을 넘는 Context Propagation 원리를 집요하게 파고들어 직접 해결해 보았습니다. 특히 과부하 상황을 고려해 CallerRunsPolicy를 적용하며 백프레셔(Backpressure) 메커니즘까지 고민해 본 과정은, 단순한 기능 구현을 넘어 '시스템의 시작부터 운영, 장애 대응까지 책임질 수 있는 백엔드 개발자'의 시야를 갖추게 해 준 경험이었습니다.