지표 카디널리티 정리: 시계열 폭발을 찾고 줄이는 법

어느 날 지표 저장소가 메모리 부족으로 죽습니다. SaaS를 쓰는 팀이라면 서버 대신 이번 달 청구서가 뛰어오르고요. 트래픽이 늘지도 않았고 새 서비스를 붙이지도 않았어요. 범인을 찾아보니 지난주 배포에 들어간 지표 한 줄입니다. 요청 경로를 레이블(label)로 붙였을 뿐인데요. 😅
관측성 정리와 OpenTelemetry 지표 정리에서 지표에 사용자 ID 같은 값을 넣지 말라고 했는데요. 실제 사고는 그렇게 대놓고 나지 않습니다. 대부분은 “이건 값이 몇 개 안 되겠지” 하고 붙인 레이블이 생각보다 많은 값을 가지면서 터지죠. 이 글에서는 카디널리티(cardinality)가 지표 저장소에서 정확히 무엇을 늘리는지 Prometheus로 직접 터뜨려 보고, 범인을 찾는 법과 줄이고 막는 법, 그리고 다른 도구에서는 같은 문제가 어떤 모양으로 터지는지까지 정리해 보겠습니다.
시계열 하나는 레이블 조합 하나
지표 저장소에서 시계열(time series)은 지표 이름과 레이블 조합으로 식별됩니다. 이름이 같아도 레이블 값이 하나라도 다르면 다른 시계열이에요. 레이블을 태그(tag)나 차원(dimension)이라고 부르는 도구도 있지만 원리는 같습니다. 그래서 지표 하나가 만드는 시계열 수는 레이블마다 가질 수 있는 값의 개수를 곱한 만큼이 됩니다.
HTTP 요청 처리 시간을 재는 히스토그램을 예로 들어 볼게요. method가 4종, path가 20종, status가 5종이면 조합은 400개입니다. 여기서 끝이 아니에요. 히스토그램은 버킷(bucket)마다 시계열을 따로 만들고 _sum과 _count도 따로 둡니다. 버킷이 +Inf까지 12개라면 조합 하나당 14개, 전체로는 5,600개가 되죠.
곱셈이라는 점이 핵심입니다. 레이블 하나를 더하는 건 시계열 수에 1을 더하는 게 아니라 그 레이블의 값 개수를 곱하는 일이에요. 값이 10개뿐인 레이블도 기존 시계열을 열 배로 만듭니다.
직접 터뜨려 보기
말로만 하면 감이 안 오니 직접 만들어 보겠습니다. 실습에는 오픈소스 지표 저장소로 가장 널리 쓰이는 Prometheus를 쓸게요. Bun으로 주문 조회 서버를 띄우고 prom-client로 지표를 노출할게요.
bun add prom-client
요청마다 처리 시간을 재면서 경로를 url.pathname 그대로 레이블에 넣습니다. 처음 지표를 붙일 때 가장 흔히 쓰는 코드예요.
import { Histogram, register } from "prom-client";
const duration = new Histogram({
name: "http_request_duration_seconds",
help: "HTTP 요청 처리 시간",
labelNames: ["method", "path", "status"],
});
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/metrics") {
return new Response(await register.metrics(), {
headers: { "Content-Type": register.contentType },
});
}
const end = duration.startTimer();
const status = url.pathname.startsWith("/orders/") ? 200 : 404;
end({ method: req.method, path: url.pathname, status });
return new Response("ok", { status });
},
});
Bun.serve는 요청마다 fetch 함수를 부르니 여기서 시간을 재면 됩니다. 이제 주문 500건을 하나씩 조회해 봅니다.
for (let id = 1; id <= 500; id++) {
await fetch(`http://localhost:3000/orders/${id}`);
}
bun run server.ts &
bun run load.ts
curl -s localhost:3000/metrics | grep -c '^http_request_duration_seconds'
7000
요청 500번에 시계열이 7,000개입니다. 주문 하나에 무엇이 생겼는지 보면 이유가 보여요.
curl -s localhost:3000/metrics | grep 'orders/42"'
http_request_duration_seconds_bucket{le="0.005",method="GET",path="/orders/42",status="200"} 1
http_request_duration_seconds_bucket{le="0.01",method="GET",path="/orders/42",status="200"} 1
http_request_duration_seconds_bucket{le="0.025",method="GET",path="/orders/42",status="200"} 1
http_request_duration_seconds_bucket{le="0.05",method="GET",path="/orders/42",status="200"} 1
http_request_duration_seconds_bucket{le="0.1",method="GET",path="/orders/42",status="200"} 1
http_request_duration_seconds_bucket{le="0.25",method="GET",path="/orders/42",status="200"} 1
http_request_duration_seconds_bucket{le="0.5",method="GET",path="/orders/42",status="200"} 1
http_request_duration_seconds_bucket{le="1",method="GET",path="/orders/42",status="200"} 1
http_request_duration_seconds_bucket{le="2.5",method="GET",path="/orders/42",status="200"} 1
http_request_duration_seconds_bucket{le="5",method="GET",path="/orders/42",status="200"} 1
http_request_duration_seconds_bucket{le="10",method="GET",path="/orders/42",status="200"} 1
http_request_duration_seconds_bucket{le="+Inf",method="GET",path="/orders/42",status="200"} 1
http_request_duration_seconds_sum{method="GET",path="/orders/42",status="200"} 1.25e-7
http_request_duration_seconds_count{method="GET",path="/orders/42",status="200"} 1
주문 번호 하나가 시계열 14개를 만들었고 주문이 500건이니 7,000개입니다. 주문이 하루에 10만 건 들어오는 서비스라면 하루 만에 140만 개가 되겠죠. 각 시계열에는 값 1이 한 번 찍히고 끝이라 쓸모도 거의 없습니다. 💥
Prometheus는 왜 이걸 버거워할까
이 서버를 Prometheus로 긁어 가 보겠습니다. 설정은 최소한으로 둡니다.
global:
scrape_interval: 5s
scrape_configs:
- job_name: orders
static_configs:
- targets: ["localhost:3000"]
Prometheus는 최근 두세 시간의 데이터를 헤드 블록(head block)이라는 메모리 영역에 둡니다. 새로 들어온 샘플(sample)을 디스크에 블록으로 굳히기 전까지 담아 두는 곳인데, 시계열마다 레이블 세트와 역색인과 압축 중인 청크(chunk)를 따로 들고 있어요. 그래서 메모리 사용량을 좌우하는 건 샘플 개수보다 시계열 개수입니다. 시계열 하나에 샘플이 만 개 쌓이는 것보다 샘플 하나짜리 시계열 만 개가 훨씬 비쌉니다.
여기에 한 가지가 더 붙습니다. 시계열 이탈(churn)이에요. 서버를 재시작하고 이번에는 501번부터 1,000번까지 다른 주문을 조회해 봤습니다. 지금 서버가 노출하는 시계열과 Prometheus 헤드에 남은 시계열을 비교하면 이렇습니다.
# 현재 살아 있는 시계열: count({__name__=~".+"})
7005
# 헤드 블록의 시계열: /api/v1/status/tsdb의 headStats.numSeries
14005
5개는 Prometheus가 수집 대상마다 자동으로 만드는 up, scrape_duration_seconds 같은 자동 지표입니다. 눈여겨볼 건 두 숫자의 차이예요. 더는 값이 들어오지 않는 앞선 7,000개가 헤드에서 빠지지 않고 그대로 메모리를 차지하고 있습니다. 헤드가 디스크 블록으로 압축되기 전까지는 사라지지 않거든요. 헤드 블록은 Prometheus의 구조지만, 최근 데이터를 메모리에 두고 시계열마다 색인을 유지하는 방식은 시계열 데이터베이스 대부분이 비슷합니다.
이탈은 주문 번호 같은 값에서만 생기지 않습니다. 쿠버네티스에서 배포할 때마다 파드 이름이 바뀌면 pod 레이블이 붙은 모든 시계열이 통째로 새로 생겨요. 배포를 하루에 여러 번 하는 팀이라면 시계열 수 자체는 일정해 보여도 헤드는 계속 부풀어 있습니다.
범인 찾기
시계열이 늘었다는 걸 알았으면 어느 지표의 어느 레이블이 원인인지 찾아야 합니다. Prometheus라면 가장 빠른 길은 TSDB 상태 API예요. Prometheus 웹 UI의 Status > TSDB status 메뉴도 같은 데이터를 보여 줍니다.
curl -s 'localhost:9090/api/v1/status/tsdb?limit=3'
{
"headStats": {
"numSeries": 7005,
"numLabelPairs": 524,
"chunkCount": 7005
},
"seriesCountByMetricName": [
{ "name": "http_request_duration_seconds_bucket", "value": 6000 },
{ "name": "http_request_duration_seconds_sum", "value": 500 },
{ "name": "http_request_duration_seconds_count", "value": 500 }
],
"labelValueCountByLabelName": [
{ "name": "path", "value": 500 },
{ "name": "le", "value": 12 },
{ "name": "__name__", "value": 8 }
]
}
읽는 순서는 이렇습니다. 먼저 seriesCountByMetricName에서 시계열을 가장 많이 만든 지표를 찾고, labelValueCountByLabelName에서 값이 가장 많은 레이블을 찾습니다. 여기서는 path가 값 500개로 압도적이고 le 12개는 히스토그램 버킷이에요. 범인이 바로 나옵니다.
같은 일을 PromQL로도 할 수 있습니다. 지표 이름별 시계열 수를 세는 쿼리는 다음과 같아요.
topk(3, count by (__name__) ({__name__=~".+"}))
{__name__="http_request_duration_seconds_bucket"} 6000
{__name__="http_request_duration_seconds_sum"} 500
{__name__="http_request_duration_seconds_count"} 500
의심 가는 레이블의 값 개수는 그 레이블로 묶은 뒤 다시 세면 됩니다.
count(count by (path) (http_request_duration_seconds_count))
500
모든 시계열을 훑는 쿼리라 시계열이 수백만 개인 서버에서는 그 자체로 무겁습니다. 큰 서버에서는 TSDB 상태 API를 먼저 보고 PromQL은 범위를 좁힌 뒤에 쓰는 편이 안전해요. 그리고 범인을 찾은 다음에는 시계열 수가 다시 치솟을 때 미리 알 수 있도록 prometheus_tsdb_head_series에 알림을 걸어 두면 좋습니다.
원천에서 줄이기: 값 대신 패턴
가장 확실한 해결책은 지표를 만드는 코드에서 레이블 값을 유한하게 만드는 겁니다. 저장소가 무엇이든 똑같이 통하는 방법이에요. /orders/42 대신 /orders/:id라는 라우트 패턴을 레이블로 쓰면 주문이 몇 건이든 값은 하나예요.
import { Histogram, register } from "prom-client";
const duration = new Histogram({
name: "http_request_duration_seconds",
help: "HTTP 요청 처리 시간",
labelNames: ["method", "route", "status"],
});
// 실제 경로 대신 라우트 패턴을 레이블로 쓴다
function measured(route: string, handler: (req: Request) => Response) {
return (req: Request) => {
const end = duration.startTimer();
const res = handler(req);
end({ method: req.method, route, status: res.status });
return res;
};
}
Bun.serve({
port: 3000,
routes: {
"/metrics": async () =>
new Response(await register.metrics(), {
headers: { "Content-Type": register.contentType },
}),
"/orders/:id": measured("/orders/:id", () => new Response("ok")),
},
fetch: measured("unmatched", () => new Response("not found", { status: 404 })),
});
routes에 등록한 패턴 문자열을 그대로 레이블 값으로 넘깁니다. 레이블 이름도 path에서 route로 바꿨는데, 실제 경로가 아니라 패턴이 들어간다는 걸 이름으로 드러내기 위해서예요. OpenTelemetry 시맨틱 컨벤션에서 url.path와 http.route를 구분하는 것과 같은 이유입니다. 이 구분은 시맨틱 컨벤션 정리에서 자세히 다뤘어요.
fetch에 둔 기본 처리기도 중요합니다. 어느 라우트에도 걸리지 않은 요청은 모두 unmatched 하나로 셉니다. 이걸 빠뜨리면 404 경로가 원래 경로 그대로 레이블에 들어가는데, 인터넷에 열린 서버라면 /wp-admin/...이나 /.env 같은 스캐너 요청이 끝없이 들어옵니다. 실제 사고는 이쪽에서 더 자주 나요.
이번에는 주문 500건에 무작위 경로 50건을 섞어서 조회해 봤습니다.
http_request_duration_seconds_count{method="GET",route="/orders/:id",status="200"} 500
http_request_duration_seconds_count{method="GET",route="unmatched",status="404"} 50
시계열은 7,000개에서 28개로 줄었습니다. 조합 2개에 14개씩이에요. 이제 남은 곱셈 인자는 버킷입니다. 응답 시간이 대부분 1초 안에 끝나는 서비스라면 2.5초, 5초, 10초 버킷은 거의 쓰이지 않아요. 실제 분포에 맞춰 버킷을 다섯 개로 줄여 봤습니다.
const duration = new Histogram({
name: "http_request_duration_seconds",
help: "HTTP 요청 처리 시간",
labelNames: ["method", "route", "status"],
buckets: [0.05, 0.1, 0.25, 0.5, 1],
});
조합 하나당 +Inf를 포함한 버킷 6개와 _sum, _count로 8개가 되어 전체는 16개입니다. 히스토그램은 레이블 조합마다 버킷 수를 곱하니, 레이블이 많은 지표일수록 버킷을 줄였을 때 효과가 큽니다.
레이블 값 중에서 흔히 문제가 되는 것들을 모아 보면 패턴이 보입니다. 사용자 ID, 주문 번호, 요청 ID처럼 식별자인 값이 첫째고, 쿼리 문자열이 붙은 URL과 User-Agent 문자열, 오류 메시지 원문처럼 자유 형식 텍스트가 둘째예요. 셋째는 파드 이름이나 컨테이너 ID처럼 배포 때마다 바뀌는 값입니다. 어느 쪽이든 “이 레이블이 가질 수 있는 값을 미리 다 적을 수 있나?”라고 물어보면 걸러집니다.
재레이블링은 집계가 아니다
코드를 바로 고칠 수 없을 때도 있습니다. 남이 만든 익스포터(exporter)라거나 배포 주기가 길다거나요. 그럴 때 떠올리는 게 Prometheus의 metric_relabel_configs입니다. 수집한 시계열을 저장하기 전에 레이블을 고치거나 지울 수 있는 재레이블링(relabeling) 기능이죠. 처음 코드로 돌아가서, 정규식으로 주문 번호를 :id로 바꿔 봤습니다.
scrape_configs:
- job_name: orders
static_configs:
- targets: ["localhost:3000"]
metric_relabel_configs:
- source_labels: [path]
regex: "/orders/[0-9]+"
target_label: path
replacement: "/orders/:id"
수집 대상은 정상(up 1)이고 오류도 없습니다. 그런데 요청 수를 조회하면 이상한 값이 나와요.
http_request_duration_seconds_count{instance="localhost:3000",job="orders",method="GET",path="/orders/:id",status="200"} 1
요청은 500번이었는데 1입니다. 재레이블링은 시계열을 하나씩 고칠 뿐 같은 레이블이 된 시계열끼리 더해 주지 않아요. 레이블을 바꾼 결과 시계열 500개가 하나로 겹쳤고, 그중 하나의 값만 남고 나머지는 버려졌습니다. 공식 문서도 labeldrop과 labelkeep을 쓸 때 레이블을 지운 뒤에도 시계열이 서로 구별되는지 확인하라고 경고합니다.
그래서 재레이블링은 지표나 레이블을 통째로 버릴 때는 안전하지만 값을 합치는 용도로는 쓸 수 없습니다. 쓸모없는 지표를 action: drop으로 버리거나, 모든 시계열에서 값이 같아 정보가 없는 레이블을 지우는 데 씁니다. 값을 합쳐야 한다면 코드에서 고치거나, 애플리케이션과 Prometheus 사이에 OpenTelemetry 수집기를 두고 거기서 집계하는 방법이 있어요. SDK 단계에서 속성을 걸러내는 뷰(view)도 같은 역할을 하는데, 이건 OpenTelemetry 지표 정리에서 다뤘습니다.
도구가 바뀌어도 이 교훈은 그대로입니다. 레이블을 지우거나 바꾸는 것과 값을 합치는 것은 다른 일이에요. 합치려면 같은 조합으로 들어온 값을 더하는 집계 단계가 어딘가에 있어야 합니다.
한도로 막아 두기
코드 리뷰에서 늘 걸러낼 수는 없으니 마지막 방어선이 필요합니다. Prometheus는 수집 설정에 한도를 걸 수 있어요.
scrape_configs:
- job_name: orders
sample_limit: 5000
label_limit: 30
label_value_length_limit: 200
static_configs:
- targets: ["localhost:3000"]
sample_limit은 한 번 수집할 때 받는 샘플 수, label_limit은 샘플 하나의 레이블 개수, label_value_length_limit은 레이블 값 하나의 바이트 길이 한도예요. 셋 다 기본값은 0이고 0은 제한 없음을 뜻합니다. 처음의 경로 레이블 서버에 sample_limit: 5000을 걸고 수집해 봤습니다.
up{instance="localhost:3000",job="orders"} 0
scrape_samples_scraped{instance="localhost:3000",job="orders"} 7000
수집 대상 페이지에는 sample limit exceeded 오류가 찍힙니다. 한도를 넘으면 넘친 만큼만 버리는 게 아니라 그 수집 전체를 실패로 처리해요. 그 대상의 지표가 통째로 끊기니 아프지만, Prometheus 서버 하나가 죽어 다른 모든 서비스의 지표까지 잃는 것보다는 낫습니다. up이 0이 되니 기존 수집 실패 알림으로도 바로 알 수 있고요. 한도는 평소 scrape_samples_scraped 값을 보고 넉넉하게 몇 배로 잡아 둡니다.
애플리케이션 쪽에도 같은 방어선이 있습니다. OpenTelemetry 명세는 SDK가 지표마다 카디널리티 한도를 두도록 정해 두었고 기본값은 2000이에요. 다만 넘쳤을 때의 처리가 sample_limit과 다릅니다. 이미 본 속성 조합은 계속 내보내고, 한도를 넘어 새로 들어온 조합의 측정값은 otel.metric.overflow=true 속성 하나만 붙은 데이터 포인트(data point)로 몰아서 셉니다. 측정값을 버리지도 두 번 세지도 않으니 전체 합계는 맞고, 어느 조합인지만 잃는 셈이죠. 이 속성이 붙은 데이터가 보이면 한도를 넘었다는 신호입니다. 언어별 SDK마다 구현 여부가 다르니 쓰는 SDK의 문서를 확인해 보세요.
다른 도구에서는 어떻게 터질까
지금까지 Prometheus로 실습했지만, 레이블 조합으로 시계열을 미리 나눠 두는 지표 저장소라면 어디서나 같은 일이 생깁니다. 도구를 직접 운영하는지 SaaS로 쓰는지에 따라 터지는 모양만 달라요.
| 도구 | 시계열을 나누는 단위 | 터지는 모양 |
|---|---|---|
| Prometheus, VictoriaMetrics, InfluxDB | 레이블(태그) 조합 | 메모리 부족, 서버 다운 |
| Grafana Mimir, Cortex | 레이블 조합 + 테넌트별 시계열 한도 | 한도를 넘은 데이터는 수집 거부 |
| Datadog | 지표 이름과 태그 값 조합 | 커스텀 지표 요금 |
| CloudWatch | 지표 이름과 차원 조합 | 지표 개수당 요금 |
| OpenTelemetry SDK | 속성 조합 | 애플리케이션 메모리, 오버플로 |
직접 운영하는 저장소에서는 시계열이 곧 메모리라 서버가 먼저 비명을 지릅니다. Mimir처럼 여러 팀이 나눠 쓰는 저장소는 테넌트(tenant)마다 시계열 상한을 두어서, 한 팀의 실수가 저장소 전체를 쓰러뜨리지 않고 그 팀의 데이터만 거부되게 하고요.
SaaS에서는 서버가 죽지 않는 대신 비용으로 돌아옵니다. Datadog은 지표 이름과 태그 값 조합 하나를 커스텀 지표(custom metric) 하나로 세서 요금을 매기고, AWS의 CloudWatch도 차원 조합이 다르면 다른 지표로 따로 과금해요. 앞의 실습에서 주문 번호를 태그로 붙였다면 서버는 멀쩡해도 다음 달 청구서에 시계열 7,000개분이 찍혀 있었을 겁니다. 오히려 장애처럼 바로 드러나지 않아서 늦게 발견되기 쉽습니다.
반대로 이 문제가 생기지 않는 도구도 있습니다. Honeycomb나 ClickHouse 기반 저장소처럼 원시 이벤트를 열(column) 단위로 쌓아 두는 도구는 시계열을 미리 만들지 않아요. user_id가 백만 가지 값을 가져도 이벤트마다 열 값이 하나 늘 뿐이고, 묶어서 세는 비용은 질문할 때 냅니다. 관측성 정리에서 높은 카디널리티는 원시 이벤트로 남긴다고 한 게 바로 이 구조예요. 그러니 정확히 말하면 시계열 폭발은 관측성 도구 전반의 문제가 아니라 미리 집계하는 지표 저장소의 문제입니다.
높은 카디널리티가 필요할 때
그렇다고 주문 번호를 어디에도 남기지 말라는 뜻은 아닙니다. “느린 요청이 어느 주문이었나”는 장애 때 꼭 필요한 질문이에요. 다만 그 답은 지표가 아니라 추적과 로그에 있습니다. 지표는 “느린 요청이 늘었다”까지만 알려 주고, 거기서 trace_id로 추적과 로그로 내려가는 흐름이 개별 요청을 찾아 줍니다.
지표와 추적을 잇는 장치로 예시값(exemplar)도 있습니다. 히스토그램 버킷 값 옆에 그 버킷에 들어간 요청 하나의 trace_id를 붙여 두는 OpenMetrics 기능이에요. 레이블이 아니라 부가 정보라 시계열을 늘리지 않으면서, 그래프의 튀는 지점에서 바로 해당 요청의 추적으로 넘어갈 수 있습니다.
마치며
지표 저장소에서 시계열 하나는 레이블 조합 하나이고 시계열 수는 레이블마다 값 개수를 곱한 만큼 늘어납니다. 히스토그램은 여기에 버킷 수를 한 번 더 곱하고, Prometheus에서는 사라진 시계열도 헤드 블록이 압축될 때까지 메모리를 차지해요. 직접 운영하면 메모리로, SaaS라면 청구서로 돌아옵니다. 터지면 저장소가 주는 통계로 범인 지표와 레이블을 찾고, 코드에서 실제 값 대신 라우트 패턴 같은 유한한 값을 쓰도록 고칩니다. 재레이블링은 버릴 때만 쓰고 합칠 때는 쓰지 않으며, sample_limit이나 SDK의 카디널리티 한도로 마지막 방어선을 쳐 둡니다.
새 레이블을 붙일 때 “이 값을 미리 다 적을 수 있나?” 한 번만 물어봐도 대부분의 사고는 막을 수 있어요. 적을 수 없다면 그 값은 추적이나 로그, 원시 이벤트의 몫입니다.
더 자세한 내용은 Prometheus 공식 문서의 Metric and label naming을 참고하세요.
This work is licensed under CC BY 4.0