분산 추적이 큐·스레드 풀에서 끊길 때 — 추적 문맥 전파
분산 추적을 붙였는데 하필 시간이 가장 오래 걸리는 큐·워커 구간에서 추적이 끊겨요. 문맥이 스레드에 묶여 있어서예요. 큐와 스레드를 넘어서도 요청을 끝까지 따라가게 만드는 법을 알 수 있어요.
분산 추적을 붙였어요. 요청 하나가 어느 서비스를 거쳐 어디서 시간을 쓰는지 한눈에 보여요.
그런데 느린 요청을 추적해 보면 중간에 끊겨 있어요. API 게이트웨이부터 우리 서비스까지는 잘 나오는데, 그다음이 없어요.
없어진 구간은 비동기 처리였어요. 큐에 넣고 워커가 처리하는 부분이요. 그리고 하필 시간을 가장 많이 쓰는 곳이 거기였어요.
돈을 들여 도구를 붙였는데 정작 알고 싶은 구간만 비어 있는 거예요. 워커 로그를 따로 뒤져 시각으로 짐작해 맞추는 수밖에 없었어요.
추적은 문맥이 따라가야 이어져요. 그리고 문맥은 스레드나 요청에 매여 있어서, 경계를 넘을 때 명시적으로 옮기지 않으면 사라집니다.
왜 그런가
추적은 요청마다 고유한 식별자를 만들고, 그게 지나가는 모든 구간에 붙어 다니는 구조예요. 그 식별자와 부모-자식 관계가 문맥이에요.
문맥은 대개 스레드 로컬 저장소에 담겨요. 같은 스레드에서 이어지는 호출은 자동으로 문맥을 물려받아요. HTTP 호출도 대개 자동인데, 라이브러리가 헤더에 문맥을 실어 보내기 때문이에요.
그런데 경계를 넘으면 끊겨요.
스레드가 바뀌는 경우. 작업을 스레드 풀에 넘기면 새 스레드에는 문맥이 없어요.
큐를 거치는 경우. 메시지를 넣고 워커가 나중에 꺼내는데, 메시지에 문맥을 담지 않으면 워커는 이게 어느 요청에서 온 건지 몰라요.
예약 실행. 지금 등록하고 나중에 실행되는 작업도 마찬가지예요.
그리고 이 끊긴 구간이 하필 느린 구간인 경우가 많아요. 비동기로 뺀 이유가 대개 오래 걸려서니까요. 그래서 추적이 가장 필요한 곳에서 끊깁니다.
여기에 하나 더 있어요. 문맥이 없으면 그 구간의 로그도 요청과 연결이 안 돼요. 추적만 끊기는 게 아니라 조사할 근거 전체가 사라지는 거예요.
코드로 보면
경계를 넘을 때 문맥을 실어 보낸다:
queue.send(payload)
워커는 이게 어느 요청에서 왔는지 모른다
· 메시지에 문맥을 담는다
ctx = 현재 추적 문맥을 꺼낸다
queue.send({payload, trace: ctx})
워커: 받은 ctx 로 문맥을 복원하고 자식 구간 시작
· 스레드를 넘길 때도 마찬가지
executor.submit(task)
executor.submit(withContext(task)) # 문맥을 감싸 넘긴다
· 비동기 구간은 부모-자식이 아니라 '연결' 로 표현한다
요청은 이미 끝났는데 워커는 나중에 도는 구조라면
별도 추적으로 두고 링크로 잇는 편이 정확하다✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
문맥을 옮기는 방법이 경계마다 달라요.
HTTP 는 대개 자동이에요. 추적 라이브러리가 표준 헤더에 문맥을 실어 보내고 받는 쪽이 복원해요. 다만 표준이 여러 개라 서비스마다 다른 형식을 쓰면 안 이어져요. 조직 안에서 형식을 통일해야 합니다.
메시지 큐는 수동인 경우가 많아요. 메시지 헤더나 본문에 문맥을 넣고 워커가 복원해야 해요. 라이브러리가 지원하면 자동으로 되지만, 직접 만든 큐라면 직접 넣어야 합니다.
스레드 풀은 감싸는 방식으로 풀어요. 작업을 넘길 때 현재 문맥을 캡처해서 실행 시점에 복원하는 래퍼를 씌우는 거죠. 많은 라이브러리가 이런 래퍼를 제공해요.
여기서 개념적인 문제가 하나 있어요. 비동기 작업을 자식으로 볼 것인가 별개로 볼 것인가.
요청이 워커의 완료를 기다린다면 자식이 맞아요. 부모 구간 안에 자식 구간이 들어가고 시간도 포함돼요.
그런데 요청은 접수만 하고 끝났고 워커는 나중에 돈다면, 자식으로 두면 이상해져요. 부모가 이미 끝났는데 자식이 나중에 시작하니 시간축이 안 맞아요. 이런 경우는 별도 추적으로 두고 링크로 잇는 게 정확합니다.
그리고 실무적으로 중요한 게 있어요. 요청 식별자를 로그에도 넣으세요. 추적 도구가 없거나 표본에서 빠져도, 로그에 식별자가 있으면 그걸로 이어 볼 수 있어요. 추적과 로그가 같은 식별자를 공유하면 둘을 오갈 수 있고요.
처방
- 비동기 경계에서 문맥을 명시적으로 옮기세요. 큐 메시지에 담고 워커가 복원하게요.
- 스레드 풀에 넘기는 작업은 문맥을 감싸는 래퍼를 쓰세요.
- 추적 헤더 형식을 조직 안에서 통일하세요. 서비스마다 다르면 안 이어집니다.
- 요청이 기다리지 않는 비동기 작업은 별도 추적 + 링크로 표현하세요. 자식으로 두면 시간축이 어긋납니다.
- 요청 식별자를 로그에도 넣으세요. 추적이 없어도 이어 볼 수 있습니다.
- 추적과 로그가 같은 식별자를 공유하게 하세요. 둘을 오갈 수 있어야 합니다.
- 끊긴 구간이 있는지 주기적으로 확인하세요. 느린 요청의 추적을 열어 보면 바로 보입니다.
- 새 비동기 경로를 만들 때 문맥 전파를 점검 항목에 넣으세요.
- 배치 작업은 하나의 문맥으로 묶지 마세요. 구간이 수십만 개인 추적은 시스템도 사람도 감당 못 합니다.
- 문맥에는 식별자와 최소한의 정보만 담으세요. 모든 경계를 넘어 다니니 커진 만큼 전송 비용이 붙습니다.
지금 확인해 보세요
추적이 끊기는 곳을 찾는다:
· 느린 요청의 추적을 열어 본다
중간에 비는 구간이 있는지
대개 비동기 경계다 — 큐 · 스레드 풀 · 예약 실행
· 비동기 경로 목록을 만들고 각각 확인
이 경계에서 문맥을 옮기고 있는가
· 추적 시간이 실제 응답 시간과 맞는가
몇 분짜리 추적인데 요청은 50ms 면 자식 관계가 잘못됐다
· 로그에 요청 식별자가 있는가
추적이 없거나 표본에서 빠져도 이어 볼 수 있다
· 헤더 형식이 서비스마다 같은가
다르면 그 경계에서 끊긴다핵심 한 줄문맥은 경계를 스스로 넘지 않는다. 실어 보내야 이어진다.