분산 트레이싱이 끊기는 자리 · 재시도와 부분 실패 · 이론
세 번째에 성공한 요청은 성공인가 실패인가
한 줄 요약
재시도한 요청 하나를 스팬 몇 개로 그릴지, 그리고 어느 스팬에 오류를 달지를 정하지 않으면 덤프는 조용히 거짓말을 한다.
왜 이게 필요했나
결제 담당자가 물었다. "세 번 시도해서 세 번째에 성공했으면 이건 성공인가요 실패인가요." 답은 둘 다다. 사용자에게는 성공이고, 상류 서비스에게는 두 번의 실패다. 문제는 트레이스가 그 둘 중 하나만 말할 수 있게 그려져 있었다는 것이다.
당시 코드는 재시도 반복문을 스팬 하나로 감싸고 있었다. 화면에는 charge 스팬 하나가 184밀리초로 떠 있었고 상태는 정상이었다. 그 184밀리초 안에 60밀리초짜리 타임아웃 한 번, 20밀리초짜리 503 한 번, 그리고 두 번의 대기가 들어 있다는 사실은 어디에도 남아 있지 않았다. 느려진 이유를 묻는 사람에게 보여 줄 수 있는 것이 "이 스팬이 오래 걸렸다" 뿐이었다.
반대쪽으로 넘어간 팀도 있었다. 시도마다 스팬을 만들고 실패한 시도에 오류 상태를 달았더니, 이번에는 대시보드의 오류율이 갑자기 두 배가 됐다. 사용자가 겪은 실패는 늘지 않았는데 숫자만 올라간 것이다. 스팬을 세면 재시도가 이중으로 계산된다는 것을 아무도 미리 말해 주지 않았다.
어떻게 동작하나
정리하면 결정할 것은 네 가지다.
| 결정 | 선택지 | 이 실습이 고르는 쪽 |
| --- | --- | --- |
| 스팬 나누기 | 재시도 전체를 하나로 / 시도마다 하나 | 감싸는 스팬 1개 + 시도 스팬 N개 |
| 오류를 다는 자리 | 감싸는 스팬 / 실패한 시도 스팬 | 실패한 시도에만 |
| 결과적 성공의 상태 | 그대로 둔다 / 명시적 정상 | 명시적으로 정상이라고 적는다 |
| 세는 단위 | 스팬 / 논리적 요청(루트) | 논리적 요청 |
감싸는 스팬은 사용자가 겪은 하나의 일이다. 세 번 시도해서 결국 성공했다면 사용자는 성공을 겪었으므로 이 스팬은 정상이다. 시도 스팬은 상류에 실제로 나간 한 번의 호출이다. 실패한 호출은 실패로 남겨야 상류의 건강을 볼 수 있다. 두 층이 서로 다른 질문에 답하기 때문에 상태도 따로 간다.
이 구분이 서면 오류율을 어디서 세야 하는지가 저절로 정해진다. 스팬을 분모로 쓰면 재시도를 많이 한 요청일수록 분모와 분자가 함께 커져 실패가 부풀고, 루트 스팬만 세면 사용자가 겪은 실패가 그대로 나온다. 같은 자료에서 53.8% 와 25.0% 가 나오는 일이 실제로 벌어진다 — 실습 4단계에서 그 두 숫자를 직접 만든다.
대기 시간도 갈 곳이 필요하다. 시도와 시도 사이의 backoff 는 어느 시도 스팬에도 들어 있지 않아서, 감싸는 스팬의 구간에서 자식 구간을 빼면 남는 빈틈으로만 보인다. 그 빈틈이 무엇이었는지를 사람이 추측하게 두지 말고 이벤트나 속성으로 적어 두어야 한다. 실습 5단계에서 그 빈틈을 직접 재고 기록과 맞춰 본다.
속성은 규칙으로 못 박아야 쓸모가 있다. 재시도 횟수와 마지막 실패 사유는 논리적 요청 하나의 성질이라 감싸는 스팬에 두고, 몇 번째 시도인지는 시도마다 다르므로 시도 스팬에 둔다. 멱등 키는 양쪽에 같은 값으로 둔다 — 같은 키로 여러 번 나갔다는 사실이 보여야 중복 처리 사고를 가려낼 수 있기 때문이다. HTTP 계측에는 같은 뜻의 표준 속성 http.request.resend_count 가 이미 정의되어 있으니, 직접 이름을 짓기 전에 [HTTP 스팬 시맨틱 컨벤션](https://opentelemetry.io/docs/specs/semconv/http/http-spans/) 을 먼저 보는 편이 좋다. 실패의 분류에 쓰는 error.type 도 [오류 속성 레지스트리](https://opentelemetry.io/docs/specs/semconv/registry/attributes/error/) 에 값의 규칙이 적혀 있다.
여기서 다루지 않는 것 하나를 분명히 해 둔다. 잡은 예외를 어떤 API 로 스팬에 남기고 상태 코드를 어떻게 배선하는지는 SDK 수명주기 모듈이 다룬다. 이 모듈의 질문은 그 앞이다 — 여러 번 시도한 하나의 작업을 스팬 몇 개로 표현하고 그중 어디에 오류를 달 것인가. 상태를 세우는 방법은 [트레이스 API 명세](https://opentelemetry.io/docs/specs/otel/trace/api/) 에 있다.
이 실습 환경이 판정할 수 없는 것도 적어 둔다. 파드에는 OpenTelemetry Collector 도 추적 백엔드도 없다. 그래서 꼬리 샘플링이 실패한 시도를 어떻게 골라내는지, 백엔드 화면에서 재시도가 어떤 모양으로 접히는지는 여기서 확인할 수 없다. 우리가 볼 수 있는 것은 SDK 가 내보낸 스팬을 그대로 적은 JSONL 덤프뿐이고, 판정은 전부 그 덤프의 구조와 속성으로 한다. 걸린 시간은 기계 사정에 따라 몇 밀리초씩 달라지므로 절대 수치가 아니라 관계로만 본다.
현장에서 만나는 모습
사고 회고에서 가장 자주 나오는 문장이 "재시도 덕분에 사용자는 아무것도 못 느꼈다" 인데, 그 말이 맞았는지 확인할 방법이 없는 경우가 많다. 시도 스팬이 없으면 상류가 얼마나 자주 넘어졌는지 셀 수 없고, 감싸는 스팬이 없으면 사용자가 실제로 실패를 겪었는지 셀 수 없다. 두 층이 다 있어야 "상류는 나빴지만 사용자는 괜찮았다" 를 숫자로 말할 수 있다.
반대 사고도 흔하다. 어떤 팀은 재시도를 세 겹으로 넣어 두었다 — 클라이언트 라이브러리가 세 번, 그 위 서비스가 세 번, 게이트웨이가 두 번. 상류 한 번이 넘어지면 실제로는 열여덟 번의 호출이 나갔는데, 트레이스에는 감싸는 스팬 하나만 보였다. 시도 스팬을 만들자마자 그 열여덟 개가 눈에 보였고, 그날 재시도 층을 하나로 줄였다. 계측이 설계 결함을 드러낸 것이다.
다음 실습에서 할 것
결정적으로 두 번 실패했다가 세 번째에 성공하는 상류를 놓고, 재시도를 한 스팬에 담았을 때 덤프가 무엇을 잃는지 먼저 본다. 그다음 시도마다 스팬을 만들고 실패한 시도에만 오류를 달아, 같은 자료에서 스팬 기준 오류율과 요청 기준 오류율을 각각 계산해 얼마나 벌어지는지 확인한다. 대기 시간을 이벤트로 남겨 빈틈과 맞춰 보고, 속성 규칙을 표로 정한 뒤, 반복문을 우리가 고칠 수 없는 두 번째 서비스에 같은 규칙을 훅으로 끼워 넣는다. 마지막에는 그 규칙을 린터로 굳혀 규칙을 어긴 덤프를 실제로 잡아낸다.