분산 트레이싱이 끊기는 자리 · 큐를 건너는 추적 · 이론
큐 너머는 부모-자식이 아니라 링크로 잇는다
한 줄 요약
큐 너머는 부모-자식이 아니라 링크로 잇는다. 부모-자식은 "이 일이 끝나야 저 일이 끝난다" 는 뜻인데, 생산자는 소비가 끝나기를 기다리지 않기 때문이다.
왜 이게 필요했나
주문 API 에 큐를 넣고 나서 트레이스가 이상해졌다. 사용자에게는 40밀리초 만에 응답이 나가는데 화면의 루트 스팬은 3초로 떠 있었다. 계측한 사람은 "주문 하나가 끝까지 처리되는 것을 한 트레이스로 보고 싶다" 는 좋은 뜻으로 소비 쪽 스팬을 생산 스팬의 자식으로 만들어 두었다.
부모-자식은 그런 뜻이 아니다. 부모 스팬은 자식이 모두 끝난 뒤에 끝난다. 그래서 생산자는 이미 응답을 돌려보냈는데도 스팬을 닫지 못하고 앉아 있게 되고, 큐가 밀린 날에는 그 스팬이 몇 분씩 열려 있는다. 팬아웃이 섞이면 더 나쁘다 — 주문 하나가 메시지 다섯 개를 만들고 그중 하나가 또 세 개를 만들면, 한 트레이스가 수천 개의 스팬으로 자라 백엔드가 그 트레이스만 특별 취급하기 시작한다.
어떻게 동작하나
OpenTelemetry 는 이런 자리를 위해 링크(Link) 를 둔다. 링크는 "이 스팬은 저 스팬과 인과로 이어져 있지만, 저 스팬이 나를 기다리지는 않는다" 는 관계다. 스팬을 만들 때 링크 목록을 함께 주고, 링크마다 속성을 붙일 수 있다. 자세한 정의는 [트레이스 API 명세](https://opentelemetry.io/docs/specs/otel/trace/api/) 와 [Traces 개념 문서](https://opentelemetry.io/docs/concepts/signals/traces/) 에 있다.
| 잇는 방법 | 뜻 | 쓰는 자리 |
| --- | --- | --- |
| 부모-자식 | 부모가 자식을 기다린다 | 같은 요청 안의 동기 호출 |
| 링크 | 인과는 있지만 기다리지 않는다 | 큐 너머, 배치 처리, 재처리 |
| 아무것도 안 함 | 관계가 기록되지 않는다 | 정말로 무관한 작업 |
그래서 큐 실습의 기본형은 이렇다. 생산 쪽은 메시지를 넣는 스팬을 만들고 그 스팬의 아이디를 메시지에 실어 보낸다. 소비 쪽은 새 트레이스의 루트로 스팬을 시작하되, 메시지에 실려 온 아이디로 링크를 만들어 건다. 그러면 생산 트레이스는 사용자 응답과 함께 바로 끝나고, 소비 트레이스는 자기 시간만큼만 산다. 두 트레이스는 링크로 이어져 있으므로 나중에 따라갈 수 있다.
한 번에 여러 건을 꺼내는 배치 소비는 링크가 여러 개인 스팬 하나로 그린다. 메시지마다 스팬을 만들면 배치를 처리한 한 번의 일이 흩어지고, 링크 없이 하나만 만들면 어느 메시지가 그 배치에 들어 있었는지 알 수 없다. 메시징 시맨틱 컨벤션은 이 경우 messaging.batch.message_count 를 적으라고 말한다 — [메시징 스팬 컨벤션](https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/) 에 표가 있다.
큐에서 기다린 시간은 어느 스팬에도 저절로 담기지 않는다. 생산 시각을 메시지에 실어 보내고 소비 스팬이 그 차이를 속성으로 적어야 한다. 이 값이 있으면 "처리가 느린 것"과 "큐가 밀린 것"을 구별할 수 있고, 둘은 고치는 방법이 완전히 다르다.
스팬 종류도 같은 문서가 정해 둔다. 메시지를 만들거나 보내는 스팬은 PRODUCER, 애플리케이션이 메시지를 처리하는 스팬은 CONSUMER 다(꺼내 오기만 하는 receive 는 CLIENT). 이 값은 장식이 아니라 분석 도구가 트레이스 사이의 관계를 해석하는 단서라, 규칙 없이 붙이면 도구가 큐를 알아보지 못한다.
링크에는 속성을 붙일 수 있고, 붙여야 한다. 링크만 있으면 "이어져 있다" 는 사실은 남지만 왜 이어져 있는지는 남지 않는다. 큐 메시지 때문인지, 재처리 때문인지, 배치에 함께 들어갔기 때문인지를 링크 속성에 적어 두면 나중에 사람이 읽을 수 있다.
이 모듈이 다루지 않는 것을 분명히 해 둔다. HTTP 헤더 traceparent 의 조각을 읽어 끊긴 체인을 잇는 일은 전파 실습의 몫이고, 한 프로세스 안에서 스레드나 Task 로 문맥을 넘기는 일은 문맥 경계 실습의 몫이다. 여기서 정하는 것은 부모-자식으로 잇는 것이 옳지 않은 자리를 알아보고 링크로 바꾸는 판단이다.
실습 환경이 판정할 수 없는 것도 적어 둔다. 파드에는 브로커도 Collector 도 추적 백엔드도 없다. 그래서 백엔드 화면이 링크를 어떤 모양으로 그려 주는지, 꼬리 샘플링이 링크로 이어진 두 트레이스를 함께 남기는지는 여기서 확인할 수 없다. 큐는 파일 한 개로 흉내 내고, 판정은 전부 SDK 가 내보낸 JSONL 덤프의 구조와 속성으로 한다. 걸린 시간은 기계 사정에 따라 달라지므로 절대 수치가 아니라 관계로만 본다.
현장에서 만나는 모습
가장 흔한 신호는 "루트 스팬의 시간이 사용자가 기다린 시간과 다르다" 는 것이다. 응답은 40밀리초에 나갔는데 트레이스는 3초라면, 거의 언제나 비동기 작업을 자식으로 매달아 둔 것이다. 그 트레이스로는 지연을 측정할 수 없다 — 사용자가 겪은 시간과 시스템이 일한 시간이 한 숫자에 뭉쳐 있기 때문이다.
또 하나는 "특정 트레이스만 열리지 않는다" 는 증상이다. 팬아웃을 부모-자식으로 이어 둔 시스템에서 주문 하나가 수천 스팬으로 자라면, 백엔드가 그 트레이스를 자르거나 화면이 멈춘다. 링크로 바꾸면 트레이스 하나하나는 작게 유지되고, 전체 여정은 링크를 따라가며 다시 이어 붙일 수 있다. 실습 마지막 단계에서 그 이어 붙이기를 직접 해 본다.
다음 실습에서 할 것
파일 한 개로 된 아주 작은 큐에 메시지를 넣고 나중에 꺼내 처리한다. 먼저 생산과 소비를 부모-자식으로 이어 루트 스팬이 어떻게 부풀고 빈틈이 어디에 생기는지 덤프로 본다. 그다음 소비 쪽을 새 트레이스의 루트로 바꾸고 링크로 잇는다. 배치 소비를 링크 여러 개인 스팬 하나로 그리고, 큐에서 기다린 시간을 속성으로 남기고, 스팬 종류와 메시징 속성을 컨벤션대로 붙이고, 링크에 이유를 적는다. 마지막에는 하나가 여럿을 만드는 팬아웃까지 적용하고, 링크를 따라가 한 주문의 여정을 트레이스 너머로 이어 붙인다.