Observability를 공부하다 보면 MDC, trace_id, span_id, ThreadLocal, Context Propagation 같은 개념이 자주 등장한다.
특히 Node.js를 먼저 경험한 뒤 Java로 넘어오면 이런 의문이 생긴다.
이 글에서는 Java 기반 Observability에서 MDC와 OTel이 어떻게 연결되는지를 중심으로 정리한다.
MDC는 Mapped Diagnostic Context의 약자다.
쉽게 말하면:
로그에 공통으로 붙일 key-value 값을 저장해두는 공간
예를 들어 요청 하나를 처리하는 동안 다음 값을 로그에 계속 넣고 싶을 수 있다.
매번 이렇게 쓰는 것은 번거롭다.
log.info("requestId={} traceId={} order created", requestId, traceId);
그래서 MDC를 사용하면 다음처럼 공통 값을 미리 넣어둘 수 있다.
MDC.put("requestId", "abc123");
MDC.put("trace_id", "trace-1");
그 뒤에는 그냥 평범하게 로그를 찍어도 된다.
log.info("order created");
로그 출력 시점에 MDC 값을 읽어서 자동으로 붙여준다.
핵심은 ThreadLocal이다.
개념적으로는 이런 구조다.
Thread
└ MDC Map
├ requestId = abc123
├ trace_id = trace-1
└ span_id = span-1
즉,
MDC는 현재 스레드에서만 유효한 로그용 key-value 저장소
이다.
그래서 같은 스레드 안에서 찍는 로그는 모두 같은 MDC 값을 읽을 수 있다.
결론부터 말하면:
아니다. MDC는 AOP처럼 메서드를 가로채는 방식이 아니다.
MDC는 로그 출력 시점에 로깅 프레임워크가 읽는다.
흐름은 대략 이렇다.
log.info("hello")
↓
Logger
↓
Logback / Log4j
↓
Formatter / Encoder
↓
MDC.get("trace_id")
↓
로그 출력
즉 MDC는 "중간에 삽입"되는 것처럼 보이지만, 실제로는 로그 포맷팅 단계에서 읽혀서 출력된다.
여기서 많이 헷갈린다.
정리하면:
즉,
MDC는 OTel이 만든 개념이 아니라, Java 로깅 생태계에 원래 있던 기능이다
SLF4J는 org.slf4j.MDC API를 제공하고, 실제 구현은 Logback이나 Log4j 같은 로깅 구현체가 담당한다.
이 부분이 Java Observability에서 가장 중요한 포인트다.
처음 보면 이런 의문이 생긴다.
OTel이 trace_id를 관리하는데, 왜 또 MDC가 필요한가?
이유는 목적이 다르기 때문이다.
OTel Context는 tracing을 위한 저장소다.
여기에는 이런 정보가 들어 있다.
즉 목적은:
이다.
MDC는 logging을 위한 저장소다.
여기에는 로그에 찍고 싶은 key-value를 넣는다.
즉 목적은:
이다.
즉 구조는 이렇다.
OTel Context = tracing용 source of truth
MDC = logging용 복사본
보통은
OTel Context → MDC → Logs
흐름으로 연결된다.
예를 들어 이런 요청이 들어온다고 하자.
GET /orders
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-1111111111111111-01
OTel Java instrumentation은 대략 이런 흐름으로 동작한다.
traceparent 헤더를 읽는다trace_id, span_id를 넣는다즉 내부적으로는 이런 흐름이다.
HTTP Request
↓
OTel Instrumentation
↓
traceparent 읽기
↓
OTel Context 저장
↓
MDC 주입
↓
Application log.info()
정확히 말하면:
먼저 OTel Context에 저장되고, 로그 출력용으로 MDC에 복사될 수 있다
많이 단순화하면:
OTel Context.current()
└ Span
├ trace_id
└ span_id
그리고 로그 연동이 필요하면:
trace_id → MDC.put("trace_id", ...)
span_id → MDC.put("span_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}
↓
로그 출력
보통 두 가지 방식이 있다.
가장 흔한 방식이다.
애플리케이션 실행 시 agent를 붙인다.
-javaagent:opentelemetry-javaagent.jar
그러면 agent가 자동으로
같은 작업을 해준다.
즉 애플리케이션 코드를 직접 수정하지 않아도 된다.
원하면 애플리케이션 코드에서 직접 넣을 수도 있다.
Span span = Span.current();
MDC.put("trace_id", span.getSpanContext().getTraceId());
MDC.put("span_id", span.getSpanContext().getSpanId());
이 방식은 커스터마이징이 쉬운 대신, 직접 관리해야 한다.
이것도 자주 헷갈린다.
MDC 자체는 그냥 Map<String, String> 같은 저장소일 뿐이다.
즉 MDC는 key 이름에 대해 아무 규칙도 강제하지 않는다.
예:
MDC.put("tenantId", "acme");
MDC.put("userId", "42");
MDC.put("trace_id", "abc");
어떤 key를 넣을지는 사용하는 쪽 convention에 달려 있다.
OTel Java 생태계에서는 보통 이런 이름을 많이 쓴다.
trace_idspan_idtrace_flags즉 이건 OTel과 Java 로깅 연동에서의 관례(convention) 에 가깝다.
매우 간단하다.
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
결론부터 말하면:
로그에 거의 항상 붙어야 하는 공통값이면 MDC가 더 적합하다
예를 들면:
이런 값은 모든 로그에 공통으로 붙는 경우가 많다.
반면 이런 값은 로그 파라미터가 더 적합하다.
즉 정리하면:
가능하다.
예를 들어 log.info("...") 대신 appLogger.info("...") 같은 wrapper를 만들 수 있다.
이 wrapper 안에서 ThreadLocal이나 Context를 읽어 메시지를 조합할 수도 있다.
하지만 공통 로그 메타데이터 용도로는 보통 MDC가 더 낫다.
이유는 다음과 같다.
반면 wrapper 방식은 내 코드에는 적용돼도, 외부 라이브러리 로그에는 적용되지 않는다.
이 부분도 중요하다.
예를 들어 애플리케이션과 Hibernate가 모두 SLF4J + Logback을 쓴다고 하자.
그러면 구조는 이렇다.
내 코드 로그 --------┐
├─> SLF4J → Logback → Appender/Pattern
외부 라이브러리 로그 ┘
즉 같은 JVM 안에서 같은 Logback 설정을 공유하면,
내 애플리케이션의 Logback 설정은 라이브러리 로그에도 적용될 수 있다.
그리고 MDC는 ThreadLocal 기반이므로, 같은 스레드 안에서 찍히는 로그라면 라이브러리 로그도 같은 MDC 값을 읽을 수 있다.
MDC는 ThreadLocal 기반이기 때문에 같은 스레드에서는 잘 동작한다.
하지만 비동기 환경에서는 문제가 생긴다.
예를 들어:
@AsyncExecutorServiceCompletableFuture이런 경우 스레드가 바뀔 수 있다.
그러면 새 스레드에서는 MDC가 비어 있을 수 있다.
즉 이런 문제가 생긴다.
Thread A → trace_id 있음
Thread B → trace_id 없음
그래서 Java Observability에서 중요한 개념이 Context Propagation이다.
정확히 말하면:
공유하는 것이 아니라 전달(propagation)하는 것
즉 현재 스레드의 컨텍스트를 복사해서 새 스레드에서 복원한다.
개념적으로는 다음과 같다.
Thread A
└ MDC / Context
└ trace_id=abc
capture
↓
restore
↓
Thread B
└ MDC / Context
└ trace_id=abc
즉 Java에서는
이 별도로 중요하다.
Node.js에서는 보통 AsyncLocalStorage 같은 방식으로 async call chain 기반 context 전파를 사용한다.
반면 Java는 전통적으로 thread-per-request 모델이라 ThreadLocal을 많이 사용한다.
즉 느낌상 차이는 이렇다.
그래서 Node 경험이 있으면 Java의 MDC 구조가 조금 더 복잡하게 느껴질 수 있다.
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
이 흐름을 기억하면 전체 구조를 이해하기 쉽다.
마지막으로 핵심만 정리하면 다음과 같다.
Java Observability에서 MDC는 단순한 부가 기능이 아니라,
trace와 logs를 연결하는 핵심 고리다.
다만 MDC 자체는 tracing 시스템이 아니라 logging 시스템의 기능이다.
그래서 실제 구조는 이렇게 이해하는 것이 가장 정확하다.
OTel Context → MDC → Logs
즉,
이 구조를 이해하면 Java에서 trace correlation이 어떻게 동작하는지 거의 다 이해한 것이다.