저번 포스트에서 서버 간 분산 추적을 위해 Zipkin을 적용시켰는데 제대로 추적되지 않았다. 이것을 해결해 보자
이전 글은 아래의 포스트에 있다.
1. Zipkin 적용시 발생한 문제점과 글을 이해하기 위해 알아야 할 내용
1-1. Zipkin을 적용하다 발생한 문제점
1. 각 SpringBoot서버에 Zipkin 적용은 완료했는데 서버 간 요청 추적이 제대로 되고 있지 않다.
2. ALB의 테스트 핑을 통해서 들어오는 요청도 http 요청이다 보니 zipkin안에 이 요청에 대한 내용들이 계속해서 쌓였다.
3. 서버간 통신에서 예외가 발생했을 때를 대비한 예외처리가 필요하다.
1-2. 프로젝트 구성 및 Zipkin의 흐름 예상하기
1. 이 프로젝트는 MSA로 구성되었으며 Message-driven(이벤트 기반) 아키텍처로 설계하였다.
2. 이 MSA 프로젝트에는 3개의 서버가 존재하며 멤버 서버, 레시피 서버, Zipkin서버로 이루어져 있다.
3. 멤버, 레시피 서버는 모두 Zipkin서버에 연결이 완료된 상태다.
4. 멤버 서버에서는 SNS 메시지를 발행하고 레시피 서버에서는 발행된 SNS메시지를 구독하는 SQS리스너 메서드가 존재한다.
5. ZeroPayload정책을 적용했기 때문에 레시피 서버의 SQS리스너는 메시지를 받은 후에 멤버 서버로 FeignClient 요청(API)을 보내서 변경된 데이터를 받아와서 업데이트를 하는 상황이다.
확실한 내용을 알고자 한다면 아래의 글을 읽어보시는 것을 추천합니다! (아래의 내용에서 이어지는 로그 추적이기 때문)
2. 이전 Zipkin 설정에서 서버간 추적을 실패했던 이유
2-1. 기존 코드(실패했던 코드)를 이용한 설명
- 이전 포스트에서는 Zipkin으로 SNS, SQS를 통해 서버 간 통신하는 것을 추적하고자 했다. 그리고 이때는 단순히 코드 안에 log만 적어놔도 Zipkin이 이것을 기록하는 줄 알았다.(조금 제대로 알아볼걸..) 그래서 @SqsListener의 메서드에서는 그 어떤 Zipkin과 관련된 코드를 사용하지 않았다.
@Slf4j
@RequiredArgsConstructor
@Service
public class SqsListenerService {
private final ObjectMapper objectMapper;
@SqsListener(value = "${spring.cloud.aws.sqs.nickname-sqs-name}")
public void receiveMessage(String messageJson) throws JsonProcessingException {
JsonNode messageNode = objectMapper.readTree(messageJson);
String topicArn = messageNode.get("TopicArn").asText();
String messageContent = messageNode.get("Message").asText();
// messageId 추출 및 로깅 (만약 메시지에 messageId 정보가 있다면)
String messageId = messageNode.get("messageId").asText();
log.info("Received message from SQS with messageId: {}", messageId);
// Assuming the "Message" is also a JSON string, we parse it to print as JSON object
JsonNode message = objectMapper.readTree(messageContent);
log.info("Topic ARN: {}", topicArn);
log.info("Message: {}", message.toString());
}
}
2-2. 원인 파악 및 분석
- 추적에 실패했을때 Zipkin이 남긴 추적 로그를 다시 확인해 보자
Zipkin에 남겨진 기록에는 멤버 서버에 /message/publish 요청(http)이 들어와서 SNS메시지를 발행하는 것은 추적에 기록이 남아있었다. 그러나 레시피 서버가 @SqsListener를 통해서 메시지를 받아서 이후 로직이 동작했다는 것은 기록되지 않았다. 그 이유는 Zipkin의 동작방식이 내가 원하는 추적을 지원하지 않았기 때문이었다.
Zipkin은 직접적으로 들어온 Http 요청을 기준으로 기록을 남기는데 만약 AWS의 클라이언트를 사용하여 http요청이 이루어졌다면 이것은 Zipkin이 기록을 하지 않는다고 한다. (나는 AWS의 SNS, SQS 클라이언트를 사용중이다.)
Zipkin의 동작 원리를 모르고서는 앞으로 설명하는 내용을 이해할 수 없을 것이라는 생각이 들었다. 그렇기에 지금부터는 간단하게 Zipkin의 동작 원리를 알아보고 넘어가도록 하자
3. Zipkin의 동작 원리
3-1. Zipkin의 동작 원리 설명
Zipkin에서 분산 추적은 주로 네트워크를 통해 서비스 간에 이루어지는 요청들을 추적하는 데 집중한다. 이런 추적은 HTTP 요청, 데이터베이스 쿼리, 외부 시스템 호출 등의 네트워크 활동에 초점을 맞추어 일어난다.
간단하게 설명하자면, 서비스 간에 요청이 전송될 때마다 특정한 추적 데이터(Trace Data)를 생성하고 전파하는 방식이다. 이 추적 데이터는 보통 '스팬(Span)'이라는 단위로 관리되는데, 스팬은 특정 작업의 시작과 끝을 나타낸다. 각 span은 유니크한 식별자를 가지고 있어서 서로 다른 서비스 간의 요청들을 연결하는 데 사용된다.
3-2. 프로젝트를 통한 Zipkin의 추적 예시
예를 들어, 멤버 서버가 레시피 서버에 HTTP 요청을 보낸다고 해보자. 이때, 멤버 서버는 요청을 보내기 전에 새 Span을 생성한다. 그리고 이 Span 정보를 HTTP 요청의 헤더에 포함시켜서 레시피 서버로 전달한다.
레시피 서버는 이 Span 정보를 받고, 자신의 로직을 모두 처리했다면 Span 정보를 업데이트해서 결과를 Zipkin 서버로 보낸다. 이렇게 서버 간의 모든 요청과 응답이 Span을 통해 추적되고, Zipkin 서버는 이러한 Span들을 수집해서 전체 트랜잭션의 흐름을 시각화하고 분석할 수 있는 데이터로 만든다.
Zipkin은 주로 네트워크 호출에 대한 정보를 수집하기 때문에, 단순히 메서드 호출이나 log.info 같은 로그 데이터를 자동으로 추적하지는 않는다. 따라서 내가 추적하고 싶은 특정 메서드나 로그 데이터를 Zipkin으로 보내고 싶다면, 해당 메서드나 로그 데이터에 대한 Span을 직접 생성하고 관리해야 한다.
3-3. 직접 Span을 생성하는 예시코드
// Zipkin 스팬 생성 예시
Span newSpan = tracer.nextSpan().name("myMethodName").start();
try (Tracer.SpanInScope ws = tracer.withSpanInScope(newSpan.start())) {
// 여기서 너의 메서드나 로직을 실행
myMethod();
} finally {
newSpan.finish(); // 스팬 종료
}
위의 코드에서 tracer는 Zipkin의 추적기(Tracer) 객체이고, myMethod()는 내가 추적하고 싶은 메서드다. 이런 식으로 Span을 만들어서 특정 메서드나 로직을 추적할 수 있다. 그리고 이렇게 생성된 Span 정보는 Zipkin 서버로 전송돼서 분산 추적에 사용된다.
3-4. 나의 상황: 스프링에서 @SqsListener를 사용할 때 Zipkin의 추적은 어떻게 일어날까?
스프링 프레임워크에서 Zipkin을 사용하는 경우, SQS 메시지 폴링을 Zipkin으로 추적하는 것은 기본적으로 지원되지 않는다. 이는 스프링 프레임워크의 한계라기보다는 Zipkin과 같은 분산 추적 시스템이 주로 HTTP 요청이나 RPC 호출 같은 네트워크 기반의 상호작용을 추적하는 데 초점을 맞추고 있기 때문이다.
@SqsListener를 사용하는 경우, SQS 메시지 폴링은 HTTP 요청을 통해 이루어지지만, 이러한 폴링 작업은 대개 백그라운드에서 자동으로 일어나며, 이 과정에서 생성되는 네트워크 호출이 분산 추적 시스템에 자동으로 통합되지 않는다. 따라서 Zipkin이나 다른 분산 추적 시스템에서 이러한 메시지 폴링 과정을 자동으로 추적하기 위해서는 추가적인 통합 작업이 필요하다.
@SqsListener 어노테이션을 사용할 때, SQS 메시지 폴링과 처리를 Zipkin으로 추적하려면, 메시지 처리 메서드 내에서 직접 Zipkin의 스팬(Span)을 생성하고, 메시지 처리 로직이 완료된 후에 스팬(Span)을 종료하는 방식으로 구현해야 한다.
3-5. @SqsListener에서 Span을 구현하는 방법
- @SqsListener로 지정된 메서드 내에서 SQS 메시지 처리 과정을 Zipkin으로 추적하기 위한 예시코드 작성
@SqsListener("{나의 SQS 이름 적어주기}")
public void sqsMessageListener(String message) {
Span newSpan = tracer.nextSpan().name("sqsMessageListener").start();
try (Tracer.SpanInScope ws = tracer.withSpanInScope(newSpan.start())) {
// 메시지 처리 로직
// 예: processMessage(message);
} finally {
newSpan.finish(); // 스팬 종료
}
}
4. @SqsListener메서드 내부에 Zipkin추적을 위해 span 추가하기
4-1. 코드를 다시 작성한 이유
처음 Zipkin을 적용시켰을 때에는 스프링이 내부적으로 Zipkin과 연동하여 자동으로 Sqs에 대한 추적도 해준다고 생각했었기에 추가적으로 다른 설정을 해주지 않아도 괜찮다고 생각했다. 그러나 실제 검증과정에서 추적이 되지 않았고 조사를 해보니 @SqsListener는 Zipkin이 자동으로 추적이 불가능하도록 설계되어 있어서 내가 직접 Span을 설정해줘야 했다. 그래서 아래와 같이 직접 Span태그를 넣어서 코드를 새롭게 작성했다.
4-2. 새롭게 발견된 문제점과 해결방안 고민
위와 같이 @SqsListener 메서드 안에 직접 span태그를 적어주기만 하면 Zipkin 추적에 대해서는 깔끔하게 해결이 될 줄 알았는데 이렇게만 해서는 추적이 불가능했다. 그 이유를 하나씩 순서를 적으면서 따라가 보다가 알게 되었다.
1. Member 서버 (HTTP 요청 → SNS 메시지 발행)
여기서는 Member 서버가 HTTP 요청을 받을 때 Zipkin Trace ID가 생성된다. 이 Trace ID는 Member 서버에서의 작업을 추적하는 데 사용된다. SNS로 메시지를 발행하는 과정도 이 Trace ID 안에서 추적될 것이다.
2. Recipe 서버 (SQS 리스너)
Recipe 서버에서 SQS리스너를 통해 메시지를 받을 때는 새로운 Trace ID가 생성되지 않는 경우가 많다. 왜냐하면 많은 분산 추적 시스템이 메시지 큐 시스템에서 자동으로 추적 정보를 전파하지 않기 때문이다. 즉, Recipe 서버에서 처리하는 작업은 Member 서버에서 처리한 작업과는 다른 추적으로 간주될 수 있다.(내 경우에는 TraceId가 생기지도 않았다.)
3. Recipe 서버 → Member 서버 (Feign 요청)
이 경우 Recipe 서버에서 Member 서버로 Feign 요청을 할 때는, 그 요청에 대한 새로운 Trace ID가 생성될 것이다. 이 Trace ID는 Recipe 서버에서 시작해서 Member 서버로 요청을 보낼 때까지의 경로를 추적하는 데 사용되고, 이전에 SNS를 통해 발행된 메시지와는 별개의 추적으로 처리된다.
결론적으로, SQS와 같은 메시지 큐 시스템을 사용할 때는 추적 정보의 전파가 자동으로 이루어지지 않아서, 다른 시스템 간의 메시지 교환에 대한 추적을 수동으로 설정해야 할 수도 있다. 그래서 우리 팀은 이를 위해서 메시지에 추적 정보(traceId)를 담아서 보내주고 이 traceId를 사용한 Zipkin 추적을 만들어서 수동으로 연결해 주기로 결정했다.
문제의 해결방법을 알았으니 멤버 서버부터 새롭게 코드를 작성해 나가면서 고쳐보도록 하자
5. 멤버 서버에서 traceId 보내기
나는 이 문제를 해결하기 위해 팀원과 회의를 통해 멤버 서버에서 SNS에 메시지를 발행할 때 traceId를 담아서 보내주기로 했다. 그럼 이 SNS메시지를 받아서 사용할 레시피 서버의 @SqsListener에서는 보내준 traceId를 꺼내서 본인의 메서드 안에서 Zipkin 추적을 수동으로 생성하여 그때 이 traceId를 적용한다면 모든 요청이 하나의 traceId를 가지게 될 것이고 그럼 추적이 하나로 묶여서 보이게 될 것이라고 생각했기 때문이다.
5-1. 멤버 서버의 스프링 이벤트 발행
- 멤버 서버에서는 유저가 닉네임을 변경하면 Controller가 동작하고(http) 그러면 내부 로직에서는 AWS SNS에 닉네임이 변경되었다는 메시지를 발행할 NicknameChangeSpringEvent라는 스프링 이벤트를 발행한다.
5-2. 멤버 서버의 스프링 이벤트 리스너 동작
- 멤버 서버의 스프링 이벤트 리스너는 이벤트가 발생했을 때 내부에서 다음과 같은 메서드 (snsService.publishNicknameToTopic)가 동작하여 SNS에 메시지를 발행하는 서비스 레이어의 로직을 호출한다.
5-3. 서비스 레이어의 메서드가 호출되어 SNS에 메시지를 발행
- 위에서 호출된 sns발행 메서드는 멤버 서버에서 추적 진행 중인 Zipkin의 traceId를 추출해서 message에 담아주고 이 메시지의 내용을 담아줘서 SNS에 발행시킨다. (지금 SNS 메시지의 내용에는 memberId, traceId가 담겨있다.)
멤버 서버의 세팅을 완료했으니 메서드 내부에 span을 추가하는 것만 추가했던 레시피 서버의 @SqsListener 메서드도 traceId를 꺼내서 사용하도록 새롭게 코드를 작성하도록 하자
6. 멤버 서버의 traceId를 받은 레시피 서버의 @SqsListener 메서드에서 이 traceId를 사용해서 새로운 추적을 만든다.
6-1. 레시피 서버의 @SqsListener 코드를 수정했다.
- 아래의 코드는 레시피 서버에서 있던 @SqsListener 코드를 수정한 내용이다. 지금부터 박스 친 부분들에 대한 설명을 진행하겠다.
6-2. 먼저 멤버 서버에서 보낸 Zipkin의 TraceId를 추출한다.
- 기존의 @SqsListener 메서드에서는 자신만의 TraceId가 생성되어서 그것을 사용했지만 나는 멤버 서버가 사용했던 traceId를 공유받아서 같은 traceId를 사용하여 추적하기로 했으니 아래의 extractTraceIdFromMessage 메서드를 통해서 Sqs가 받은 message 안에서 traceId를 꺼내준다.
6-3. 꺼낸 traceId로 새로운 TraceContext를 생성
멤버 서버의 traceId를 이어받아서 사용하기 위해서는 새로운 TraceContext를 만들 필요가 있다. 이 TraceContext 안에 멤버 서버에서 받은 traceId를 넣어주고 새로운 spanId를 생성해야 한다.
이렇게 함으로써, 동일한 traceId를 가진 여러 span들이 하나의 트랜잭션 또는 요청 흐름을 구성하게 되며, 각각의 span은 서비스 내의 구체적인 작업이나 이벤트를 대표하게 된다.
- 위의 설명에 따라 아래와 같이 TraceContext를 생성하는 Builder코드를 작성했다. (이 코드는 Extract로 추출한 메서드이다.)
6-4. Zipkin추적을 위한 span 생성
- 위에서 buildTraceContext 메서드를 통해서 만든 TraceContext객체를 사용해서 새로운 span을 생성한다.
6-5. 마지막으로 스프링 이벤트를 발행하는 로직을 실행한다. (Feign클라이언트 요청 이벤트를 발행)
- 스프링 이벤트를 발행하는 processNicknameMessage 메서드는 SNS로부터 받아온 message에서 memberId를 꺼내서 이것을 인자로 사용하여 NicknameChangeEvent라는 스프링 이벤트를 발행한다.
6-6. NicknameChangeEvent 스프링 이벤트 리스너(구독자)의 동작 (실제 FeignClient 호출)
아래의 코드를 보면 최상단에 tracer.nextSpan() 코드를 통해 먼저 span을 생성하고 조금 아래에서 try-resource 문법을 사용해서 실제 동작할 로직을 실행하게 된다. 이렇게 span을 생성하고 try-resource를 적용하는 방식은 Zipkin의 수동 추적 방식에서 사용되는 기본적인 틀이라고 생각하면 된다.
잠깐 간단하게 Zipkin 수동추적 문법을 설명하고 넘어가겠다.
1. 아래와 같이 span을 먼저 생성한다.
- 여기서 tracer.nextSpan()으로 새로운 Span을 생성한다. 그다음 name() 메서드를 통해 이 Span에 이름을 지정하고, .start()메서드로 Span을 시작한다. 이 Span은 특정 작업(여기서는 Feign 요청)을 추적하는 데 사용된다.
Span feignRequestSpan = tracer.nextSpan().name("[RECIPE] /feign/member/getNickname request").start();
2. span 생성 후에는 아래처럼 try-resource 문법을 사용하여 실제 로직을 동작시킨다.
- try 블록 안에서 tracer.withSpanInScope(feignRequestSpan)을 사용하여 현재 스레드의 실행 컨텍스트에 Span을 설정해 준다. 이렇게 하면 try 블록 내의 코드가 실행되는 동안 해당 Span이 활성 상태가 된다. catch 블록은 예외 처리를 담당하고, finally에서는 Span을 종료(finish)한다.
try (Tracer.SpanInScope ws = tracer.withSpanInScope(feignRequestSpan)) {
nicknameDto = memberFeignClient.getNickname(memberId);
// Feign 요청 후 응답 처리
if (nicknameDto != null) {
// NicknameDto 처리 로직
List<Recipe> recipeList = recipeRepository.findRecipeByMemberIdAndDelYn(memberId, "N");
if (!recipeList.isEmpty()) {
recipeList.forEach(recipe -> recipe.changeNickname(nicknameDto.nickname()));
}
}
} catch (Exception e) {
// 에러 처리
} finally {
feignRequestSpan.finish();
}
6-7. 레시피 서버에서 멤버 서버로 FeignClient를 요청하는 메서드
- 위에서 작성한 코드에서 try문 안의 memberFeignClient.getNickname(memberId) 메서드가 동작하면 FeignClient 요청을 진행한다. 레시피 서버에는 아래와 같이 FeignClient 요청 메서드를 작성해 주었다.
6-8. Feign 호출에 동작하는 FeignClientConfig 인터셉터 클래스 설명
이 인터셉터 클래스는 레시피 서버에서 보내는 모든 FeignClient요청에 호출 당시 추적이 진행 중이던 traceId를 담아주기 위해 서 생성했다. 실제 TraceId값을 담아주고 Feign 요청임을 나타내는 헤더도 추가해 준다. 이를 통해 전체 시스템에서의 요청 흐름과 각 서비스 간의 상호작용을 더 명확하게 추적할 수 있게 된다.
FeignClientConfig 인터셉터 클래스의 역할 설명
- Trace ID 추가
- 인터셉터는 현재 컨텍스트에서 활성화된 Span을 찾아 그 Trace ID를 가져온다. 이 Trace ID를 HTTP 요청의 헤더에 추가함으로써, Feign 요청이 다른 서비스에 도달했을 때, 이 요청이 어떤 추적에 속하는지 식별할 수 있게 한다.
- 인터셉터는 현재 컨텍스트에서 활성화된 Span을 찾아 그 Trace ID를 가져온다. 이 Trace ID를 HTTP 요청의 헤더에 추가함으로써, Feign 요청이 다른 서비스에 도달했을 때, 이 요청이 어떤 추적에 속하는지 식별할 수 있게 한다.
- Feign 클라이언트 표시
- 추가적으로, X-Feign-Client라는 헤더를 요청에 추가해서 Feign 클라이언트를 통해 요청이 발생했다는 것을 표시한다. 이는 받는 서버 측에서 요청의 출처를 알 수 있게 해 주고, 필요에 따라 다르게 처리할 수 있게 해 준다.
import brave.Span;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import brave.Tracer;
@RequiredArgsConstructor
@Configuration
public class FeignClientConfig {
private final Tracer tracer;
@Bean
public RequestInterceptor requestInterceptor() {
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
Span currentSpan = tracer.currentSpan();
if (currentSpan != null) {
String traceId = currentSpan.context().traceIdString();
// TraceID를 HTTP 요청 헤더에 추가
template.header("X-Trace-Id", traceId);
}
// Feign 요청임을 나타내는 헤더 추가
template.header("X-Feign-Client", "true");
}
};
}
}
7. 멤버 서버의 Feign클라이언트 응답 (TraceId관련 필터코드 설명)
7-1. 멤버 서버에서는 레시피 서버의 FeignClient요청을 받고 이에 대한 응답을 한다.
- 레시피 서버에서는 멤버 서버의 "/feign/member/getNickname"에 FeignClient요청을 보냈고 멤버 서버의 컨트롤가 동작할 것이다. 이 요청을 받은 것 또한 Zipkin으로 추적을 해야 하니 메서드 내부에 span을 생성하고 try-resource문을 작성해서 try문 안에서 주요 로직을 동작시켜서 Zipkin의 추적을 진행하도록 했다.
여기까지 왔다면 잠시 멈추고 생각을 해보자 위의 컨트롤러 코드를 보면 여기에서 무언가 빠진 작업이 존재한다는 것이 떠오른다.
멤버서버로 요청이 보내졌다면 이걸 받은 멤버서버는 기존의 TraceId가 아니라 또다시 새로운 TraceId를 생성하게 될 것이다. 그런데 컨트롤러 로직 내부에는 그 어디에도 TraceId를 받아서 그것을 사용해서 context를 설정하고 새롭게 span을 정해주는 부분이 보이지 않는다.
7-2. 멤버 서버의 Feign응답을 하는 컨트롤러에서 TraceContext를 생성하지 않는 이유
- 지금 상황은 TraceContext를 생성해서 traceId를 넣어주는 로직이 없는데 레시피 서버에서 보낸 Feign요청과 멤버 서버의 Feign응답이 하나의 추적으로 연결이 되고 있는 상황이다. 그 이유는 다음과 같다.
멤버 서버 내부에는 FeignTraceIdFilter라는 레시피 서버로부터 들어온 요청의 Header에서 TraceId를 꺼내서 세팅해 주는 전용 필터 클래스가 선언되어 있다.
이 FeignTraceIdFilter 클래스의 주된 목적은 다른 서비스에서 보낸 Feign 요청에 포함된 Trace ID를 인식하고, 이를 멤버 서버의 요청 처리에 연결하는 것이다. 이렇게 함으로써, 여러 서비스 간의 요청 흐름을 하나의 추적으로 연결할 수 있다.
7-3. FeignTraceIdFilter 클래스
- 아래와 같이 FeignTraceIdFilter 클래스를 만들었고 내부 로직에서는 if문을 통해 분기처리를 한다. 만약 Feign클라이언트 요청인 경우와 일반 요청인 경우를 구분하여 모든 요청이 이 필터를 타지 않도록 했다.
import brave.Span;
import brave.propagation.TraceContext;
import jakarta.servlet.*;
import brave.Tracer;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.io.IOException;
@RequiredArgsConstructor
@Component
public class FeignTraceIdFilter implements Filter {
private final Tracer tracer;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// Feign 클라이언트가 아닌 경우, 다음 필터로 이동
if (!"true".equals(httpRequest.getHeader("X-Feign-Client"))) {
chain.doFilter(request, response);
return;
}
String incomingTraceId = httpRequest.getHeader("X-Trace-Id");
if (incomingTraceId != null && !incomingTraceId.isEmpty()) {
// 추출한 TraceID로 새로운 Span 시작
TraceContext incomingContext = buildTraceContext(incomingTraceId);
Span newSpan = tracer.newChild(incomingContext).start();
try (Tracer.SpanInScope ws = tracer.withSpanInScope(newSpan)) {
chain.doFilter(request, response);
} finally {
newSpan.finish();
}
} else {
chain.doFilter(request, response);
}
}
private TraceContext buildTraceContext(String traceId) {
TraceContext.Builder contextBuilder = TraceContext.newBuilder();
if (traceId.length() == 32) {
long traceIdHigh = Long.parseUnsignedLong(traceId.substring(0, 16), 16);
long traceIdLow = Long.parseUnsignedLong(traceId.substring(16), 16);
contextBuilder.traceIdHigh(traceIdHigh).traceId(traceIdLow);
} else {
long traceIdLow = Long.parseUnsignedLong(traceId, 16);
contextBuilder.traceId(traceIdLow);
}
contextBuilder.spanId(tracer.nextSpan().context().spanId());
return contextBuilder.build();
}
}
7-4. FeignTraceIdFilter 클래스의 동작 방식 설명
- 헤더 검사
- 먼저 필터는 들어오는 모든 HTTP 요청의 헤더를 확인한다. 여기서 X-Feign-Client라는 헤더가 있는지를 확인해서, 요청이 Feign 클라이언트를 통해 왔는지를 판단한다.
- 먼저 필터는 들어오는 모든 HTTP 요청의 헤더를 확인한다. 여기서 X-Feign-Client라는 헤더가 있는지를 확인해서, 요청이 Feign 클라이언트를 통해 왔는지를 판단한다.
- Trace ID 추출 및 사용
- 만약 X-Trace-Id 헤더가 있다면, 이 헤더에 포함된 Trace ID를 추출한다. 이 Trace ID를 사용해서 멤버 서버 내부에서 새로운 Span을 시작한다. 이렇게 하면, 원본 요청의 추적 정보(Trace ID)를 멤버 서버의 작업에 연결할 수 있다.
- 만약 X-Trace-Id 헤더가 있다면, 이 헤더에 포함된 Trace ID를 추출한다. 이 Trace ID를 사용해서 멤버 서버 내부에서 새로운 Span을 시작한다. 이렇게 하면, 원본 요청의 추적 정보(Trace ID)를 멤버 서버의 작업에 연결할 수 있다.
- Context 설정
- 추출한 Trace ID로 생성한 새 Span을 현재 Context에 설정하고, 이 Context를 이어받는 모든 처리(예를 들면 컨트롤러 내의 로직)에서 이 Span을 사용할 수 있게 한다.
- 추출한 Trace ID로 생성한 새 Span을 현재 Context에 설정하고, 이 Context를 이어받는 모든 처리(예를 들면 컨트롤러 내의 로직)에서 이 Span을 사용할 수 있게 한다.
- 결과
- 이 필터를 통해 멤버 서버는 다른 서비스에서 발생한 요청의 추적 정보를 이어받고, 해당 요청을 처리하는 동안의 모든 작업을 동일한 추적에 포함시킬 수 있다. 이로써 전체 시스템에서의 요청 흐름을 더 명확하게 추적하고 분석할 수 있게 되는 것이다.
- 이 필터를 통해 멤버 서버는 다른 서비스에서 발생한 요청의 추적 정보를 이어받고, 해당 요청을 처리하는 동안의 모든 작업을 동일한 추적에 포함시킬 수 있다. 이로써 전체 시스템에서의 요청 흐름을 더 명확하게 추적하고 분석할 수 있게 되는 것이다.
결국 FeignTraceIdFilter는 분산 시스템에서의 요청 흐름을 보다 명확하게 추적하기 위해 꼭 필요하다. 이 필터 없이는 각 서비스 간의 요청과 응답이 서로 다른 추적으로 간주될 수 있기 때문에, 요청의 전체 경로를 추적하는 데 어려움이 생길 수 있다.
이제 postman을 통해 요청을 해서 결과를 확인해 보도록 하자
8. 결과
8-1. postman을 통해 닉네임 변경 요청 보내기
8-2. 멤버 서버의 로깅 확인
- 로깅을 잘 보면 TraceId에는 655ae3edfbe2d22c26ab8f5c3e104dda가 적혀있다.
8-3. 레시피 서버의 로깅 확인
- 레시피 서버 로깅의 TraceId에도 같은 655ae3edfbe2d22c26ab8f5c3e104dda가 적혀있는 것을 확인할 수 있다.
8-4. Zipkin에서 확인하기
- Zipkin에 655ae3edfbe2d22c26ab8f5c3e104dda를 검색했더니 아래와 같은 결과가 나왔다.
8-5. Zipkin 분산로그 추적
- 위의 로그추적을 분석해 보면 다음과 같다.
1. 맨 처음 멤버 서버로 /member/nicknamechange라는 요청이 들어왔다. 이 요청에서 SNS 메시지 발행이 진행된다.
2. recipe서버의 SqsListener에서는 SNS로 발행된 메시지를 polling 한다.
3. 레시피 서버는 /feign/member/getNickname API에 feignClient로 데이터를 요청한다.
4. 멤버서버는 이 FeignClient요청을 받아서 응답을 한다.
9. 마무리
9-1. 내 생각 정리
이렇게 Zipkin을 사용해서 서버 간 분산 로그 추적하는것을 구현해 봤다. 이 과정은 생각보다 많이 복잡했고 MSA에서는 서버간 통신으로 데이터를 주고받기 때문에 로깅이 엄청 중요하다는 생각을 가지고 있어서 이것을 적당히 하기보단 확실히 설정을 하고싶은 마음이 들어서 상당히 많은 시간을 들였던 것 같다. 추적에 대한 결과물이 내 마음에 쏙 들지는 않지만 적어도 이제 추적은 가능하게 되었으니 조금씩 고도화를 진행하면서 완벽하게 만들어 가면 될것이라는 생각을 했다.
아직 이번 포스트에서 설명하지 못한 부분이 존재하는데 제일 중요한 내용인 서버간 통신에서 예외가 발생했을 때 어떻게 처리할지에 대한 설명이다. 다음 포스트에서는 예외처리에 대해서 설명하도록 하겠다.
아래의 글은 이 프로젝트를 구현하면서 사용한 ECS에 대한 설명글이다.
이 포스트는 Team chillwave에서 사이드 프로젝트 중 적용했던 부분을 다시 공부하며 기록한 것입니다.
시간이 괜찮다면 팀원 '평양냉면7'님의 블로그도 한번 봐주세요 :)
'Spring MSA' 카테고리의 다른 글
Zipkin 로그 최적화: AWS ALB 헬스 체크 설정과 로그 추적 간소화 (2) | 2023.11.22 |
---|---|
SpringBoot MSA 로깅: Zipkin을 사용한 분산 추적에서 예외상황을 다루는 방법 (1) | 2023.11.22 |
[Spring] Util 클래스 - static vs Bean (3) | 2023.11.18 |
[Spring MSA] Zipkin으로 분산추적 로깅 구현하기 (0) | 2023.11.18 |
[Spring MSA] 스프링 이벤트와 SNS/SQS로 DB 정합성 보장 2탄 - ZeroPayload로 FeignClient 요청 (0) | 2023.11.17 |