|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: " MDC와 GlobalTraceId를 활용한 분산 추적 " |
| 4 | +categories: SpringBoot Technology |
| 5 | +author: devFancy |
| 6 | +--- |
| 7 | +* content |
| 8 | +{:toc} |
| 9 | + |
| 10 | +## Prologue |
| 11 | + |
| 12 | +이번 글에서는 분산 시스템 환경에서 서로 다른 서버의 로그에 동일한 TraceId를 적용한 과정을 간단하게 정리해보고자 한다. |
| 13 | + |
| 14 | +* 분산 아키텍처에서는 하나의 요청이 여러 서비스(예: API 서버, 메시지 큐 컨슈머)를 거쳐 처리되는 경우가 많다. |
| 15 | + |
| 16 | + 이때 각 시스템에 분산된 로그를 하나의 흐름으로 묶어 추적할 수 없다면, 장애 발생 시 원인을 파악하기 매우 어려워진다. |
| 17 | + |
| 18 | +* 현재 진행 중인 개인 프로젝트(쿠폰 시스템)에서 쿠폰 발급 요청이 |
| 19 | + |
| 20 | + API 서버에서 시작되어 카프카(Kafka)를 통해 컨슈머 서버로 전달되는 과정이 있다. |
| 21 | + |
| 22 | + 이 두 서버의 로그에 동일한 식별자를 부여하여 요청 흐름을 쉽게 추적할 수 있도록 `GlobalTraceId`를 적용한 경험을 공유한다. |
| 23 | + |
| 24 | +> `참고`: 본 글에서 소개되는 코드 예시는 현재 시점의 구현을 바탕으로 작성되었으며, 프로젝트가 발전함에 따라 내용이 변경되거나 개선될 수 있음을 미리 알려드립니다. |
| 25 | +> |
| 26 | +> 이 글에서 다루는 모든 코드는 [깃허브](https://github.com/devFancy/springboot-coupon-system)에서 확인하실 수 있습니다. |
| 27 | +
|
| 28 | + |
| 29 | +--- |
| 30 | + |
| 31 | +## 현재 상황: 분리된 로그의 파편 |
| 32 | + |
| 33 | +현재 쿠폰 발급 API는 요청을 받으면 내부 로직을 처리한 후, 최종 데이터 저장을 위해 카프카에 메시지를 발행(produce)한다. |
| 34 | + |
| 35 | +그리고 별도의 컨슈머 애플리케이션이 이 메시지를 구독(consume)하여 데이터베이스에 저장하는 구조를 가진다. |
| 36 | + |
| 37 | +핵심 흐름은 다음과 같다. |
| 38 | + |
| 39 | +* HTTP Request -> `쿠폰 API 서버` -> Kafka Produce -> Kafka Consume -> `컨슈머 서버` -> DB 저장 |
| 40 | + |
| 41 | +이 구조에서 Spring Boot Actuator와 Micrometer Tracing 같은 라이브러리를 사용하면 각 애플리케이션 내에서는 `traceId`와 `spanId`가 자동으로 생성되어 로그에 포함된다. |
| 42 | + |
| 43 | +문제는 **API 서버에서 생성된 `traceId`가 카프카를 거쳐 컨슈머 서버로 전파되지 않는다는 점**이다. |
| 44 | + |
| 45 | +카프카는 메시지를 전달할 뿐, 생산자(Producer)의 실행 컨텍스트(MDC 정보 포함)를 소비자(Consumer)에게 자동으로 넘겨주지 않는다. |
| 46 | + |
| 47 | +결과적으로 컨슈머는 새로운 요청으로 인지하고, 완전히 새로운 `traceId`를 생성하게 된다. |
| 48 | + |
| 49 | +> MDC 컨텍스트가 전파되지 않는 이유 |
| 50 | +
|
| 51 | +* `MDC`(Mapped Diagnostic Context)는 스레드 로컬(thread-local)에 데이터를 저장하여 로깅 프레임워크(Logback 등)가 참조할 수 있도록 하는 SLF4J의 기능이다. |
| 52 | + |
| 53 | +* Micrometer Tracing은 이 MDC에 `traceId`와 `spanId`를 저장하여 로그에 컨텍스트 정보가 남도록 한다. |
| 54 | + |
| 55 | +* 그러나 API 서버의 요청 처리 스레드와 컨슈머의 메시지 처리 스레드는 완전히 분리되어 있다. |
| 56 | + |
| 57 | +* 따라서 API 서버의 MDC 정보는 카프카 메시지와 함께 자동으로 전송되지 않아 컨슈머의 MDC에서 조회할 수 없다. 이로 인해 두 서버의 로그가 단절되는 것이다. |
| 58 | + |
| 59 | +아래 로그를 보면, 두 서버의 `traceId`가 서로 다른 것을 명확히 확인할 수 있다. |
| 60 | + |
| 61 | + |
| 62 | +> API 서버 로그 (traceId: `6852b11d...`) |
| 63 | +
|
| 64 | +```bash |
| 65 | +21:29:03.238| INFO|6852b11d60ddb3445244ba6270080315,5244ba6270080315|d.b.c.a.c.i.r.aop.DistributedLockAop |락 획득 성공... |
| 66 | +21:29:03.268| INFO|6852b11d60ddb3445244ba6270080315,5244ba6270080315|d.b.c.a.c.application.CouponService |중복 발급 요청 감지... |
| 67 | +``` |
| 68 | + |
| 69 | +> 컨슈머 서버 로그 (traceId: `6852b10f...`) |
| 70 | +
|
| 71 | +```bash |
| 72 | +21:29:03.404| INFO|6852b10febe4df79b383d66d36df8483,b383d66d36df8483|d.b.c.k.c.a.CouponIssueConsumer |발급 처리 메시지 수신... |
| 73 | +21:29:03.488| INFO|6852b10febe4df79b383d66d36df8483,b383d66d36df8483|d.b.c.k.c.a.CouponIssueConsumer |쿠폰 발급 완료... |
| 74 | +``` |
| 75 | + |
| 76 | + |
| 77 | +## 해결 방안: GlobalTraceId를 이용한 수동 전파 |
| 78 | + |
| 79 | +> GlobalTraceId를 개인 프로젝트에 적용한 부분과 관련된 코드는 깃허브 [PR](https://github.com/devFancy/springboot-coupon-system/pull/33) 에서 확인할 수 있다. |
| 80 | +
|
| 81 | +이 문제를 해결하기 위해 전체 요청 흐름을 관통하는 단일 식별자인 `GlobalTraceId`를 도입한다. |
| 82 | + |
| 83 | +이 ID를 HTTP 요청의 시작점에서 생성하고, 카프카 메시지 헤더를 통해 컨슈머까지 명시적으로 전달하는 것이다. |
| 84 | + |
| 85 | +* 참고) Micrometer의 자동 전파 기능과 `GlobalTraceId`의 차이점 |
| 86 | + |
| 87 | + * Spring Boot 3.x 환경에서 `micrometer-tracing-bridge-brave`나 `micrometer-tracing-bridge-otel` 의존성을 추가하면, |
| 88 | + Micrometer가 자동으로 Kafka Producer와 Consumer를 계측하여 트레이스 컨텍스트(traceId, spanId)를 전파해 준다. |
| 89 | + Spring Boot 2.x에서는 Spring Cloud Sleuth가 이 역할을 했다. |
| 90 | + |
| 91 | + * 하지만 이 글에서 다루는 `GlobalTraceId`는 필자가 **직접 만든 커스텀 필드**이므로 이러한 자동 전파의 대상이 아니다. |
| 92 | + 이처럼 라이브러리가 모르는 커스텀 식별자를 서비스 간에 전달해야 할 때는, |
| 93 | + 이 글에서 소개한 것처럼 **직접 헤더에 담아 전달하는 수동 전파 방식**이 필요하다. |
| 94 | + |
| 95 | + * 이는 분산 추적의 핵심 원리를 이해하고 우리가 원하는 식별자를 직접 제어할 수 있다는 장점이 있다. |
| 96 | + |
| 97 | +### 1. Logback 설정: GlobalTraceId 출력 필드 추가 |
| 98 | + |
| 99 | +먼저, 로그 패턴에 `GlobalTraceId`를 출력할 수 있도록 `logback-spring.xml` 설정에 globalTraceId 필드를 추가한다. |
| 100 | + |
| 101 | +이 필드는 MDC에 해당 키가 존재할 경우 그 값을 출력한다. |
| 102 | + |
| 103 | +> logback-local.xml |
| 104 | +
|
| 105 | +```xml |
| 106 | +<configuration> |
| 107 | + <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> |
| 108 | + <encoder> |
| 109 | + <pattern>%clr(%d{HH:mm:ss.SSS}){faint}|%clr(${level:-%5p})|%32X{globalTraceId:-}|%32X{traceId:-},%16X{spanId:-}|%clr(%-40.40logger{39}){cyan}%clr(|){faint}%m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}</pattern> |
| 110 | + <charset>utf8</charset> |
| 111 | + </encoder> |
| 112 | + </appender> |
| 113 | +</configuration> |
| 114 | +``` |
| 115 | + |
| 116 | + |
| 117 | +### 2. Filter: GlobalTraceId 생성 및 MDC 적용 |
| 118 | + |
| 119 | +HTTP 요청이 들어오는 가장 앞단에서 `GlobalTraceId`를 생성하거나, |
| 120 | + |
| 121 | +외부 시스템으로부터 이미 전달받았다면 해당 값을 사용하도록 필터를 구현한다. |
| 122 | + |
| 123 | +* 요청 헤더에 `X-Global-Trace-Id`가 있으면 그 값을 사용한다. |
| 124 | + |
| 125 | +* 없으면 `UUID`를 이용해 새로운 ID를 생성한다. |
| 126 | + |
| 127 | +* 생성된 ID를 MDC에 `globalTraceId`라는 키로 저장하여 이후의 모든 로그에 기록될 수 있도록 한다. |
| 128 | + |
| 129 | +* 요청 처리가 끝나면 반드시 `MDC.remove()`를 호출하여 메모리 누수를 방지한다. |
| 130 | + |
| 131 | +> HttpRequestAndResponseLoggingFilter.java |
| 132 | +
|
| 133 | +```java |
| 134 | +public class HttpRequestAndResponseLoggingFilter extends OncePerRequestFilter { |
| 135 | + |
| 136 | + private static final String GLOBAL_TRACE_ID_HEADER = "X-Global-Trace-Id"; |
| 137 | + private static final String GLOBAL_TRACE_ID_KEY = "globalTraceId"; |
| 138 | + |
| 139 | + @Override |
| 140 | + protected void doFilterInternal(@NonNull final HttpServletRequest request, |
| 141 | + @NonNull final HttpServletResponse response, |
| 142 | + @NonNull final FilterChain filterChain) { |
| 143 | + // 중간 생략 (request/response wrapper) |
| 144 | + |
| 145 | + String globalTraceId = request.getHeader(GLOBAL_TRACE_ID_HEADER); |
| 146 | + if (!StringUtils.hasText(globalTraceId)) { |
| 147 | + globalTraceId = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 32); |
| 148 | + } |
| 149 | + MDC.put(GLOBAL_TRACE_ID_KEY, globalTraceId); |
| 150 | + |
| 151 | + try { |
| 152 | + filterChain.doFilter(request, response); |
| 153 | + // 중간 생략 (로깅 처리) |
| 154 | + } catch (Exception e) { |
| 155 | + // 중간 생략 (예외 처리) |
| 156 | + } finally { |
| 157 | + // 요청 처리가 끝나면 반드시 MDC에서 제거해야 한다. |
| 158 | + MDC.remove(GLOBAL_TRACE_ID_KEY); |
| 159 | + } |
| 160 | + } |
| 161 | + // 뒷부분 생략 |
| 162 | +} |
| 163 | +``` |
| 164 | + |
| 165 | + |
| 166 | +### 3.Kafka Producer: 메시지 헤더에 GlobalTraceId 주입 |
| 167 | + |
| 168 | +API 서버에서 카프카로 메시지를 보낼 때, 현재 스레드의 MDC에서 `globalTraceId`를 꺼내 카프카 메시지 헤더에 추가한다. |
| 169 | + |
| 170 | +이 헤더가 바로 서버 간 컨텍스트를 이어주는 **다리 역할**을 한다. |
| 171 | + |
| 172 | +> CouponIssueProducer.java |
| 173 | +
|
| 174 | +```java |
| 175 | +@Component |
| 176 | +public class CouponIssueProducer { |
| 177 | + |
| 178 | + private final KafkaTemplate<String, Object> kafkaTemplate; |
| 179 | + private static final String GLOBAL_TRACE_ID_HEADER = "globalTraceId"; |
| 180 | + |
| 181 | + // 중간 생략 (생성자) |
| 182 | + |
| 183 | + public void issue(final UUID userId, final UUID couponId) { |
| 184 | + CouponIssueMessage payload = new CouponIssueMessage(userId, couponId); |
| 185 | + // MDC에서 GlobalTraceId를 가져온다. |
| 186 | + String globalTraceId = MDC.get("globalTraceId"); |
| 187 | + |
| 188 | + Message<CouponIssueMessage> message = MessageBuilder |
| 189 | + .withPayload(payload) |
| 190 | + .setHeader(KafkaHeaders.TOPIC, "coupon_issue") |
| 191 | + // Kafka 메시지 헤더에 GlobalTraceId를 추가한다. |
| 192 | + .setHeader(GLOBAL_TRACE_ID_HEADER, globalTraceId) |
| 193 | + .build(); |
| 194 | + |
| 195 | + kafkaTemplate.send(message); |
| 196 | + } |
| 197 | +} |
| 198 | +``` |
| 199 | + |
| 200 | + |
| 201 | +### 4. Kafka Consumer: 헤더에서 GlobalTraceId 추출 및 MDC 탑재 |
| 202 | + |
| 203 | +마지막으로, 컨슈머는 메시지를 수신할 때 헤더에 포함된 `globalTraceId`를 추출하여 자신의 MDC에 설정한다. |
| 204 | + |
| 205 | +이로써 컨슈머에서 발생하는 모든 로그에도 API 서버와 동일한 `GlobalTraceId`가 남게 된다. |
| 206 | + |
| 207 | +> CouponIssueConsumer.java |
| 208 | +
|
| 209 | +```java |
| 210 | +@Component |
| 211 | +public class CouponIssueConsumer { |
| 212 | + |
| 213 | + private final IssuedCouponRepository issuedCouponRepository; |
| 214 | + private static final String GLOBAL_TRACE_ID_KEY = "globalTraceId"; |
| 215 | + private static final String GLOBAL_TRACE_ID_HEADER = "globalTraceId"; |
| 216 | + |
| 217 | + // 중간 생략 (생성자, 로거) |
| 218 | + |
| 219 | + @KafkaListener(topics = "coupon_issue", groupId = "group_1") |
| 220 | + public void listener(final CouponIssueMessage message, |
| 221 | + @Header(name = GLOBAL_TRACE_ID_HEADER, required = false) String globalTraceId) { |
| 222 | + try { |
| 223 | + // 수신한 헤더의 globalTraceId를 컨슈머의 MDC에 설정한다. |
| 224 | + if (StringUtils.hasText(globalTraceId)) { |
| 225 | + MDC.put(GLOBAL_TRACE_ID_KEY, globalTraceId); |
| 226 | + } |
| 227 | + log.info("발급 처리 메시지 수신: {}", message); |
| 228 | + |
| 229 | + // ... 쿠폰 발급 비즈니스 로직 ... |
| 230 | + IssuedCoupon issuedCoupon = new IssuedCoupon(message.userId(), message.couponId()); |
| 231 | + issuedCouponRepository.save(issuedCoupon); |
| 232 | + log.info("쿠폰 발급 완료: {}", issuedCoupon); |
| 233 | + |
| 234 | + } catch (Exception e) { |
| 235 | + // 중간 생략 (예외 처리) |
| 236 | + } finally { |
| 237 | + // 메시지 처리가 끝나면 반드시 MDC에서 제거한다. |
| 238 | + MDC.remove(GLOBAL_TRACE_ID_KEY); |
| 239 | + } |
| 240 | + } |
| 241 | +} |
| 242 | +``` |
| 243 | + |
| 244 | +## 결과: 통합된 로그 |
| 245 | + |
| 246 | +이제 다시 애플리케이션 2대를 실행하고, API 테스트 도구(예. Postman)를 사용하여 쿠폰 발급 요청을 보낼 때, 헤더에 `X-Global-Trace-Id`를 담아 보내보자 |
| 247 | + |
| 248 | +* (예: X-Global-Trace-Id: gtxid-coupon-issue-test) |
| 249 | + |
| 250 | +> API 서버 로그 (GlobalTraceId가 gtxid-coupon-issue-test로 동일) |
| 251 | +
|
| 252 | +```bash |
| 253 | +21:49:30.685| INFO| gtxid-coupon-issue-test|...|d.b.c.a.c.i.r.aop.DistributedLockAop |락 획득 성공... |
| 254 | +21:49:30.704| INFO| gtxid-coupon-issue-test|...|d.b.c.a.c.application.CouponService |쿠폰 발급 요청 Kafka 전송 성공... |
| 255 | +21:49:30.711| INFO| gtxid-coupon-issue-test|...|.f.v.HttpRequestAndResponseLoggingFilter|[REQUEST] POST /api/coupon/... |
| 256 | +``` |
| 257 | + |
| 258 | +> 컨슈머 서버 로그 (GlobalTraceId가 gtxid-coupon-issue-test로 동일) |
| 259 | +
|
| 260 | +```bash |
| 261 | +21:49:30.821| INFO| gtxid-coupon-issue-test|...|d.b.c.k.c.a.CouponIssueConsumer |발급 처리 메시지 수신... |
| 262 | +21:49:30.825| INFO| gtxid-coupon-issue-test|...|d.b.c.k.c.a.CouponIssueConsumer |쿠폰 발급 완료... |
| 263 | +``` |
| 264 | + |
| 265 | + |
| 266 | +--- |
| 267 | + |
| 268 | +만약 헤더에 `X-Global-Trace-Id`를 보내지 않더라도 필터에서 랜덤 ID(98ac71d9...)가 생성되어 API 서버와 컨슈머 서버에서 동일하게 사용되는 것을 확인할 수 있다. |
| 269 | + |
| 270 | +> API 서버 로그 (랜덤 생성된 GlobalTraceId) |
| 271 | +
|
| 272 | +```bash |
| 273 | +21:46:15.394| INFO|98ac71d934194fd59a2fb04b94021234|...|d.b.c.a.c.i.r.aop.DistributedLockAop |락 획득 성공... |
| 274 | +21:46:15.685| INFO|98ac71d934194fd59a2fb04b94021234|...|d.b.c.a.c.application.CouponService |쿠폰 발급 요청 Kafka 전송 성공... |
| 275 | +``` |
| 276 | + |
| 277 | +> 컨슈머 서버 로그 (API 서버와 동일한 GlobalTraceId) |
| 278 | +
|
| 279 | +```bash |
| 280 | +21:46:15.779| INFO|98ac71d934194fd59a2fb04b94021234|...|d.b.c.k.c.a.CouponIssueConsumer |발급 처리 메시지 수신... |
| 281 | +21:46:15.846| INFO|98ac71d934194fd59a2fb04b94021234|...|d.b.c.k.c.a.CouponIssueConsumer |쿠폰 발급 완료... |
| 282 | +``` |
| 283 | + |
| 284 | +두 서버의 로그에 동일한 `GlobalTraceId`가 남음으로써, 특정 요청의 전체 처리 과정을 한눈에 추적할 수 있게 되었다. |
| 285 | + |
| 286 | +이제 로그 분석 시스템에서 `globalTraceId`로 필터링하기만 하면 분산된 로그를 손쉽게 모아볼 수 있다. |
| 287 | + |
| 288 | +## Summary |
| 289 | + |
| 290 | +위의 내용을 간단히 정리하면 아래와 같다. |
| 291 | + |
| 292 | +* `문제점`: 분산 시스템(MSA) 환경에서 서비스 간 호출(예. 메시지 큐) 시, 각 시스템의 로그가 별도의 `traceId`를 가져 추적이 어렵다. |
| 293 | + |
| 294 | +* `원인`: 스레드 기반의 `MDC` 컨텍스트는 비동기/프로세스 경계를 넘어서 자동으로 전파되지 않는다. |
| 295 | + |
| 296 | +* `해결책`: 전체 트랜잭션을 대표하는 `GlobalTraceId`를 정의하고, 이를 서비스 간 통신(HTTP 헤더, Kafka 메시지 헤더 등)을 통해 명시적으로 전달한다. |
| 297 | + |
| 298 | +* `구현` |
| 299 | + |
| 300 | + * 최초 진입점(Filter)에서 GlobalTraceId를 생성하여 MDC에 저장한다. |
| 301 | + |
| 302 | + * 프로듀서에서 메시지 발행 시 MDC의 GlobalTraceId를 메시지 헤더에 포함시킨다. |
| 303 | + |
| 304 | + * 컨슈머에서 메시지 수신 시 헤더의 GlobalTraceId를 추출하여 MDC에 다시 저장한다. |
| 305 | + |
| 306 | +* `결과`: 모든 분산 로그에 동일한 식별자가 기록되어, **Observability가 향상**되고 장애 추적이 용이해진다. |
| 307 | + |
| 308 | +## References |
| 309 | + |
| 310 | +* [[Github] micrometer](https://github.com/micrometer-metrics/micrometer) |
| 311 | + |
| 312 | +* [Micrometer Tracing Documentation](https://micrometer.io/) |
| 313 | + |
| 314 | +* [Spring Cloud Sleuth in a Monolith Application](https://www.baeldung.com/spring-cloud-sleuth-single-application) |
| 315 | + |
| 316 | +* [토스ㅣSLASH 23 - 분산 추적 체계 & 로그 중심으로 Observability 확보하기](https://www.youtube.com/watch?v=Ifz0LsfAG94&ab_channel=%ED%86%A0%EC%8A%A4) |
0 commit comments