MDC와 OpenTelemetry

hi·2026년 3월 14일

MDC와 OpenTelemetry 정리 — Java에서 trace_id는 로그에 어떻게 찍히는가

Observability를 공부하다 보면 MDC, trace_id, span_id, ThreadLocal, Context Propagation 같은 개념이 자주 등장한다.
특히 Node.js를 먼저 경험한 뒤 Java로 넘어오면 이런 의문이 생긴다.

  • Java에서는 왜 MDC를 쓰는가?
  • OTel은 request header의 trace 정보를 어디에 저장하는가?
  • 로그 라이브러리는 OTel과 독립적인데 trace_id를 어떻게 자동으로 출력하는가?
  • MDC는 표준인가?

이 글에서는 Java 기반 Observability에서 MDC와 OTel이 어떻게 연결되는지를 중심으로 정리한다.


1. MDC란 무엇인가

MDC는 Mapped Diagnostic Context의 약자다.

쉽게 말하면:

로그에 공통으로 붙일 key-value 값을 저장해두는 공간

예를 들어 요청 하나를 처리하는 동안 다음 값을 로그에 계속 넣고 싶을 수 있다.

  • request_id
  • trace_id
  • span_id
  • tenantId
  • userId

매번 이렇게 쓰는 것은 번거롭다.

log.info("requestId={} traceId={} order created", requestId, traceId);

그래서 MDC를 사용하면 다음처럼 공통 값을 미리 넣어둘 수 있다.

MDC.put("requestId", "abc123");
MDC.put("trace_id", "trace-1");

그 뒤에는 그냥 평범하게 로그를 찍어도 된다.

log.info("order created");

로그 출력 시점에 MDC 값을 읽어서 자동으로 붙여준다.


2. MDC는 어떻게 동작하는가

핵심은 ThreadLocal이다.

개념적으로는 이런 구조다.

Thread
 └ MDC Map
     ├ requestId = abc123
     ├ trace_id = trace-1
     └ span_id = span-1

즉,

MDC는 현재 스레드에서만 유효한 로그용 key-value 저장소

이다.

그래서 같은 스레드 안에서 찍는 로그는 모두 같은 MDC 값을 읽을 수 있다.


3. MDC는 AOP처럼 동작하는가

결론부터 말하면:

아니다. MDC는 AOP처럼 메서드를 가로채는 방식이 아니다.

MDC는 로그 출력 시점에 로깅 프레임워크가 읽는다.

흐름은 대략 이렇다.

log.info("hello")
   ↓
Logger
   ↓
Logback / Log4j
   ↓
Formatter / Encoder
   ↓
MDC.get("trace_id")
   ↓
로그 출력

즉 MDC는 "중간에 삽입"되는 것처럼 보이지만, 실제로는 로그 포맷팅 단계에서 읽혀서 출력된다.


4. MDC는 표준인가

여기서 많이 헷갈린다.

정리하면:

  • MDC는 OpenTelemetry 표준이 아니다
  • MDC는 로깅 프레임워크 쪽 개념이다
  • Log4j, Logback, SLF4J 생태계에서 오래전부터 사용되던 방식이다

즉,

MDC는 OTel이 만든 개념이 아니라, Java 로깅 생태계에 원래 있던 기능이다

SLF4J는 org.slf4j.MDC API를 제공하고, 실제 구현은 Logback이나 Log4j 같은 로깅 구현체가 담당한다.


5. OTel Context와 MDC는 왜 따로 있는가

이 부분이 Java Observability에서 가장 중요한 포인트다.

처음 보면 이런 의문이 생긴다.

OTel이 trace_id를 관리하는데, 왜 또 MDC가 필요한가?

이유는 목적이 다르기 때문이다.

OTel Context

OTel Context는 tracing을 위한 저장소다.

여기에는 이런 정보가 들어 있다.

  • trace_id
  • span_id
  • parent span
  • baggage
  • sampling 정보

즉 목적은:

  • 현재 span 관리
  • trace propagation
  • child span 생성

이다.

MDC

MDC는 logging을 위한 저장소다.

여기에는 로그에 찍고 싶은 key-value를 넣는다.

즉 목적은:

  • 로그에 trace_id 출력
  • 로그에 request_id 출력
  • 로그 correlation

이다.

관계 정리

즉 구조는 이렇다.

OTel Context = tracing용 source of truth
MDC          = logging용 복사본

보통은

OTel Context → MDC → Logs

흐름으로 연결된다.


6. HTTP 요청이 들어오면 Java OTel은 무엇을 하는가

예를 들어 이런 요청이 들어온다고 하자.

GET /orders
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-1111111111111111-01

OTel Java instrumentation은 대략 이런 흐름으로 동작한다.

  1. traceparent 헤더를 읽는다
  2. trace context를 복원한다
  3. 현재 요청용 span을 생성한다
  4. 현재 실행 컨텍스트에 span을 저장한다
  5. 필요하면 MDC에도 trace_id, span_id를 넣는다

즉 내부적으로는 이런 흐름이다.

HTTP Request
   ↓
OTel Instrumentation
   ↓
traceparent 읽기
   ↓
OTel Context 저장
   ↓
MDC 주입
   ↓
Application log.info()

7. Java에서는 trace 정보가 어디에 저장되는가

정확히 말하면:

먼저 OTel Context에 저장되고, 로그 출력용으로 MDC에 복사될 수 있다

많이 단순화하면:

OTel Context.current()
   └ Span
       ├ trace_id
       └ span_id

그리고 로그 연동이 필요하면:

trace_id → MDC.put("trace_id", ...)
span_id  → MDC.put("span_id", ...)

가 일어난다.


8. 로그 라이브러리는 OTel과 독립적인데 어떻게 trace_id를 찍는가

이 질문이 핵심이다.

정답은:

OTel이 직접 로그를 출력하는 것이 아니라, MDC를 통해 로깅 프레임워크와 연결한다

예를 들어 Logback 설정이 이렇게 되어 있다고 하자.

<pattern>
trace_id=%X{trace_id} span_id=%X{span_id} %msg%n
</pattern>

여기서 %X{trace_id}

MDC.get("trace_id")

를 의미한다.

즉 로그 라이브러리는 OTel을 몰라도 된다.
그냥 MDC만 읽는다.

그리고 OTel 쪽 integration 또는 agent가 MDC에 값을 넣어주면 된다.

흐름은 이렇게 된다.

OTel Context
   ↓
MDC.put("trace_id", ...)
   ↓
Logback %X{trace_id}
   ↓
로그 출력

9. 누가 MDC에 trace_id를 넣어주는가

보통 두 가지 방식이 있다.

1) OTel Java Agent

가장 흔한 방식이다.

애플리케이션 실행 시 agent를 붙인다.

-javaagent:opentelemetry-javaagent.jar

그러면 agent가 자동으로

  • HTTP instrumentation
  • trace context 복원
  • span 생성
  • MDC 연동

같은 작업을 해준다.

즉 애플리케이션 코드를 직접 수정하지 않아도 된다.

2) 직접 코드로 넣기

원하면 애플리케이션 코드에서 직접 넣을 수도 있다.

Span span = Span.current();

MDC.put("trace_id", span.getSpanContext().getTraceId());
MDC.put("span_id", span.getSpanContext().getSpanId());

이 방식은 커스터마이징이 쉬운 대신, 직접 관리해야 한다.


10. MDC의 key 이름은 누가 정하는가

이것도 자주 헷갈린다.

MDC 자체는 그냥 Map<String, String> 같은 저장소일 뿐이다.

즉 MDC는 key 이름에 대해 아무 규칙도 강제하지 않는다.

예:

MDC.put("tenantId", "acme");
MDC.put("userId", "42");
MDC.put("trace_id", "abc");

어떤 key를 넣을지는 사용하는 쪽 convention에 달려 있다.

OTel Java 생태계에서는 보통 이런 이름을 많이 쓴다.

  • trace_id
  • span_id
  • trace_flags

즉 이건 OTel과 Java 로깅 연동에서의 관례(convention) 에 가깝다.


11. MDC에 커스텀 값을 넣고 싶다면

매우 간단하다.

MDC.put("tenantId", tenantId);
MDC.put("userId", userId);

그리고 로그 패턴에서 출력하면 된다.

<pattern>
trace_id=%X{trace_id} tenantId=%X{tenantId} userId=%X{userId} %msg%n
</pattern>

그러면 로그는 이렇게 나온다.

trace_id=abc tenantId=acme userId=42 order created

12. 공통값은 MDC에 넣는 게 좋은가, 직접 함수로 넣는 게 좋은가

결론부터 말하면:

로그에 거의 항상 붙어야 하는 공통값이면 MDC가 더 적합하다

예를 들면:

  • trace_id
  • span_id
  • requestId
  • tenantId
  • userId
  • clientIp

이런 값은 모든 로그에 공통으로 붙는 경우가 많다.

반면 이런 값은 로그 파라미터가 더 적합하다.

  • orderId
  • retryCount
  • paymentStatus

즉 정리하면:

MDC에 넣기 좋은 것

  • 요청 단위 공통 메타데이터
  • 거의 모든 로그에 같이 붙어야 하는 값

로그 파라미터로 넣기 좋은 것

  • 특정 로그에서만 필요한 값
  • 이벤트 고유 정보

13. 커스텀 logger wrapper를 만드는 방법도 있지 않나

가능하다.

예를 들어 log.info("...") 대신 appLogger.info("...") 같은 wrapper를 만들 수 있다.

이 wrapper 안에서 ThreadLocal이나 Context를 읽어 메시지를 조합할 수도 있다.

하지만 공통 로그 메타데이터 용도로는 보통 MDC가 더 낫다.

이유는 다음과 같다.

  • 라이브러리 로그에도 적용 가능
  • Logback / Log4j / JSON encoder와 자연스럽게 연동
  • OTel 연동이 쉬움
  • 실수로 wrapper를 안 써도 되는 구조를 만들 수 있음

반면 wrapper 방식은 내 코드에는 적용돼도, 외부 라이브러리 로그에는 적용되지 않는다.


14. 왜 MDC는 외부 라이브러리 로그에도 영향을 주는가

이 부분도 중요하다.

예를 들어 애플리케이션과 Hibernate가 모두 SLF4J + Logback을 쓴다고 하자.

그러면 구조는 이렇다.

내 코드 로그 --------┐
                    ├─> SLF4J → Logback → Appender/Pattern
외부 라이브러리 로그 ┘

즉 같은 JVM 안에서 같은 Logback 설정을 공유하면,
내 애플리케이션의 Logback 설정은 라이브러리 로그에도 적용될 수 있다.

그리고 MDC는 ThreadLocal 기반이므로, 같은 스레드 안에서 찍히는 로그라면 라이브러리 로그도 같은 MDC 값을 읽을 수 있다.


15. MDC의 가장 큰 한계: ThreadLocal

MDC는 ThreadLocal 기반이기 때문에 같은 스레드에서는 잘 동작한다.
하지만 비동기 환경에서는 문제가 생긴다.

예를 들어:

  • @Async
  • ExecutorService
  • CompletableFuture
  • Kafka consumer thread
  • Reactor / WebFlux

이런 경우 스레드가 바뀔 수 있다.

그러면 새 스레드에서는 MDC가 비어 있을 수 있다.

즉 이런 문제가 생긴다.

Thread A → trace_id 있음
Thread B → trace_id 없음

그래서 Java Observability에서 중요한 개념이 Context Propagation이다.


16. ThreadLocal은 여러 스레드에서 어떻게 공유하는가

정확히 말하면:

공유하는 것이 아니라 전달(propagation)하는 것

즉 현재 스레드의 컨텍스트를 복사해서 새 스레드에서 복원한다.

개념적으로는 다음과 같다.

Thread A
  └ MDC / Context
      └ trace_id=abc

capture
   ↓
restore
   ↓

Thread B
  └ MDC / Context
      └ trace_id=abc

즉 Java에서는

  • OTel Context propagation
  • MDC propagation

이 별도로 중요하다.


17. Node.js와 Java의 차이

Node.js에서는 보통 AsyncLocalStorage 같은 방식으로 async call chain 기반 context 전파를 사용한다.

반면 Java는 전통적으로 thread-per-request 모델이라 ThreadLocal을 많이 사용한다.

즉 느낌상 차이는 이렇다.

Node.js

  • async context 중심
  • SDK가 자동으로 전파하는 느낌이 강함

Java

  • thread context 중심
  • ThreadLocal / MDC / propagation 문제를 더 많이 의식해야 함

그래서 Node 경험이 있으면 Java의 MDC 구조가 조금 더 복잡하게 느껴질 수 있다.


18. 실무에서 많이 쓰는 구조

Java Observability에서 많이 쓰는 구조는 대략 이렇다.

HTTP Request
   ↓
OTel Instrumentation
   ↓
OTel Context 생성
   ↓
MDC 복사
   ↓
Application / Library logs
   ↓
Logback 출력

조금 더 추상화하면:

OTel Context = trace용 source of truth
MDC          = 로그용 복사본
Logs         = 최종 출력

OTel Context → MDC → Logs

이 흐름을 기억하면 전체 구조를 이해하기 쉽다.


19. 핵심 정리

마지막으로 핵심만 정리하면 다음과 같다.

MDC란?

  • 로그에 공통으로 붙일 key-value를 저장하는 로깅용 컨텍스트
  • ThreadLocal 기반

OTel Context란?

  • trace_id, span_id, baggage 등을 담는 tracing용 컨텍스트
  • 현재 span과 propagation의 기준

둘의 관계는?

  • OTel Context가 원본
  • MDC는 로그 출력을 위한 복사본

왜 둘이 따로 있나?

  • tracing과 logging의 목적이 다르기 때문

로그 라이브러리는 OTel과 어떻게 연결되나?

  • 직접 연결되는 것이 아니라 MDC를 통해 연결된다

왜 비동기에서 문제가 생기나?

  • MDC가 ThreadLocal 기반이라 스레드가 바뀌면 값이 사라질 수 있기 때문

결론

Java Observability에서 MDC는 단순한 부가 기능이 아니라,
trace와 logs를 연결하는 핵심 고리다.

다만 MDC 자체는 tracing 시스템이 아니라 logging 시스템의 기능이다.
그래서 실제 구조는 이렇게 이해하는 것이 가장 정확하다.

OTel Context → MDC → Logs

즉,

  • OTel Context는 추적을 위한 컨텍스트
  • MDC는 로그를 위한 컨텍스트
  • 로그 출력 시점에 MDC가 읽혀서 trace_id와 span_id가 찍힌다

이 구조를 이해하면 Java에서 trace correlation이 어떻게 동작하는지 거의 다 이해한 것이다.

0개의 댓글