MSA 환경에서 분산 트레이싱 구축기

MSA 환경에서 분산 트레이싱 구축기
OpenTelemetry + Micrometer Tracing 공통 라이브러리 제작 경험

MSA 환경에서는 하나의 요청이 여러 서비스를 거치는 것이 일상적입니다. 문제는 그 과정에서 오류가 발생했을 때인데, 단순 에러 로그만으로는 어느 서비스에서 지연이 생긴 것인지, 어디서 장애가 발생했는지 파악하기가 쉽지 않습니다. 이를 해결하기 위해 비즈니스 로직 전반에 걸친 표준화된 Tracing 체계를 구축하게 되었습니다.

투입 시점이 통합 테스트 한두 달 전이었다 보니 시간이 넉넉하지 않았습니다. 최소한 테스트 전에는 체계를 갖춰야 개발자들이 테스트할 때 조금이라도 편하게 디버깅할 수 있을 것 같아서, 빠르게 설계하고 공통 라이브러리 형태로 배포하는 것을 목표로 잡았습니다.

Tracing은 말 그대로 추적입니다. 특정 API를 호출했을 때 그 요청이 끝나기까지 어떤 메서드와 서비스들을 거쳤는지 한눈에 볼 수 있게 만드는 것입니다. 이를 위해 Micrometer Tracing과 OpenTelemetry를 조합했습니다. Micrometer Tracing은 코드 내에서 작업 단위인 Span을 생성하고 로그에 Trace ID를 심는 역할이고, OpenTelemetry는 그 데이터를 표준 프로토콜인 OTLP로 변환해서 Grafana Tempo로 보내주는 역할입니다. 둘의 역할이 명확히 나뉘어 있어서, 나중에 백엔드 수집 도구가 바뀌어도 애플리케이션 코드를 건드릴 필요가 없다는 것도 장점이었습니다.

핵심 용어 정리

용어 설명
Span 작업의 최소 단위. 하나의 메서드 실행 또는 하나의 HTTP 요청에 해당한다.
Trace 여러 Span의 집합. 하나의 요청이 시작부터 끝까지 거치는 전체 경로.
Tracer Span을 생성하고 현재 실행 중인 Span을 관리하는 객체.
Propagator 서비스 간 요청 전송 시 헤더에 Trace ID를 실어 전달하는 도구.
Observation Micrometer의 고수준 API. 트레이싱과 메트릭을 한 번에 기록한다.

분산 트레이싱에서 중요한 것은 서비스를 이동할 때도 같은 Trace ID가 유지되어야 한다는 점입니다. 이 역할을 하는 것이 Propagator인데, 서비스 간 요청 헤더에 Trace 정보를 실어서 전달해주는 도구입니다. 이를 위해 동기·비동기 클라이언트마다 설정을 달리했습니다. RestTemplate과 WebClient는 인터셉터와 필터로 나가는 요청 헤더에 Trace ID를 자동으로 넣었고, FeignClient도 같은 방식으로 현재 Trace 정보를 헤더에 실어 보내도록 처리했습니다. 비즈니스 로직을 건드리지 않고도 전파가 되도록 한 것이 포인트였습니다.

비동기 처리 쪽이 조금 까다로웠습니다. EventListener는 별도 스레드에서 동작하기 때문에 Tracing 컨텍스트가 끊기기 쉬운 구조였습니다. 특히 @TransactionalEventListener는 트랜잭션 커밋 후에 실행되는데, 그 시점엔 Span이 이미 닫혀있어서 그냥 두면 부모-자식 관계가 끊겨버립니다. 그래서 publishEvent()가 호출되는 순간, 즉 Span이 아직 살아있는 타이밍에 context를 미리 캡처해두고, 리스너가 실행될 때 그것을 복원해서 child Span을 만드는 방식으로 해결했습니다. 이렇게 하면 비동기 로직도 하나의 Trace로 이어지고, 이벤트 처리 쪽에서 지연이나 실패가 발생했을 때 어디서 문제가 생긴 것인지 바로 찾을 수 있습니다.

기술적인 구현보다 사실 더 고민했던 부분은 Span을 어디에 심을 것이냐였습니다. '어느 서비스가 느린가'보다 '그 서비스 안에서 어느 레이어의 어떤 메서드가 병목인가'까지 잡아내는 것이 목표였기 때문입니다. Span 생성은 Tracer가 담당하는데, 메서드가 호출되는 시점에 Tracer로 Span을 만들고 메서드가 끝나면 닫는 방식입니다. 여기에 Micrometer의 Observation API를 함께 사용해서 트레이싱과 메트릭을 한 번에 기록할 수 있도록 했습니다. Span 이름은 클래스명과 메서드명을 조합해서 만들었습니다. 예를 들어 UserLogic의 findUser() 메서드면 'UserLogic findUser', OrderResource의 createOrder()면 'OrderResource createOrder' 이런 식입니다.

이것이 가능했던 것은 팀 안에 클래스 네이밍 규칙이 잘 잡혀 있었기 때문입니다. *Resource, *Flow, *Action처럼 클래스 이름 자체에 레이어 역할이 담겨 있었고, AOP Pointcut을 이 패턴 기반으로 동적으로 조합해서 해당 클래스들의 메서드에 자동으로 Span이 생성되도록 했습니다. 클래스 네이밍 규칙이 곧 Tracing 기준이 된 셈이라, 중간에 투입된 상황에서도 코드를 펼쳐보면 어디에 Span을 심어야 할지 바로 파악이 되었습니다.

구현이 마무리되고 나서는 Grafana Tempo에서 API에서 제공하는 TraceID로 전체 흐름을 한눈에 볼 수 있게 되었습니다. 그리고 Error Report 대시보드에서 에러 상태의 Trace를 뽑아서 연관 로그까지 바로 연결해서 볼 수 있었고, Trace Duration Analyzer로는 응답이 느린 Trace를 duration 기준으로 걸러내서 어디가 병목인지 좁혀나갈 수 있었습니다. 장애가 발생했을 때 예전처럼 로그를 뒤지는 시간이 확실히 줄었습니다.

아쉬운 점도 있었습니다. 라이브러리 개발을 다 해놓고 막상 검증할 때 어떤 API로 테스트해야 하는지 처음에 기준이 모호했고, 인증 토큰을 매번 발급받아야 하는 구조 탓에 Tracing 확인하는 과정이 번거로웠습니다. 바쁜 개발자들을 조금 귀찮게 한 부분이었습니다. 테스트 스크립트를 미리 만들어뒀으면 더 편했을 것 같습니다.

이번 작업을 하면서 느낀 것은, 아키텍처가 잘 잡혀 있으면 나중에 이런 공통 작업을 붙이기가 훨씬 수월하다는 것입니다. 레이어가 명확하게 나뉘어 있었기 때문에 공통 라이브러리 코드도 최소한으로 유지할 수 있었습니다.

Tim