Tempo Search의 드롭다운에 숨겨진 동작 알아보기(feat. 고카디널리티 태그)

1분 범위의 trace를 조회했을 뿐인데 16 MiB 응답 제한 오류가 발생했다.

분산 시스템에서는 하나의 사용자 요청이 여러 마이크로서비스를 거치며 처리된다. 이 전체 요청 흐름을 추적해 기록한 것을 Trace(트레이스) 라 하고, 각 서비스나 함수 단위의 세부 작업 단위를 Span(스팬) 이라 부른다. 그리고 이러한 방대한 트레이스 데이터를 효율적으로 저장하고 검색할 수 있게 돕는 분산 추적 백엔드가 바로 Grafana Tempo 다.

어느 날 특정 사용자가 겪은 트랜잭션의 지연 원인을 추적하기 위해 Grafana Explore에서 Tempo를 열었다. 분산 추적 환경에서 가장 흔히 활용되는 도구는 복잡한 쿼리 문법을 직접 작성하지 않고 마우스 클릭 몇 번으로 조건을 조합할 수 있는 Search UI(쿼리 빌더) 다.

Pasted image 20260909174610

서비스 이름을 지정한 뒤 대상 사용자의 요청만 골라내기 위해 스팬의 세부 속성(Attribute) 필터에 span.user.id를 추가하고 값(value) 입력란을 선택했다. 하지만 선택 가능한 유저 목록이 나타나는 대신 우측에서 로딩 상태가 지속되었다.

user.id 태그 값 입력란에서 멈추지 않는 로딩 스피너

수 초간의 대기 끝에 드롭다운이 열리지 않고 다음 오류 메시지가 반환되었다.

Grafana에 발생한 16 MiB 초과 에러 알림

response larger than the max (17322487 vs 16777216)

백엔드 응답 크기가 약 17.32 MB(17,322,487 bytes)에 도달하여, Grafana 프록시 계층이 허용하는 기본 응답 크기 상한선인 16 MiB(16,777,216 bytes)를 초과했다는 의미다. 분산 시스템 환경에서 프록시나 gRPC 통신 계층은 거대한 단일 페이로드로 인해 서버 메모리가 고갈되는 것을 막기 위해 이처럼 기본 수신 제한(Default Buffer Limit)을 둔다.

본격적인 검색 실행 버튼(Run Query)을 누르지도 않은 상태에서, 단지 태그 필터의 입력창을 열었을 뿐인데 대용량 응답이 발생한 것이다. 이 오류의 원인은 trace 본문 검색이 아니라, UI가 드롭다운 자동완성을 채우기 위해 실행한 태그 값 탐색(tag-value discovery) 요청에 있었다.


가설 설정과 검증의 실패

가장 먼저 검토한 가설은 “조회 시간 범위가 너무 넓어서 발생한 일”이라는 생각이었다.

기본 설정된 시간 범위가 최근 1시간이었기 때문에, 구간 내 누적된 데이터양이 임계치를 넘었을 것이라 판단했다. 따라서 시간 범위를 최근 1분 으로 대폭 축소했다. 구간을 60분의 1로 줄이면 데이터양과 응답 페이로드 역시 비례하여 크게 줄어들어, 16 MiB 제한 아래로 안전하게 떨어질 것이라 기대했다.

실제로 스토리지 스캔량 자체는 줄어들었다. 뒤에서 확인한 지표에 따르면 1시간 동안의 블록 스캔량은 약 2.04 GB였던 반면, 1분 구간의 스캔량은 약 170 MB로 크게 감소했다. 만약 1시간 구간의 고유 유저 ID를 모두 모았다면 최종 응답은 17 MB를 아득히 넘어 수백 MB에 달했을 것이다.

그러나 문제는 단 1분 동안 발생한 고유 유저 수만으로도 이미 16 MiB 상한선을 넘기에 충분했다는 점이다.

  • 조회 시간 범위: 최근 1시간 → 최근 1분 으로 축소
  • 스토리지 스캔량: 약 2.04 GB → 약 170 MB 로 크게 감소
  • 최종 응답 크기: 여전히 약 17.32 MB → 동일한 16 MiB 상한 초과 오류 재현

시간 구간을 극단적으로 좁혀 스캔량을 줄였음에도 불구하고, 고카디널리티 필드는 단 1분의 찰나에도 수십만 개의 고유값을 만들어내며 UI의 한계선(16 MiB)을 가볍게 뚫어버린 것이다.


Search UI가 발행하는 두 가지 HTTP 요청

네트워크 트래픽과 Tempo 백엔드 로그를 분석한 결과, Grafana Search UI는 단일 검색 인터페이스 뒤에서 완전히 다른 목적을 가진 두 종류의 개별 API 를 호출하고 있었다.

flowchart TD
    subgraph UI["Grafana Search UI"]
        A["태그 값 입력란 포커스<br>(드롭다운 자동완성)"]
        B["Run Query 버튼 클릭<br>(최종 검색 실행)"]
    end

    subgraph API["Tempo HTTP API"]
        C["GET /api/search/tag/.../values<br>(태그의 고유 값 목록 탐색)"]
        D["GET /api/search?q=...<br>(조건 일치 Trace 본문 검색)"]
    end

    A -->|"자동완성 후보 질의"| C
    B -->|"TraceQL 질의"| D
    C -.->|"수십만 개 고유 ID 집합 (17.32 MB)"| UI
    D -.->|"조건 일치 Trace 5건 메타데이터 (수십 KB)"| UI

1. 조건 일치 Trace 검색 (/api/search)

엔지니어가 의도한 본래 작업은 특정 조건에 부합하는 trace 레코드를 찾는 것이다. 서비스 이름과 유저 ID를 입력하고 조회를 실행하면, Grafana는 Tempo 전용 검색 언어인 TraceQL 조건식을 생성하여 백엔드에 질의한다. 마치 프로메테우스의 PromQL이나 로키의 LogQL처럼, 트레이스를 정밀하게 필터링하기 위한 쿼리 문법이다.

{
  resource.service.name = "example-api" &&
  name = "GET /v3/example" &&
  span.user.id = "target-user-id"
}

이 요청은 Tempo의 /api/search 엔드포인트로 전송된다.

sequenceDiagram
    participant U as 엔지니어
    participant G as Grafana Search UI
    participant T as Tempo Backend

    U->>G: 서비스, 엔드포인트, user.id 입력 후 Run Query
    G->>T: GET /api/search?q={TraceQL}&limit=5
    Note over T: 인덱스를 참조하여 조건에 일치하는 Trace 5건 추출
    T-->>G: 5건의 Trace 메타데이터 반환 (수십 KB)
    G-->>U: Trace 결과 목록 렌더링

이 요청은 limit=5 또는 limit=20과 같이 반환 레코드 수가 엄격히 제한되며, 이미 명시된 특정 ID 하나만을 인덱스에서 검색한다. 스토리지 스캔 범위가 좁고 반환 크기 역시 수십 KB 수준으로 매우 가볍다.

2. 태그 고유값 목록 탐색 (/api/search/tag/{tag_name}/values)

반면 값 입력란을 클릭하는 순간 브라우저가 Tempo로 전송한 요청은 trace 본문 조회가 아니었다.

GET /api/search/tag/span.user.id/values?start=1725410000&end=1725410060

이 엔드포인트는 trace 본문을 반환하지 않는다. 지정된 시간 구간 내에 존재하는 모든 고유한(Unique) user.id 문자열 값을 추출하여 JSON 배열로 반환 하는 메타데이터 탐색 질의다.

sequenceDiagram
    participant U as 엔지니어
    participant G as Grafana Search UI
    participant T as Tempo Backend

    U->>G: span.user.id 값 입력란 클릭
    G->>T: GET /api/search/tag/span.user.id/values?start=...&end=...
    Note over T: 1분간 저장된 스토리지 블록 전체를 열어<br>모든 고유 user.id를 메모리 Set으로 집계
    T-->>G: 수십만 개 고유 ID 문자열이 담긴 JSON 반환 (17.32 MB)
    Note over G: 프록시 버퍼 상한(16 MiB) 초과로 응답 차단
    G-->>U: "response larger than the max" 에러 토스트 출력

두 요청은 목적과 비용 구조가 근본적으로 다르다:

  • Trace 검색 (/api/search): 이미 알고 있는 키를 기준으로 소수의 레코드를 조회하는 작업 (Index Lookup)
  • 태그 값 탐색 (tag/.../values): 컬럼 내에 출현한 모든 고유값의 전체 집합을 구성하는 작업 (Full Column Scan & Distinct Aggregation)

왜 드롭다운 자동완성은 ‘중복 없는 전체 목록’을 요구하는가

여기서 한 가지 의문이 생길 수 있다. “왜 백엔드는 일부만 적당히 가져오지 않고, 굳이 중복 없이 모든 유저 ID를 끝까지 찾아 가져와야 하는가?”

그 이유는 드롭다운(Dropdown) UI의 동작 방식과 분산 추적 스토리지의 구조적 한계에 기인한다.

  1. 드롭다운 컴포넌트의 완전성(Completeness) 전제

    • 드롭다운은 사용자에게 “선택 가능한 모든 유효한 옵션”을 제공하기 위한 인터페이스다. 만약 백엔드가 임의로 앞선 100개만 반환한다면, 사용자가 찾고자 하는 특정 유저 ID가 목록에서 누락되어 드롭다운에서 선택하는 것 자체가 불가능해진다.
    • 또한 사용자가 드롭다운 입력창에 검색어를 타이핑할 때 브라우저가 실시간 필터링을 수행하려면, 프론트엔드는 해당 시간 구간에 출현한 모든 고유값 배열을 메모리에 온전히 확보하고 있어야 한다.
  2. 분산 추적 시스템에는 ‘유저 마스터 테이블’이 존재하지 않는다

    • 관계형 데이터베이스(RDB)라면 정규화된 users 테이블에서 고유한 유저 목록을 가볍게 질의할 수 있다.
    • 하지만 Tempo와 같은 분산 추적 스토리지는 유저 목록을 보관하는 별도의 메타데이터 마스터 테이블을 두지 않는다. 유저 ID는 오직 초당 수만 건씩 인입되는 개별 span의 속성(Attribute) 데이터로만 파편화되어 기록된다.
    • 따라서 “이 시간 구간에 어떤 유저 ID들이 존재하는가?”라는 질문에 답하기 위해서는, 백엔드가 해당 구간에 저장된 모든 스토리지 블록(Parquet)을 열어 수백만 건의 span 레코드를 전수 검사하고, user.id 컬럼의 중복을 제거(Distinct)하여 고유 집합(Set)을 직접 산출해 내는 방법밖에 없다.

결국 “드롭다운에 후보 목록을 보여준다”는 단순한 UI 인터랙션 하나를 처리하기 위해, 백엔드는 스토리지 전체를 뒤져 중복 없는 유저 전체 명부를 만드는 대규모 집계 연산을 강제당하는 셈이다.


개념적 대조: 레코드 검색과 전체 고유값 추출

두 작업의 시스템적 부하 차이는 대규모 도서관의 장부 처리에 비유할 수 있다.

  • Trace 검색 (특정 대출 기록 조회)
    • “대출자 식별자가 chulsoo인 최근 대출 기록 5건을 찾아달라.”
    • 색인된 장부에서 해당 이름이 적힌 카드 5장을 확인하는 즉시 탐색이 완료된다. 작업량이 적고 소요 시간이 균일하다.
  • 태그 값 자동완성 (방문자 전체 명부 생성)
    • “최근 1분간 대출을 실행한 모든 방문자의 고유 이름 목록 을 빠짐없이 정리해 달라.”
    • 대출 기록의 건수와 무관하게 해당 구간의 모든 대출 전표를 전수 검사해야 하며, 중복을 제거한 전체 명단을 새로 구성해야 한다.

Grafana Search UI의 값 입력란을 누르는 행위는 검색이 아니라 후자에 해당하는 ‘전체 고유 식별자 명부 작성’ 에 해당한다.


고카디널리티(High Cardinality) 태그의 비용 구조

카디널리티(Cardinality)란 특정 데이터 필드가 가질 수 있는 고유한 값(Unique Value)의 상대적 가짓수 를 의미한다.

필드의 특성에 따라 고유값의 범위는 고정되어 있기도 하고, 트래픽에 비례하여 무제한으로 팽창하기도 한다.

태그 속성고유 값 종류 수카디널리티 수준드롭다운 자동완성 적합성필드 특성
http.method5 ~ 10개매우 낮음적합GET, POST 등 사양으로 정의된 고정 집합
http.status_code20 ~ 30개낮음적합표준화된 HTTP 상태 코드 집합
resource.service.name수십 ~ 수백 개낮음적합배포된 마이크로서비스 수에 비례
name (endpoint)수백 ~ 수천 개중간대체로 적합URL 파라미터가 정규화된 경로 집합
span.user.id수만 ~ 수백만 개매우 높음부적합유저 수와 전체 트랜잭션 규모에 비례하여 팽창
trace_id무한대최고부적합모든 트랜잭션마다 유일하게 발급되는 난수

http.methodservice.name과 같은 저카디널리티 필드는 전체 고유값을 집계해도 수십 개 수준에 머물기 때문에 응답 페이로드가 수 KB를 넘지 않는다.

반면 user.id는 대표적인 고카디널리티(High Cardinality) 필드다. 트래픽 밀도가 높은 분산 환경에서는 단 1분 동안에도 수만에서 수십만 명의 고유 사용자가 API를 호출한다.

1분 구간의 스토리지 스캔 규모

Tempo는 수집된 수많은 trace 데이터를 효율적으로 보관하기 위해, 빅데이터 분석에서 널리 쓰이는 컬럼 기반(Columnar) 저장 포맷인 Parquet(파케이) 형식의 블록 단위로 압축하여 S3 같은 오브젝트 스토리지에 보관한다. 컬럼 기반 포맷은 특정 속성(컬럼)만 골라 빠르게 읽는 데는 유리하지만, 특정 시간 구간의 고유값을 집계하려면 해당 구간의 블록 전체를 열어 확인해야 한다.

실제 1분 구간에서 span.user.id의 tag-values를 요청했을 때의 백엔드 측정 지표는 다음과 같다:

  • 1분 조회 시 블록 스캔 데이터량 (inspectedBytes): 170,325,070 bytes (약 170 MB)
  • 1시간 조회 시 블록 스캔 데이터량 (inspectedBytes): 약 2.04 GB
  • 중복 제거 후 생성된 JSON 페이로드 크기: 17,322,487 bytes (약 17.32 MB)

1분이라는 짧은 구간이라 해도, 백엔드는 약 170 MB 분량의 Parquet 블록을 읽어 user.id 컬럼을 메모리에 역직렬화한 뒤 Distinct 연산을 수행해야 했다. 중복을 제거한 순수 ID 문자열 배열만으로도 JSON 페이로드가 17 MB를 초과한 것이다.


16 MiB 제한 오류의 진짜 의미

이 오류는 단순한 시스템 버그가 아니라 프록시와 클라이언트를 보호하기 위한 정상적인 방어 기제다.

response larger than the max (17322487 vs 16777216)

이 오류와 관련하여 흔히 발생하는 두 가지 오해를 짚어볼 필요가 있다:

  1. “조회 대상 trace 데이터의 본문이 과다하여 발생했다?”
    • 앞서 확인했듯 trace 본문 검색은 시작되지도 않았다. 단지 자동완성 입력창의 후보 목록을 가져오는 과정에서 발생한 부하이다.
  2. “이전에 상위 조건(예: service.name)을 지정했으므로 해당 범위의 user.id만 필터링되지 않는가?”
    • Grafana Tempo 플러그인과 백엔드 버전에 따라 차이가 있다.
    • 구형 v1 tag-values API는 선행 필터 조건(q)을 고려하지 않고 시간 구간 내 전체 블록을 스캔한다.
    • 최신 v2 API가 쿼리 스코프(q)를 지원하더라도, 트래픽이 집중된 핵심 서비스는 단 1분간의 고유 사용자 수만으로도 수만 단위를 상회할 수 있다.

결국 이 오류가 진짜 말해주는 핵심은 “수십만 개의 고유값을 지닌 고카디널리티 필드를 UI 드롭다운 자동완성으로 나열하려는 접근 방식 자체가 아키텍처적으로 성립할 수 없다” 는 경고 신호라는 점이다.


해결책: TraceQL Editor 모드를 통한 직접 질의

해결 방법은 명확하다. Search UI의 드롭다운 자동완성을 우회하고, TraceQL Query Editor 로 전환하여 사전에 확인된 대상 식별자를 쿼리식에 직접 기입하는 것이다.

Grafana Explore 상단의 Query type을 Search 에서 TraceQL 로 변경한다. 이 모드에서는 자동완성 목록을 호출하지 않는 단일 쿼리 에디터가 제공된다.

Pasted image 20260909174620

에디터에 대상 식별자를 포함한 조건식을 직접 작성하여 질의한다.

{
  resource.service.name = "example-api" &&
  name = "GET /v3/example" &&
  span.user.id = "6a2f73541314c90055dd5d87"
}
flowchart LR
    subgraph Bad["Search UI 드롭다운 자동완성 (위험)"]
        direction TB
        B1["span.user.id 추가"] --> B2["값 입력란 포커스"]
        B2 --> B3["백그라운드 tag-values 호출"]
        B3 --> B4["1분간 수십만 개 고유 ID 수집 (17.32 MB)"]
        B4 --> B5["16 MiB 프록시 상한 초과 오류 발생"]
    end

    subgraph Good["TraceQL Editor 직접 질의 (권장)"]
        direction TB
        G1["TraceQL 탭 전환"] --> G2["span.user.id = 'ID' 직접 기입"]
        G2 --> G3["TraceQL 질의 실행"]
        G3 --> G4["태그 값 탐색 우회, 목표 Trace만 인덱스 스캔"]
        G4 --> G5["수십 밀리초 내 정상 반환"]
    end

TraceQL Editor 모드에서는 값 입력란의 후보 완성을 위한 tag-values API가 호출되지 않는다. Tempo 백엔드는 오직 지정된 유저 ID와 일치하는 소수의 span만을 인덱스에서 검색하므로 수십 밀리초(ms) 내에 결과를 반환한다.

목적별 쿼리 인터페이스 선택 기준

분석 목적권장 도구선택 사유
서비스 이름, HTTP 메서드, 상태 코드 탐색Search UI (쿼리 빌더)카디널리티가 낮아 드롭다운 목록 조회가 안전하고 직관적임
요청 처리 시간(Duration) 및 에러 유무 필터링Search UI (쿼리 빌더)슬라이더와 체크박스를 통한 정량 조건 설정에 적합함
특정 user.id, order.id, ip 기반 조회TraceQL Editor고카디널리티 필드이므로 자동완성을 배제하고 직접 일치 질의를 수행해야 함
특정 시간대 전체 고유 유저 목록 추출별도 RDB / 분석 메트릭분산 트레이싱 스토리지는 고유값 집계용 분석 DB로 설계되지 않음

딥다이브를 마치며 내린 세 가지 결론

1분 범위에서 17 MB가 넘는 응답이 터져 16 MiB 제한에 걸린 현상은 겉으로 드러난 하나의 증상일 뿐이다. 이 문제를 추적하며 내린 본질적인 결론은 다음 세 가지다.

1. Search UI를 사용할 때 Tempo가 뒤에서 무엇을 하는지 이해하게 되었다

겉보기에는 그저 검색창의 드롭다운 하나를 클릭했을 뿐이지만, Tempo 백엔드는 내부적으로 해당 시간대의 모든 트레이스 블록을 전수 조사하여 그 안의 user_id를 하나하나 추출해 드롭다운에 채워 넣는 로직을 수행하고 있었다. 쿼리 빌더가 제공하는 단순한 입력창 이면에 스토리지 전체를 훑는 집계 연산이 숨어 있다는 내부 메커니즘을 정확히 파악할 수 있었다.

2. 시간대를 줄이는 것 자체가 핵심 해결책이 아니다

조회 시간대를 1시간에서 1분으로 줄이면 당연히 스토리지 스캔량 자체는 줄어든다. 하지만 본질적인 문제는 조회 시간의 길이가 아니라, 다루려는 속성(span attribute)의 카디널리티(고유값의 가짓수) 에 있다. user.id처럼 카디널리티가 매우 높은 속성은 시간대를 아무리 줄여도 드롭다운 방식(Search 탭)으로 다루기에 적합하지 않으며, 특정 ID를 직접 기입하여 검색하는 TraceQL 탭을 사용하는 것이 올바른 접근이다.

3. 용량 제한(16 MiB)을 늘리는 것은 근본적인 해결책이 아니다

드롭다운을 반드시 써야 해서 불편하다는 이유로 Grafana 설정의 응답 상한선을 늘려줄 수도 있겠지만, 이는 문제를 뒤로 미루는 임시방편에 불과하다. 카디널리티 요소를 고려했을 때, span attribute의 카디널리티가 높다면 TraceQL Search 탭의 드롭다운 인터페이스로 처리하는 것 자체가 구조적으로 적합하지 않다. 상한선을 풀어봤자 브라우저 프리징과 백엔드 쿼리 노드의 메모리 고갈만 초래하므로, 태그의 특성에 맞게 검색 도구를 올바르게 분리하여 사용해야 한다.


실무 적용 및 향후 확인 과제

  • 사내 관측성 활용 가이드 정립: 공통 메타데이터는 Search UI로 탐색하되, user.id나 주문 번호 등 고카디널리티 식별자는 TraceQL Editor에서 직접 입력하도록 질의 가이드라인을 문서화한다.
  • Grafana DataSource 플러그인 동작 검증: 사내에 구성된 Grafana 및 Tempo 플러그인 버전에서 tag-values 질의 시 선행 쿼리 스코프(q) 파라미터가 백엔드로 올바르게 전달되고 있는지 네트워크 트래픽을 검증한다.
  • 속성 인덱싱 아키텍처 점검: 시스템 전반에서 user.id가 span attribute 외에 resource attribute로도 중복 인덱싱되어 스토리지 블록 크기와 메타데이터 스캔 오버헤드를 가중시키고 있지 않은지 점검한다.

참고 자료