분산 트레이싱이 끊기는 자리 · 속성·이벤트·링크 — 재현할 수 있는 스팬 · 이론
이 스팬만 보고 장애를 재현할 수 있는가
한 줄 요약
좋은 스팬은 그 한 줄만 보고 같은 실패를 다시 낼 수 있게 하는 스팬이다. 속성은 그 재현 입력이고, 이벤트는 그 사이에 언제 무슨 일이 있었는지이고, 링크는 이 일을 시킨 다른 트레이스가 무엇인지다.
왜 이게 필요했나
새벽에 결제 실패가 몰렸다. 트레이스를 열었더니 스팬에는 http.route=/checkout, http.response.status_code=502, 그리고 1842밀리초가 있었다. 여기까지는 자동 계측이 다 해 준 것이다. 그런데 이 스팬을 들고 할 수 있는 일이 없었다. 장바구니에 상품이 몇 개였는지, 캐시가 비어 있었는지, 큐가 얼마나 밀려 있었는지, 결제를 몇 번 다시 걸었는지 — 재현에 필요한 값이 하나도 없었다.
반대로 어떤 팀은 "일단 다 넣자" 로 갔다가 사고를 냈다. 요청 본문을 통째로 속성에 넣었더니 고객 이메일과 인증 토큰이 관측 백엔드에 그대로 쌓였고, 그 백엔드는 개발자 전원이 볼 수 있었다. 그 뒤로 그 팀은 속성에 무엇을 넣는가 를 코드 리뷰 항목으로 올렸다.
이 실습이 다루는 것은 그 사이다. SDK 를 어떻게 설정하는가도, service.name 을 어떤 순서로 정하는가도 아니고, 속성 칸을 무엇으로 채울 것인가 다.
어떻게 동작하나
먼저 질문을 뒤집는다. "무엇을 넣을까" 가 아니라 "이 스팬만 들고 같은 실패를 다시 내려면 무엇이 더 있어야 하나" 를 묻는다. 그 답을 적어 놓으면 그게 곧 속성 목록이다. 재현 입력(상품 수, 본문 크기), 그때의 상태(캐시 적중, 큐 깊이), 그리고 우리가 한 일(재시도 횟수)이 대개 여기 들어간다.
그다음 넣으면 안 되는 값을 가른다. 연락처·카드 번호·인증 토큰 같은 값은 원문으로 남기지 않는다. 그렇다고 통째로 버리면 조사할 때 곤란하므로, 세 가지 중 하나로 바꿔 남긴다.
| 원문 | 바꿔 남기는 법 | 그래도 답할 수 있는 질문 |
| --- | --- | --- |
| 이메일·계정 | 해시(앞 몇 자리) | "같은 사용자에게 반복되는가" |
| 카드 번호 | 범주(브랜드) | "특정 카드사에서만 나는가" |
| 인증 토큰 | 길이 | "토큰이 잘려 들어왔는가" |
세 번째로, 시점이 있는 사실은 속성이 아니라 이벤트다. 재시도를 두 번 했다는 것은 하나의 숫자라 속성이지만, 첫 번째 재시도가 언제 어떤 이유로 일어났는가는 시각이 있는 기록이라 이벤트다. 같은 사실을 속성으로만 남기면 순서가 사라지고, 이벤트로만 남기면 집계가 어려워진다. 둘은 경쟁 관계가 아니라 역할이 다르다 — 세는 것은 속성으로, 일어난 순간은 이벤트로.
네 번째로, 관계 중에는 부모-자식이 아닌 것이 있다. 큐에 쌓인 작업을 나중에 처리할 때 그 작업은 다른 트레이스에서 만들어졌고, 처리 스팬을 그 트레이스의 자식으로 붙이면 몇 시간 뒤에 끝나는 이상한 부모가 생긴다. 이럴 때 쓰는 것이 링크다. 링크는 "이 스팬은 저 스팬과 관계가 있다" 만 말하고 부모 자리는 비워 둔다. 관계를 속성 문자열(parent_trace_id=...)로 적어 두는 팀이 많은데, 그러면 도구가 이어 주지 못하고 사람이 눈으로 찾아야 한다. 여기서는 "관계는 속성이 아니라 링크" 라는 것만 한 번 써 보고, 큐 너머의 트레이스를 어떻게 설계할지는 뒤의 모듈에서 따로 다룬다.
마지막이 비용이다. 속성 열쇠마다 값이 몇 가지나 되는지를 세어 보면 성격이 갈린다. 주문 번호는 요청마다 다른 것이 정상이고(식별자), 카드 브랜드는 세 가지여야 정상이다(범주). 문제는 범주여야 할 자리에 원문이 새어 들어간 경우다 — 예외 메시지에 주문 번호가 박혀 있으면 열쇠 하나가 순식간에 수천 가지 값을 갖는다. 그래서 열쇠마다 "고유값을 제한하지 않는 열쇠" 와 "범주형이라 고유값이 적어야 하는 열쇠" 를 나눠 적어 두고, 덤프를 세어 어긋난 곳을 찾는다.
여기까지 정한 것을 문장으로만 남기면 다음 사람이 읽지 않는다. 열쇠 이름·타입·허용값·고유값 성격을 한 줄씩 적은 규약 파일로 만들고, 그 규약을 어긴 스팬을 찾아 주는 작은 검사기를 함께 둔다. 그러면 새 핸들러를 계측할 때 규약이 저절로 따라온다.
이 파드에서 판정할 수 없는 것도 적어 둔다. 컬렉터가 없다. 실제 운영에서는 컬렉터의 redaction 프로세서로 한 번 더 걸러 내지만, 여기서는 그 단계를 돌릴 수 없어 애플리케이션이 스스로 거른 결과만 본다. 속성이 백엔드에서 어떻게 색인되고 얼마나 비싼지도 이 파드에서는 알 수 없다 — 대신 덤프를 세어 고유값 수로 대신한다. 그리고 SDK 가 속성 개수와 길이를 잘라 내는 설정은 이 실습의 범위가 아니다.
현장에서 만나는 모습
한 서비스는 장애 때마다 "재현이 안 된다" 로 조사가 멈췄다. 스팬에 요청 식별자조차 없어서 어느 요청이 실패했는지 로그에서 찾을 수가 없었다. 속성 여섯 개(주문 번호·상품 수·본문 크기·캐시 적중·큐 깊이·재시도 횟수)를 더한 뒤로는 실패한 스팬을 하나 골라 그대로 같은 입력을 다시 넣어 볼 수 있게 됐고, 조사 시간이 시간 단위에서 분 단위로 줄었다.
다른 사고는 정반대였다. 예외 메시지를 속성에 그대로 넣는 관행 때문에 열쇠 하나가 수만 가지 값을 갖게 됐고, 그 열쇠로 검색하는 화면이 매번 시간 초과로 죽었다. 고친 방법은 메시지를 지운 것이 아니라 둘로 나눈 것 이다 — 분류할 수 있는 짧은 종류(declined·timeout)는 속성으로, 사람이 읽을 긴 문장은 스팬 상태 메시지와 이벤트로 옮겼다. 검색은 다시 빨라졌고 사람이 읽을 문장도 그대로 남았다.
다음 실습에서 할 것
실패한 요청의 스팬 한 줄을 받아, 재현에 필요한데 없는 값이 무엇인지부터 적는다. 그 값을 속성으로 더하고, 넣으면 안 되는 값은 해시·범주·길이로 바꿔 남긴다. 재시도와 캐시 미스처럼 시점이 있는 사실은 이벤트로 옮기고, 이백 건을 돌려 속성 열쇠마다 고유값을 세어 예산표를 만든다. 거기서 드러난 새는 열쇠를 근거로 팀 규약 파일을 쓰고, 그 규약을 검사하는 프로그램을 만들어 내 덤프와 남의 덤프에 돌린다. 마지막으로 큐가 시킨 환불 핸들러를 규약대로 계측하면서, 그 작업을 만들어 낸 트레이스를 링크로 잇는다.
- [OpenTelemetry — Traces (속성·이벤트·링크)](https://opentelemetry.io/docs/concepts/signals/traces/)
- [OpenTelemetry — 민감한 자료 다루기](https://opentelemetry.io/docs/security/handling-sensitive-data/)
- [OpenTelemetry — 속성 이름 규약](https://opentelemetry.io/docs/specs/semconv/general/naming/)