1분인데 16MiB를 넘는 이유: Tempo Search와 고카디널리티 태그
1분 범위의 trace를 검색했을 뿐인데, 16 MiB 응답 제한에 걸렸다.
처음에는 검색 시간 범위를 1시간에서 1분으로 좁히면 데이터양이 줄어들어 문제가 해결될 것이라 생각했다. 하지만 오류는 사라지지 않았고, 응답 크기는 여전히 약 17 MB에 달했다.
원인은 trace 검색 자체가 아니었다. Grafana Explore의 Search UI가 user.id 입력창의 자동완성 드롭다운을 채우기 위해 백그라운드에서 실행한 태그 값 탐색(tag-value discovery) 요청 때문이었다. 이미 알고 있는 특정 ID로 trace를 조회하는 일과, 시스템에 존재하는 수많은 ID 목록을 한 번에 뽑아내는 일은 컴퓨터 입장에서 완전히 다른 비용을 가진다.
왜 이 문제가 궁금해졌는가
분산 추적 시스템인 Grafana Tempo를 사용할 때, 대부분의 엔지니어는 Grafana Explore의 Search UI(쿼리 빌더) 로 조회를 시작한다. 복잡한 TraceQL 문법을 외우지 않아도 서비스 이름, 엔드포인트(span name), HTTP 상태 코드, 태그 등을 마우스 클릭 몇 번으로 조합할 수 있기 때문이다.
특정 사용자가 겪은 장애를 추적하기 위해 Search UI에서 span.user.id 태그를 추가하고, 그 옆의 Select value 드롭다운 버튼을 눌렀다. 목표는 드롭다운에서 사용자의 ID를 찾아 해당 요청의 trace를 확인하는 것이었다.
하지만 드롭다운이 열리는 대신 다음과 같은 에러 메시지가 화면을 덮었다.
response larger than the max
17,058,823 bytes > 16,777,216 bytes응답 크기(약 17.05 MB)가 시스템이 허용하는 최대 응답 상한선인 16 MiB(16,777,216 bytes)를 초과했다는 뜻이다.
“시간 범위가 너무 넓어서 그런가?” 싶어 조회 구간을 1시간에서 1분으로 줄였다. 1분 동안 쌓인 trace라면 데이터양이 수십 분의 일로 줄어드니 당연히 가볍게 열릴 것이라 기대했다. 하지만 결과는 똑같이 16 MiB 초과 실패였다.
왜 단 1분짜리 범위에서도 17 MB가 넘는 거대한 응답이 만들어졌을까? Search UI는 드롭다운 버튼 하나를 누르는 순간 뒤에서 도대체 무슨 요청을 보내고 있었을까?
도서관 비유: ‘책 찾기’와 ‘회원 명부 만들기’
이 현상을 직관적으로 이해하기 위해 도서관에 비유해 볼 수 있다.
- Trace 검색: “철수(user.id = ‘chulsoo’)가 오늘 대출해 간 책 목록 5권만 찾아주세요.”
- 사서는 대출 기록 카드를 뒤져 철수의 이름이 적힌 카드 5장을 찾으면 즉시 검색을 끝낸다. 1분이면 충분하고 작업량도 매우 적다.
- Tag Value 자동완성(드롭다운): “오늘 우리 도서관을 방문한 모든 대출자의 이름 목록 을 중복 없이 전부 적어서 가져와 주세요.”
- 사서는 오늘 작성된 모든 서가를 처음부터 끝까지 일일이 넘겨보며, 거기에 적힌 모든 사람의 이름을 적어 거대한 명부를 만들어야 한다.
- 책을 찾는 행위가 아니라, 방문자 전체 명부(Unique Values List) 를 작성하는 작업이기 때문이다.
Grafana Search UI의 드롭다운을 누르는 행위는 바로 후자인 ‘전체 회원 명부 만들기’ 였다.
Search UI가 뒤에서 만드는 두 종류의 요청
Grafana Search UI는 겉보기에는 하나의 통합된 검색창처럼 보이지만, 실제로는 목적이 완전히 다른 두 가지 HTTP API 를 Tempo 백엔드에 호출한다.
flowchart TD subgraph UI["Grafana Search UI"] A["조건 입력 & 드롭다운 클릭"] B["최종 Run Query 클릭"] end subgraph API["Tempo HTTP API"] C["/api/search/tag/.../values<br>(태그 값 목록 탐색)"] D["/api/search<br>(조건 일치 Trace 검색)"] end A -->|"Select value 클릭 시"| C B -->|"검색 실행 시"| D C -.->|"응답: 수십만 개 고유 ID 목록 (17MB 폭발)"| UI D -.->|"응답: 조건에 맞는 Trace 5~20건 (수십 KB)"| UI
1. 조건에 맞는 Trace를 가져오는 요청 (/api/search)
엔지니어가 서비스 이름, 엔드포인트, 그리고 이미 알고 있는 user.id를 입력하고 Run Query 를 누르면 다음 TraceQL 쿼리가 실행된다.
{
resource.service.name = "example-api" &&
name = "GET /v3/example" &&
span.user.id = "known-user-id"
}이 요청은 Tempo의 /api/search 엔드포인트로 전달된다.
sequenceDiagram participant U as 엔지니어 participant G as Grafana Search UI participant T as Tempo Backend U->>G: 서비스, 엔드포인트, known-user-id 입력 후 검색 G->>T: GET /api/search?q={TraceQL}&limit=5 Note over T: 인덱스를 참조하여 조건에 맞는 Trace 5건만 추출 T-->>G: 5건의 Trace 메타데이터 반환 (수십 KB) G-->>U: 화면에 Trace 리스트 표시
이 쿼리는 limit=5처럼 반환 개수가 작게 제한되며, 이미 알고 있는 특정 ID 단 하나와 일치하는 span을 찾는다. 데이터 스캔량도 작고 응답 크기도 수십 KB에 불과하다.
2. 드롭다운 목록을 채우는 요청 (/api/search/tag/{tag_name}/values)
반면, Search UI에서 span.user.id 태그 옆의 Select value 를 클릭하는 순간 발생하는 요청은 완전히 다르다.
GET /api/search/tag/span.user.id/values?start=1725410000&end=1725410060이 요청은 trace 데이터를 한 줄도 가져오지 않는다. 대신 지정된 시간 범위 내에 존재하는 모든 고유한 user.id 문자열 값들을 모조리 긁어모아 JSON 배열로 반환 하려 한다.
sequenceDiagram participant U as 엔지니어 participant G as Grafana Search UI participant T as Tempo Backend U->>G: user.id 태그의 'Select value' 드롭다운 클릭 G->>T: GET /api/search/tag/span.user.id/values?start=...&end=... Note over T: 지정된 시간 범위의 스토리지 블록을 열고<br>모든 고유 user.id 값을 집합(Set)으로 수집 T-->>G: 수십만 개의 user.id 문자열 배열 (17 MB!) Note over G: Grafana 응답 상한(16 MiB) 초과로 파싱 에러 발생 G-->>U: "response larger than the max" 에러 팝업
두 요청의 차이는 명확하다. 첫 번째 요청의 목적지는 ‘Trace 몇 개’ 이고, 두 번째 요청의 목적지는 ‘모든 user.id의 고유값 목록’ 이다.
고카디널리티(High Cardinality) 태그의 함정
카디널리티(Cardinality) 란 특정 속성(Attribute)이 가질 수 있는 고유한 값(Unique Value)의 가짓수 를 의미한다.
어떤 태그는 가질 수 있는 값의 종류가 몇 개 안 되지만, 어떤 태그는 요청이 발생할 때마다 새로운 값이 생겨난다.
| 태그 속성 | 고유 값 종류 수 | 카디널리티 수준 | 드롭다운 후보로 적합한가? | 이유 |
|---|---|---|---|---|
http.method | 5 ~ 10개 | 매우 낮음 | 매우 적합 | GET, POST, PUT 등 값 종류가 고정됨 |
http.status_code | 20 ~ 30개 | 낮음 | 매우 적합 | 200, 404, 500 등 규격화된 코드만 존재 |
resource.service.name | 수십 ~ 수백 개 | 낮음 | 적합 | 사내 마이크로서비스 개수에 비례함 |
name (endpoint) | 수백 ~ 수천 개 | 중간 | 대체로 적합 | URL 파라미터가 정규화되어 있다면 안정적임 |
span.user.id | 수만 ~ 수백만 개 | 매우 높음 | 부적합 | 사용자 수와 요청 수에 비례하여 무한히 증가 |
trace_id | 무한대 | 최고 | 부적합 | 모든 트랜잭션마다 유일한 새 값이 생성됨 |
http.method나 service.name 같은 저카디널리티 태그는 드롭다운으로 조회해도 고유 값이 몇 개 안 되므로 응답 크기가 수 KB에 불과하다.
하지만 user.id는 대표적인 고카디널리티(High Cardinality) 태그다. 활성 사용자가 많은 대규모 서비스에서는 단 1분 동안에도 수만에서 수십만 명의 서로 다른 유저가 API를 호출한다.
1분 동안에도 170 MB를 검사해야 했던 이유
Tempo는 수집된 trace 데이터를 일정 주기로 압축하여 Parquet 형식의 블록 단위로 오브젝트 스토리지에 보관한다.
실제 1분 범위로 span.user.id의 tag-values를 요청했을 때의 백엔드 지표를 확인해 보면 다음과 같았다:
- 1분 조회 시 스캔 데이터량 (
inspectedBytes):170,325,070 bytes(약 170 MB) - 1시간 조회 시 스캔 데이터량 (
inspectedBytes): 약 2.04 GB - 최종 생성된 JSON 응답 크기:
17,058,823 bytes(약 17.05 MB)
1분이라는 짧은 시간이라도, 해당 시간대의 블록에 기록된 수많은 span들을 풀어서 user.id 컬럼의 유니크 값을 집계해야 한다. 그 결과 중복을 제거하고 남은 수십만 개의 user ID 문자열 목록만으로도 JSON 본문이 17 MB에 달했던 것이다.
16 MiB 에러가 말해주는 핵심
화면에 발생한 오류는 Grafana 백엔드가 프록시 응답을 처리할 때 설정된 기본 버퍼 상한(16 MiB)을 넘었을 때 발생한다.
response larger than the max
17,058,823 bytes > 16,777,216 bytes이 오류를 보고 흔히 할 수 있는 두 가지 오해가 있다:
- “내가 검색한 trace 데이터가 너무 방대해서 터진 것이다?”
- 아니다. 앞서 살펴봤듯 trace 검색은 시작도 안 했다. 단지 드롭다운 박스 하나 채우려다 터진 것이다.
- “앞에서 이미 서비스 이름(
example-api)을 골랐으니, 그 서비스의 user.id만 가져오는 게 아닌가?”- Grafana Tempo 플러그인과 Tempo 백엔드 버전에 따라 차이가 있다.
- 구형 v1 tag-values API는 앞선 필터 조건(
q)을 고려하지 않고 해당 시간대의 태그 전체를 스캔하기도 한다. - 최신 v2 API는
q,limit,maxStaleValues같은 파라미터를 지원하지만, UI 환경에 따라 이 조건이 적절히 전달되지 않거나 전달되더라도 특정 서비스의 1분간 유저 수가 여전히 수만 단위를 넘을 수 있다.
즉, 이 에러는 시스템 결함이라기보다는 “UI 드롭다운으로 고카디널리티 태그 값을 나열하려는 접근 자체가 아키텍처적으로 무리” 라는 경고 신호다.
해결 방법: TraceQL Editor로 직접 질의하기
해결책은 간단하다. Search UI의 드롭다운을 거치지 않고, TraceQL Query Editor 모드로 전환하여 이미 알고 있는 user ID를 직접 쿼리식에 입력하는 것이다.
Grafana Explore 상단의 쿼리 타입을 Search 에서 TraceQL 로 전환하고 다음처럼 입력한다.
{
resource.service.name = "example-api" &&
name = "GET /v3/example" &&
span.user.id = "target-user-id"
}flowchart LR subgraph Bad["Search UI 드롭다운 사용 (위험)"] direction TB B1["user.id 선택"] --> B2["Select value 클릭"] B2 --> B3["/api/search/tag/.../values 호출"] B3 --> B4["수십만 개 ID 수집 (17 MB)"] B4 --> B5["16 MiB 상한 에러 💥"] end subgraph Good["TraceQL Editor 직접 입력 (권장)"] direction TB G1["TraceQL 탭 선택"] --> G2["span.user.id = 'ID' 직접 입력"] G2 --> G3["/api/search?q={...} 호출"] G3 --> G4["태그 탐색 건너뛰고 목표 Trace만 즉시 반환"] G4 --> G5["0.1초 만에 검색 완료 ✅"] end
TraceQL Editor를 사용하면 Select value를 누를 필요가 없으므로 위험한 tag-values discovery 요청을 아예 발생시키지 않는다. Tempo는 오직 해당 유저 ID를 가진 몇 개의 trace 블록만 색인하여 즉시 결과를 돌려준다.
상황별 올바른 쿼리 도구 선택 기준
| 작업 목적 | 추천 방식 | 이유 |
|---|---|---|
| 서비스 이름, 엔드포인트, HTTP 상태 코드 탐색 | Search UI Builder | 카디널리티가 낮아 드롭다운으로 손쉽게 클릭 선택 가능 |
| 지연 시간(Duration)이나 에러 유무로 필터링 | Search UI Builder | 슬라이더와 체크박스 UI가 직관적이고 가벼움 |
특정 user.id, order.id, ip 등으로 검색 | TraceQL Editor | 고카디널리티 태그이므로 드롭다운을 생략하고 직접 매칭해야 안전함 |
| 특정 유저의 전체 후보군 목록 자체가 필요할 때 | 별도 RDB / 메트릭 조회 | 분산 트레이싱 스토리지의 드롭다운은 목록 추출용 도구가 아님 |
이 탐구가 남긴 것
1. 시간 범위가 좁다고 무조건 비용이 작은 것은 아니다
검색 조건의 시간 범위를 1시간에서 1분으로 줄이는 것은 trace 본문을 찾는 데는 큰 도움이 된다. 하지만 반환해야 하는 데이터가 ‘고유 식별자 전체 목록’이라면, 초당 수천 건의 트래픽이 인입되는 대규모 시스템에서는 단 1분이라도 수만 개의 고유 값이 생성된다. 반환 대상이 개별 레코드 인지, 유니크 집합 인지에 따라 시스템 부하의 성격은 완전히 달라진다.
2. UI 편의성은 종종 비싼 백그라운드 비용을 숨긴다
Grafana의 드롭다운과 쿼리 빌더는 개발자 경험을 획기적으로 개선해 준다. 하지만 그 클릭 한 번 뒤에서 백엔드 블록 전체를 스캔하는 무거운 API가 동작하고 있을 수 있다. 관측성 도구를 다룰 때는 화면에 보이는 UI뿐만 아니라, 그 도구가 백엔드 스토리지에 어떤 쿼리를 던지고 있는지를 이해해야 시스템 병목을 피할 수 있다.
3. 상한값(Limit)을 무작정 늘리는 것은 해결책이 아니다
Grafana 설정에서 max response size를 16 MiB에서 32 MiB나 64 MiB로 늘리면 당장의 에러는 피할 수 있을지 모른다. 하지만 그것은 수백 MB의 JSON 데이터를 브라우저로 전송하여 브라우저를 멈추게 만들고, Tempo query-frontend 노드의 메모리를 고갈시키는 길이다. 고카디널리티 속성은 드롭다운 목록 조회가 아니라 정확한 값을 직접 질의(Direct Exact Match) 하는 방식으로 접근해야 한다.
앞으로의 방향
- 사내 모니터링 가이드에 Search UI와 TraceQL Editor의 사용 기준을 명시할 예정이다. 서비스나 엔드포인트 같은 공통 메타데이터는 Search UI로 쉽게 찾고,
user.id나 결제 번호 같은 트랜잭션 식별자는 TraceQL Editor에서 직접 입력하도록 안내한다. - Grafana Tempo 데이터소스 설정 및 최신 버전에서 tag-values API 호출 시 상위 필터(
q)가 정상적으로 전달되는지 브라우저 개발자 도구의 네트워크 탭과 Grafana Query Inspector를 통해 점검할 예정이다.
남은 질문
- 현재 사용 중인 Grafana Tempo datasource plugin은 tag-values 요청 시 이전 단계에서 선택한 필터 조건들을 쿼리 파라미터로 올바르게 포함하고 있는가?
- Tempo v2 API의
limit과maxStaleValues설정을 적용했을 때, 드롭다운 응답 크기와 백엔드 부하를 어느 수준까지 안정적으로 제어할 수 있는가? user.id가 span attribute뿐만 아니라 resource attribute에도 불필요하게 중복 인덱싱되고 있지는 않은가?