Skip to content

Commit abd1280

Browse files
committed
Write Technology Post " MDC와 GlobalTraceId를 활용한 분산 추적 "
1 parent 45c629d commit abd1280

File tree

2 files changed

+329
-3
lines changed

2 files changed

+329
-3
lines changed

_posts/2025-06-03-SpringBoot-Coupon-System-Redisson.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,6 @@ public class CouponIssueService {
405405
// ... 생략 ...
406406

407407
@DistributedLock(key = "'couponIssue:' + #command.couponId() + ':' + #command.userId()", waitTime = 5, leaseTime = 30)
408-
@Transactional // DB 트랜잭션과 함께 사용
409408
public CouponIssueResult issue(final CouponIssueCommand command) {
410409
// ... 순수한 쿠폰 발급 비즈니스 로직 ...
411410
}
@@ -445,7 +444,7 @@ INFO| , |d.b.c.a.c.i.redis.config.RedisConfig |Redisson Client 생성 성공
445444
INFO| , |d.b.c.a.c.i.redis.config.RedisConfig |couponRedisTemplate 빈 생성 완료
446445
INFO| , |dev.be.coupon.CouponApplication |Started CouponApplication in 4.752 seconds (process running for 5.087)
447446

448-
# --- 단일 쿠폰 발급 요청 처리 시작 (traceId: {traceId}) ---
447+
# --- 단일 쿠폰 발급 요청 처리 시작 ---
449448

450449
DEBUG|{TraceId},{SpanId}|d.b.c.a.c.i.r.aop.DistributedLockAop |락 획득 시도: 키='LOCK:coupon:{couponId}:{userId}', 대기시간=5s, 임대시간=30s
451450
INFO|{TraceId},{SpanId}|d.b.c.a.c.i.r.aop.DistributedLockAop |락 획득 성공: 키='LOCK:coupon:{couponId}:{userId}'
@@ -482,7 +481,18 @@ INFO|{TraceId},{SpanId}|d.b.c.k.c.a.CouponIssueConsumer |쿠폰 발급
482481
Kafka Consumer 서버에서는 API 서버에서 전송한 `CouponIssueMessage`를 수신하고(`발급 처리 메시지 수신`),
483482
최종적으로 쿠폰 발급을 완료합니다(`쿠폰 발급 완료`).
484483

485-
여기서 `traceId`는 API 서버에서 전달된 것과 동일하게 유지되어, 분산 시스템 내에서 **단일 요청의 전체 흐름을 손쉽게 추적**할 수 있습니다.
484+
여기서 `traceId`는 API 서버의 {TraceId}와는 다른, 새로운 {NewTraceId}가 기록됩니다.
485+
그 이유는 Consumer가 메시지를 **비동기적으로, 그리고 별도의 트랜잭션**(`@Transactional(propagation = Propagation.REQUIRES_NEW)`)으로 처리하기 때문입니다.
486+
이렇게 프로세스와 실행 컨텍스트가 완전히 분리되면 기존의 `traceId`가 자동으로 전파되지 않고 새로운 추적이 시작됩니다.
487+
488+
이처럼 비동기 경계로 단절된 흐름을 하나의 요청으로 묶어 추적하기 위해,
489+
실무에서는 **별도의 전역 추적 ID(예: `GlobalTraceId`)를 Kafka 메시지 헤더 등에 담아 명시적으로 전파하는 패턴**을 사용합니다.
490+
491+
이를 통해 비록 시스템 내부의 `traceId`는 다르더라도, 사용자의 최초 요청부터 최종 처리까지의 전체 여정을 쉽게 파악할 수 있습니다.
492+
493+
> 2025.06.18 업로드
494+
495+
* 관련 포스팅: [분산 시스템에서 MDC를 이용한 분산 추적: GlobalTraceId 적용](https://devfancy.github.io/SpringBoot-Distributed-Tracing-With-MDC/)
486496

487497

488498
---
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
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

Comments
 (0)