분산 트레이싱이 끊기는 자리 · 스팬 경계 — 어디서 끊을 것인가 · 이론
스팬을 어디서 끊을 것인가
한 줄 요약
스팬은 코드 블록마다 긋는 것이 아니라 다음에 이 트레이스를 열어 볼 사람이 무엇을 물을 것인가 로 긋는다. 너무 적으면 어디를 봐야 할지 알 수 없고, 너무 많으면 아무도 열어 보지 않는다.
왜 이게 필요했나
결제가 느리다는 신고를 받고 트레이스를 열었는데 스팬이 하나였다. 이름은 POST /checkout, 길이는 211밀리초. 여기서 읽을 수 있는 것은 "211밀리초 걸렸다" 뿐이다. 장바구니를 읽느라 걸린 건지, 결제 대행사가 늦은 건지, 우리 프로세스 안에서 상품 값을 스물네 번 구하느라 걸린 건지 알 수 없다. 계측은 켜져 있었지만 아무 질문에도 답하지 못하는 계측이었다.
그래서 반대로 간 팀도 있다. 반복문 안에 스팬을 하나씩 넣었더니 한 요청이 스팬 서른 개가 됐고, 상품이 이백 개인 주문에서는 이백 개가 넘었다. 화면에는 똑같이 생긴 막대가 끝없이 이어지고, 장애 회의에서 그 트레이스를 끝까지 스크롤한 사람은 아무도 없었다. 스팬 수는 저장 비용이자 읽는 사람의 주의력 예산이기도 하다.
두 실패는 같은 질문을 건너뛴 결과다 — 이 요청에서 나중에 무엇을 묻게 될 것인가, 그 질문에 답하려면 경계가 어디에 있어야 하는가.
어떻게 동작하나
경계를 정할 때 쓸 수 있는 숫자가 하나 있다. 부모 스팬이 살아 있던 시간 중 어느 자식도 덮지 않은 시간 이다. 이 글에서는 계측 공백이라고 부른다. 자식이 서로 겹칠 수 있으므로 합집합으로 재고, 부모 길이에서 빼면 남는다.
POST /checkout ├────────────────────────────────────────┤ 211ms cart.load ├──────┤ 30ms payment.charge ├────────────┤ 60ms 공백 ······· ······ ······ 121ms공백이 크다는 것은 느리다는 뜻이 아니라 그 구간에 아직 스팬이 없다는 뜻이다. 이 구분은 중요하다. 자기 시간과 임계 경로는 이미 만들어진 스팬들 사이에서 무엇이 느린가를 묻는 성능 계산이고, 공백은 아직 계측이 없는 자리를 가리키는 지도다. 공백을 재는 목적은 순위를 매기는 것이 아니라 다음 스팬을 어디에 그을지 고르는 것이다.
경계를 그을 때 쓰는 규칙은 세 가지로 정리된다.
| 규칙 | 뜻 | 어겼을 때 |
| --- | --- | --- |
| 프로세스 경계에는 반드시 스팬 | 들어온 요청·나가는 호출은 나눈다 | 남의 잘못인지 우리 잘못인지 못 가른다 |
| 공백이 큰 구간을 먼저 가른다 | 덮이지 않은 시간이 곧 모르는 시간 | 스팬을 늘려도 답이 안 나온다 |
| 반복은 스팬이 아니라 접어서 | 횟수·총시간·최댓값으로 | 같은 모양 막대가 트레이스를 덮는다 |
세 번째가 실무에서 가장 자주 어긋난다. 반복 구간을 하나의 스팬으로 감싼 뒤, 그 스팬에 count, total_ms, max_ms 를 속성으로 붙이면 스팬 하나로 반복 전체를 설명할 수 있다. 평균만 남기면 스물네 번 중 한 번이 열 배 느렸다는 사실이 사라지므로 최댓값을 함께 남기고, 그 한 건이 무엇이었는지는 이벤트로 적는다. 이벤트는 스팬 안의 한 시점에 붙는 기록이라 반복 중 특정 순간을 남기기에 맞다.
경계의 표시로는 SpanKind 를 쓴다. 들어온 요청을 처리하는 스팬은 SERVER, 프로세스 밖으로 나가는 호출은 CLIENT, 프로세스 안에서 나눈 구간은 INTERNAL 이다. 이 표시는 장식이 아니라 나중에 "우리가 기다린 시간" 과 "우리가 쓴 시간" 을 가르는 기준이 된다. CLIENT 스팬의 합이 크면 남을 기다린 것이고, INTERNAL 이 크면 우리가 일한 것이다.
마지막으로, 이 판단은 사람의 취향으로 남기면 리뷰 때마다 다시 싸우게 된다. 요청당 스팬 상한과 허용 공백 비율을 파일로 적어 두고 기계가 읽게 하면, 새 핸들러를 계측할 때 같은 기준이 저절로 적용된다. 상한을 넘겼다고 해서 늘 틀린 것은 아니지만, 넘긴 이유를 한 줄 적게 만드는 것만으로도 "일단 스팬부터 늘리고 보자" 가 사라진다.
이 실습 파드에서 판정할 수 없는 것도 분명히 해 두자. 컬렉터 바이너리도, 트레이스를 그려 주는 백엔드 화면도 이 파드에 없다. 그래서 "화면에서 이 트레이스가 읽기 좋은가" 는 눈으로 봐야 하고, 채점기가 보는 것은 스팬을 JSONL 로 떨어뜨린 덤프의 구조 다 — 스팬 수, 부모 관계, 이름, SpanKind, 속성 키, 그리고 공백 비율. 밀리초 절대값은 파드가 붐비면 달라지므로 비율로만 판정한다.
현장에서 만나는 모습
한 주문 서비스는 자동 계측만 켜고 몇 달을 보냈다. 자동 계측은 HTTP 입구와 데이터베이스 드라이버에 스팬을 만들어 주므로 트레이스가 비어 보이지는 않았다. 그런데 느린 요청을 열면 늘 공백이 절반이었다. 그 절반은 애플리케이션 코드가 직접 도는 구간이었고, 자동 계측이 볼 수 있는 자리가 아니었다. 공백을 재기 시작하고 나서야 "어디에 손으로 스팬을 넣어야 하는가" 가 목록으로 나왔다.
반대 사고도 있었다. 배치 작업 하나가 항목마다 스팬을 만들었는데, 평소에는 항목이 열 개라 아무도 몰랐다. 월말에 항목이 이만 개가 되자 한 트레이스가 이만 개 스팬이 됐고, 수집 쪽이 그 트레이스 하나 때문에 밀렸다. 고친 방법은 스팬을 지운 것이 아니라 접은 것 이다 — 항목 스팬을 없애고 배치 스팬 하나에 처리 건수와 최장 소요를 속성으로 남겼다. 트레이스는 다시 읽을 수 있게 됐고, 느린 한 건은 이벤트로 그대로 남았다.
다음 실습에서 할 것
계측이 한 줄도 없는 주문 처리 코드를 받아, 스팬 하나짜리 트레이스에서 출발한다. 공백을 재서 계측이 없는 구간을 찾고, 그 구간을 갈라 공백을 5% 아래로 내린다. 그다음 반복 구간을 스팬으로 만들어 스팬이 몇 개가 되는지 직접 세어 보고, 같은 반복을 속성과 이벤트로 접어 다시 줄인다. SpanKind 로 경계를 표시한 뒤 요청당 스팬 상한과 허용 공백을 규칙 파일로 적고, 그 규칙을 검사하는 작은 프로그램을 만들어 앞에서 만든 덤프들에 돌려 본다. 마지막으로 두 번째 핸들러를 같은 규칙 아래에서 처음부터 계측한다.
- [OpenTelemetry — Traces (SpanKind, 속성, 이벤트)](https://opentelemetry.io/docs/concepts/signals/traces/)
- [OpenTelemetry Specification — Tracing API](https://opentelemetry.io/docs/specs/otel/trace/api/)
- [OpenTelemetry Python — Instrumentation](https://opentelemetry.io/docs/languages/python/instrumentation/)