11---
22layout : post
3- title : " MDC와 GlobalTraceId를 활용한 분산 추적 "
4- categories : SpringBoot Technology
3+ title : " MDC와 GlobalTraceId를 활용한 분산 추적 "
4+ categories : SpringBoot
55author : devFancy
66---
7+
78* content
89{: toc }
910
@@ -15,7 +16,7 @@ author: devFancy
1516
1617 이때 각 시스템에 분산된 로그를 하나의 흐름으로 묶어 추적할 수 없다면, 장애 발생 시 원인을 파악하기 매우 어려워진다.
1718
18- * 현재 진행 중인 개인 프로젝트(쿠폰 시스템)에서 쿠폰 발급 요청이
19+ * 현재 진행 중인 개인 프로젝트(쿠폰 시스템)에서 쿠폰 발급 요청이
1920
2021 API 서버에서 시작되어 카프카(Kafka)를 통해 컨슈머 서버로 전달되는 과정이 있다.
2122
@@ -36,7 +37,7 @@ author: devFancy
3637
3738핵심 흐름은 다음과 같다.
3839
39- * HTTP Request -> ` 쿠폰 API 서버 ` -> Kafka Produce -> Kafka Consume -> ` 컨슈머 서버 ` -> DB 저장
40+ * HTTP Request -> ` 쿠폰 API 서버 ` -> Kafka Producer -> Kafka Consumer -> ` 컨슈머 서버 ` -> DB 저장
4041
4142이 구조에서 Spring Boot Actuator와 Micrometer Tracing 같은 라이브러리를 사용하면 각 애플리케이션 내에서는 ` traceId ` 와 ` spanId ` 가 자동으로 생성되어 로그에 포함된다.
4243
@@ -73,7 +74,6 @@ author: devFancy
737421:29:03.488| INFO| 6852b10febe4df79b383d66d36df8483,b383d66d36df8483| d.b.c.k.c.a.CouponIssueConsumer | 쿠폰 발급 완료...
7475```
7576
76-
7777## 해결 방안: GlobalTraceId를 이용한 수동 전파
7878
7979> GlobalTraceId를 개인 프로젝트에 적용한 부분과 관련된 코드는 깃허브 [ PR] ( https://github.com/devFancy/springboot-coupon-system/pull/33 ) 에서 확인할 수 있다.
@@ -84,39 +84,41 @@ author: devFancy
8484
8585* 참고) Micrometer의 자동 전파 기능과 ` GlobalTraceId ` 의 차이점
8686
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가 이 역할을 했다.
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가 이 역할을 했다.
9090
91- * 하지만 이 글에서 다루는 ` GlobalTraceId ` 는 필자가 ** 직접 만든 커스텀 필드** 이므로 이러한 자동 전파의 대상이 아니다.
92- 이처럼 라이브러리가 모르는 커스텀 식별자를 서비스 간에 전달해야 할 때는,
93- 이 글에서 소개한 것처럼 ** 직접 헤더에 담아 전달하는 수동 전파 방식** 이 필요하다.
91+ * 하지만 이 글에서 다루는 ` GlobalTraceId ` 는 필자가 ** 직접 만든 커스텀 필드** 이므로 이러한 자동 전파의 대상이 아니다.
92+ 이처럼 라이브러리가 모르는 커스텀 식별자를 서비스 간에 전달해야 할 때는,
93+ 이 글에서 소개한 것처럼 ** 직접 헤더에 담아 전달하는 수동 전파 방식** 이 필요하다.
9494
95- * 이는 분산 추적의 핵심 원리를 이해하고 우리가 원하는 식별자를 직접 제어할 수 있다는 장점이 있다.
95+ * 이는 분산 추적의 핵심 원리를 이해하고 우리가 원하는 식별자를 직접 제어할 수 있다는 장점이 있다.
9696
9797### 1. Logback 설정: GlobalTraceId 출력 필드 추가
9898
99- 먼저, 로그 패턴에 ` GlobalTraceId ` 를 출력할 수 있도록 ` logback-spring.xml ` 설정에 globalTraceId 필드를 추가한다.
99+ 먼저, 로그 패턴에 ` GlobalTraceId ` 를 출력할 수 있도록 ` logback-spring.xml ` 설정에 globalTraceId 필드를 추가한다.
100100
101101이 필드는 MDC에 해당 키가 존재할 경우 그 값을 출력한다.
102102
103103> logback-local.xml
104104
105105``` xml
106+
106107<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 >
108+ <appender name =" STDOUT" class =" ch.qos.logback.core.ConsoleAppender" >
109+ <encoder >
110+ <pattern >
111+ %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}
112+ </pattern >
113+ <charset >utf8</charset >
114+ </encoder >
115+ </appender >
113116</configuration >
114117```
115118
116-
117119### 2. Filter: GlobalTraceId 생성 및 MDC 적용
118120
119- HTTP 요청이 들어오는 가장 앞단에서 ` GlobalTraceId ` 를 생성하거나,
121+ HTTP 요청이 들어오는 가장 앞단에서 ` GlobalTraceId ` 를 생성하거나,
120122
121123외부 시스템으로부터 이미 전달받았다면 해당 값을 사용하도록 필터를 구현한다.
122124
@@ -133,36 +135,35 @@ HTTP 요청이 들어오는 가장 앞단에서 `GlobalTraceId`를 생성하거
133135``` java
134136public class HttpRequestAndResponseLoggingFilter extends OncePerRequestFilter {
135137
136- private static final String GLOBAL_TRACE_ID_HEADER = " X-Global-Trace-Id" ;
137- private static final String GLOBAL_TRACE_ID_KEY = " globalTraceId" ;
138+ private static final String GLOBAL_TRACE_ID_HEADER = " X-Global-Trace-Id" ;
139+ private static final String GLOBAL_TRACE_ID_KEY = " globalTraceId" ;
138140
139- @Override
140- protected void doFilterInternal (@NonNull final HttpServletRequest request ,
141- @NonNull final HttpServletResponse response ,
142- @NonNull final FilterChain filterChain ) {
143- // 중간 생략 (request/response wrapper)
141+ @Override
142+ protected void doFilterInternal (@NonNull final HttpServletRequest request ,
143+ @NonNull final HttpServletResponse response ,
144+ @NonNull final FilterChain filterChain ) {
145+ // 중간 생략 (request/response wrapper)
144146
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 );
147+ String globalTraceId = request. getHeader(GLOBAL_TRACE_ID_HEADER );
148+ if (! StringUtils . hasText(globalTraceId)) {
149+ globalTraceId = UUID . randomUUID(). toString(). replaceAll(" -" , " " ). substring(0 , 32 );
150+ }
151+ MDC . put(GLOBAL_TRACE_ID_KEY , globalTraceId);
152+
153+ try {
154+ filterChain. doFilter(request, response);
155+ // 중간 생략 (로깅 처리)
156+ } catch (Exception e) {
157+ // 중간 생략 (예외 처리)
158+ } finally {
159+ // 요청 처리가 끝나면 반드시 MDC에서 제거해야 한다.
160+ MDC . remove(GLOBAL_TRACE_ID_KEY );
161+ }
159162 }
160- }
161- // 뒷부분 생략
163+ // 뒷부분 생략
162164}
163165```
164166
165-
166167### 3.Kafka Producer: 메시지 헤더에 GlobalTraceId 주입
167168
168169API 서버에서 카프카로 메시지를 보낼 때, 현재 스레드의 MDC에서 ` globalTraceId ` 를 꺼내 카프카 메시지 헤더에 추가한다.
@@ -172,32 +173,37 @@ API 서버에서 카프카로 메시지를 보낼 때, 현재 스레드의 MDC
172173> CouponIssueProducer.java
173174
174175``` java
176+
175177@Component
176178public class CouponIssueProducer {
177179
178- private final KafkaTemplate<String , Object > kafkaTemplate;
179- private static final String GLOBAL_TRACE_ID_HEADER = " globalTraceId" ;
180+ private final KafkaTemplate<String , Object > kafkaTemplate;
181+ private static final String GLOBAL_TRACE_ID_HEADER = " globalTraceId" ;
182+ private static final Logger log = LoggerFactory . getLogger(CouponIssueProducer . class);
180183
181- // 중간 생략 (생성자)
184+ // 중간 생략 (생성자)
182185
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" );
186+ /**
187+ * 쿠폰 발급 요청 메시지를 동기적으로 Kafka에 발행한다.
188+ * .join()을 호출하여 메시지 전송이 완료될 때까지 현재 스레드를 블로킹한다.
189+ */
190+ public void issue (final UUID userId , final UUID couponId ) {
191+ CouponIssueMessage payload = new CouponIssueMessage (userId, couponId);
192+ String globalTraceId = MDC . get(" globalTraceId" );
187193
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+ ProducerRecord<String , Object > record = new ProducerRecord<> (KafkaTopic . COUPON_ISSUE. getTopicName(), payload);
194195
195- kafkaTemplate. send(message);
196- }
196+ if (globalTraceId != null ) {
197+ record. headers(). add(GLOBAL_TRACE_ID_HEADER , globalTraceId. getBytes(StandardCharsets . UTF_8 ));
198+ }
199+
200+ kafkaTemplate. send(record). whenComplete((result, ex) - > {
201+ // ...
202+ }). join();
203+ }
197204}
198205```
199206
200-
201207### 4. Kafka Consumer: 헤더에서 GlobalTraceId 추출 및 MDC 탑재
202208
203209마지막으로, 컨슈머는 메시지를 수신할 때 헤더에 포함된 ` globalTraceId ` 를 추출하여 자신의 MDC에 설정한다.
@@ -207,45 +213,47 @@ public class CouponIssueProducer {
207213> CouponIssueConsumer.java
208214
209215``` java
216+
210217@Component
211218public class CouponIssueConsumer {
212219
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 );
220+ private final CouponIssuanceService couponIssuanceService;
221+ private final Logger log = LoggerFactory . getLogger(CouponIssueConsumer . class);
222+ private static final String GLOBAL_TRACE_ID_KEY = " globalTraceId" ;
223+
224+ // 중간 생략 (생성자)
225+
226+ /**
227+ * Kafka 토픽으로부터 메시지를 수신하여 쿠폰을 발급한다.
228+ */
229+ @KafkaListener (topics = " ..." , groupId = " ..." )
230+ public void listener (final CouponIssueMessage message ,
231+ @Header (name = GLOBAL_TRACE_ID_KEY , required = false ) final String globalTraceId ,
232+ final Acknowledgment ack ) {
233+
234+ if (! Objects . isNull(globalTraceId)) {
235+ MDC . put(GLOBAL_TRACE_ID_KEY , globalTraceId);
236+ }
237+ log. info(" 발급 처리 메시지 수신: {}" , message);
238+
239+ try {
240+ couponIssuanceService. process(message);
241+ ack. acknowledge();
242+ } catch (Exception e) {
243+ log. error(" 메시지 처리 실패, 재처리를 위해 커밋하지 않음: {}" , message, e);
244+ } finally {
245+ // 메시지 처리가 끝나면 반드시 MDC에서 제거한다.
246+ MDC . remove(GLOBAL_TRACE_ID_KEY );
247+ }
239248 }
240- }
241249}
242250```
243251
244252## 결과: 통합된 로그
245253
246254이제 다시 애플리케이션 2대를 실행하고, API 테스트 도구(예. Postman)를 사용하여 쿠폰 발급 요청을 보낼 때, 헤더에 ` X-Global-Trace-Id ` 를 담아 보내보자
247255
248- * (예: X-Global-Trace-Id: gtxid-coupon-issue-test)
256+ * (예: X-Global-Trace-Id: gtxid-coupon-issue-test)
249257
250258> API 서버 로그 (GlobalTraceId가 gtxid-coupon-issue-test로 동일)
251259
@@ -262,7 +270,6 @@ public class CouponIssueConsumer {
26227021:49:30.825| INFO| gtxid-coupon-issue-test| ...| d.b.c.k.c.a.CouponIssueConsumer | 쿠폰 발급 완료...
263271```
264272
265-
266273---
267274
268275만약 헤더에 ` X-Global-Trace-Id ` 를 보내지 않더라도 필터에서 랜덤 ID(98ac71d9...)가 생성되어 API 서버와 컨슈머 서버에서 동일하게 사용되는 것을 확인할 수 있다.
@@ -281,7 +288,7 @@ public class CouponIssueConsumer {
28128821:46:15.846| INFO| 98ac71d934194fd59a2fb04b94021234| ...| d.b.c.k.c.a.CouponIssueConsumer | 쿠폰 발급 완료...
282289```
283290
284- 두 서버의 로그에 동일한 ` GlobalTraceId ` 가 남음으로써, 특정 요청의 전체 처리 과정을 한눈에 추적할 수 있게 되었다.
291+ 두 서버의 로그에 동일한 ` GlobalTraceId ` 가 남음으로써, 특정 요청의 전체 처리 과정을 한눈에 추적할 수 있게 되었다.
285292
286293이제 로그 분석 시스템에서 ` globalTraceId ` 로 필터링하기만 하면 분산된 로그를 손쉽게 모아볼 수 있다.
287294
@@ -297,11 +304,11 @@ public class CouponIssueConsumer {
297304
298305* ` 구현 `
299306
300- * 최초 진입점(Filter)에서 GlobalTraceId를 생성하여 MDC에 저장한다.
307+ * 최초 진입점(Filter)에서 GlobalTraceId를 생성하여 MDC에 저장한다.
301308
302- * 프로듀서에서 메시지 발행 시 MDC의 GlobalTraceId를 메시지 헤더에 포함시킨다.
309+ * 프로듀서에서 메시지 발행 시 MDC의 GlobalTraceId를 메시지 헤더에 포함시킨다.
303310
304- * 컨슈머에서 메시지 수신 시 헤더의 GlobalTraceId를 추출하여 MDC에 다시 저장한다.
311+ * 컨슈머에서 메시지 수신 시 헤더의 GlobalTraceId를 추출하여 MDC에 다시 저장한다.
305312
306313* ` 결과 ` : 모든 분산 로그에 동일한 식별자가 기록되어, ** Observability가 향상** 되고 장애 추적이 용이해진다.
307314
0 commit comments