OpenTelemetry + ClickHouse 로 APM 스캐터 차트 만들기[2편] — Grafana 커스텀 패널
2026년 9월 10일
원문에서 보기 ↗OpenTelemetry + ClickHouse 로 APM 스캐터 차트 만들기 (2) --- Grafana 커스텀 패널
글. 박준희(Kuiper) / 숙박플랫폼개선TF팀

1. 들어가며
안녕하세요.
여기어때 숙박플랫폼개선TF팀 박준희(카이퍼)입니다.
1편에서 OpenTelemetry Collector 로 span 을 받아 ClickHouse 에 쌓는 데까지 다뤘습니다. 요약하면 이렇습니다.
- span 하나를 점 하나로 보고, X축은 종료 시각(timestamp + duration)
- 기존 트레이싱 경로는 건드리지 않고 별도 consumer group 으로 분기
- 적재 시점 필터로 화면에 쓰이지 않는 span 을 버림
- 범용 raw 테이블을 Materialized View 로 조회 전용 테이블로 변환
- 정렬 키는 (namespace, service, timestamp, trace_id)
이제 이 데이터를 화면으로 만들 차례입니다. 2편은 다음을 다룹니다.
- Grafana 기본 패널로는 왜 안 되는가
- 패널 플러그인이 어떻게 생겼고, 어떻게 개발 환경을 꾸리는가
- 컴포넌트를 어떻게 나눴는가
- 종료 시각으로 그리면서 시작 시각으로 찾아야 하는 조회 문제
- 만들고 나서야 보인 함정 3가지와 그 대응 --- 서버 격자 집계, 사전 집계 롤업, 증분 조회
- 화면에 나타나는 패턴을 읽는 법
완성된 화면부터 보겠습니다. 시간대별 트래픽 변동 위에 URI 마다 다른 응답 대역이 층을 이루고, 오른쪽에 URI 별 전수 통계가 붙습니다.
검증 범위 는 1편과 같습니다. 로컬 환경에서 구성·검증한 기록이며, 수치는 로컬 실측 / 공식 문서 로 출처를 구분해 표기했습니다.
2. 왜 기본 패널로는 안 되는가
Grafana 에는 이미 여러 시각화가 있습니다. 처음에는 기본 제공 패널로 어떻게든 해보려 했습니다.
2.1 시도해 본 것들
Time series 패널 --- 시계열 라인 차트입니다. 점 표시 모드로 원본 행을 그대로 찍을 수는 있지만, 점마다 카테고리별 모양·색을 주거나 영역을 드래그해 재조회하는 상호작용이 없습니다. 그리고 보통은 쿼리 단계에서 시간 버킷 집계를 물려 선으로 잇는 용법이라, 애초에 1편에서 피하려던 "접는" 시각화에 가깝습니다.
Table 패널 --- 개별 행은 보이지만 분포의 모양이 보이지 않습니다. 수만 건을 눈으로 훑을 수는 없습니다.
Heatmap 패널 --- 이쪽은 꽤 가깝습니다. 시간 × 값의 2차원 밀도를 보여주니까요. 실제로 밀도만 보면 되는 용도라면 heatmap 으로 충분할 수 있습니다.
XY chart 패널 --- 산점도를 그려주지만, 아래 요구를 채우지 못했습니다.
2.2 기본 패널로 채워지지 않은 요구
- 점 색으로 상태 구분 --- 정상/느림/4xx/5xx 를 한눈에. 에러가 위쪽에 몰리는 패턴이 바로 보여야 합니다
- 점 모양으로 카테고리 구분 --- HTTP In / Messaging Consumer / Scheduled 를 색과 독립적으로 구분 (색은 이미 상태에 씁니다)
- 드래그로 영역 선택 → 상세 조회 --- 이상 구간을 끌어서 그 안의 요청 목록을 바로 보기
- 선택 영역만 별도 쿼리 --- 화면에 그린 데이터가 아니라 서버에 다시 물어야 정확한 목록이 나옵니다
- 카테고리 필터 토글 --- 하위 호출을 켜고 끄며 원인 층을 좁히기
- 트레이스·로그로 이동 --- 점 하나에서 분산 트레이싱·로그로 연결
- 10만 점 규모 렌더링 --- 기본 패널은 이 규모에서 버벅입니다
특히 드래그 선택 → 별도 쿼리 가 결정적이었습니다. 화면에 그려진 점은 이미 상한에 걸려 잘렸을 수 있으므로(8장), 선택 영역의 정확한 목록은 서버에 다시 물어야 합니다. 이건 시각화 패널의 일반적인 동작 범위를 벗어납니다.

그래서 커스텀 패널 플러그인을 만들기로 했습니다.
3. Grafana 패널 플러그인은 어떻게 생겼나
3.1 플러그인의 최소 구성
공식 문서가 정의하는 필수 파일은 두 개입니다.
- plugin.json --- "Stores metadata about your plugin, including information like its description, supported Grafana versions, and dependencies."
- module.ts --- "The entry point for your plugin's frontend logic."
둘 다 src/ 아래에 있어야 하고, 이 디렉터리는 필수로 표시되어 있습니다.
3.2 plugin.json
{
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
"type": "panel",
"name": "APM Scatter",
"id": "myorg-apmscatter-panel",
"info": {
"description": "Scatter plot panel for per-request latency distribution",
"author": { "name": "..." },
"keywords": ["scatter", "apm", "tracing"],
"version": "1.0.0",
"updated": "2026-08-24"
},
"dependencies": {
"grafanaDependency": ">=10.0.0",
"plugins": []
}
}
필드별로 짚으면,
- type --- panel / datasource / app 중 하나
- id --- 전역 고유 식별자. 대시보드 JSON 이 이 값으로 패널을 찾습니다
- grafanaDependency --- 지원 최소 버전. 이보다 낮은 Grafana 는 로드하지 않습니다
id 는 명명 규칙이 있습니다. 문서는 "The plugin folder follows a standard naming convention (for example, organization-pluginName-pluginType*)"* 라고 안내합니다. 예시로 myorg-myplugin-datasource 를 듭니다.
id는 나중에 바꾸기 어렵습니다. 대시보드 JSON·프로비저닝 설정·플러그인 디렉터리 경로에 전부 박히기 때문입니다. 처음에 신중하게 정하시길 권합니다. 저는 프로젝트 이름을 바꾸면서 이 값도 함께 바꿔야 했는데, 관련 파일을 전부 찾아 고치는 일이 생각보다 번거로웠습니다.
3.3 module.ts --- 진입점
import { PanelPlugin } from '@grafana/data';
import { ScatterPanel } from './components/ScatterPanel';
import { ScatterPanelOptions } from './types';
export const plugin = new PanelPlugin<ScatterPanelOptions>(ScatterPanel)
.setPanelOptions((builder) => {
return builder
.addNumberInput({
path: 'pointSize',
name: 'Point Size',
description: 'Size of scatter points in pixels',
defaultValue: 3,
category: ['Display'],
})
.addBooleanSwitch({
path: 'adaptiveRefresh',
name: 'Adaptive Refresh',
description: 'Raise the refresh interval when it is shorter than one pixel of time',
defaultValue: true,
category: ['Refresh'],
});
});
세 가지가 여기서 정해집니다.
- export const plugin --- Grafana 가 찾는 이름입니다. 다른 이름으로 export 하면 로드되지 않습니다
- new PanelPlugin<Options>(Component) --- 제네릭 인자가 패널 옵션의 타입이고, 생성자 인자가 실제로 렌더링할 React 컴포넌트입니다
- .setPanelOptions(builder) --- 우측 편집 사이드바에 나타날 옵션 컨트롤을 선언합니다
path 가 옵션 객체의 키와 연결됩니다. 문서 표현으로 "the pathis used to bind the control to an option" 입니다. path: 'thresholds.slowMs' 처럼 점 표기로 중첩 객체도 다룰 수 있습니다.
category 를 주면 사이드바에서 섹션으로 묶입니다. 옵션이 열 개를 넘어가면 이게 없으면 사용자가 못 찾습니다.
빌더가 제공하는 컨트롤은 여러 가지입니다.
.addNumberInput({...}) // 숫자
.addTextInput({...}) // 문자열
.addBooleanSwitch({...}) // 토글
.addRadio({...}) // 라디오 (settings.options 로 항목 지정)
.addSelect({...}) // 드롭다운
.addColorPicker({...}) // 색상
.addCustomEditor({...}) // 직접 만든 React 컴포넌트
3.4 조건부 옵션
옵션이 다른 옵션에 종속될 때는 showIf 를 씁니다.
.addNumberInput({
path: 'adaptiveRefreshMinPixels',
name: 'Min Pixels Per Refresh',
defaultValue: 1,
category: ['Refresh'],
showIf: (opts) => opts.adaptiveRefresh, // 토글이 켜져 있을 때만 노출
})
3.5 커스텀 에디터
기본 컨트롤로 안 되는 옵션이 있습니다. 예를 들어 현재 Grafana 에 등록된 Tempo 데이터소스 중에서 고르게 하는 드롭다운은 실행 시점에 데이터소스 목록을 알아야 하므로 정적 addSelect 로는 만들 수 없습니다. 이 패널은 점 하나에서 트레이스·로그로 이동하는 링크를 위해 Tempo·Loki 데이터소스 선택 에디터를 이 방식으로 만듭니다.
.addCustomEditor({
id: 'tempoDataSourceUid',
path: 'tempoDataSourceUid',
name: 'Tempo DataSource',
description: 'Select Tempo datasource for trace linking',
category: ['Data Source'],
editor: TempoDataSourceEditor, // 직접 만든 React 컴포넌트
})
커스텀 에디터 컴포넌트는 StandardEditorProps 를 받습니다.
import { StandardEditorProps } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
export const TempoDataSourceEditor: React.FC<StandardEditorProps<string>> = ({
value, onChange,
}) => {
// 실행 시점에 등록된 데이터소스 목록을 읽어 옵션을 구성한다
const tempoSources = getDataSourceSrv().getList().filter((ds) => ds.type === 'tempo');
return (
<Select
value={value}
options={tempoSources.map((ds) => ({ label: ds.name, value: ds.uid }))}
onChange={(v) => onChange(v.value)}
/>
);
};
정적 선언으로는 담을 수 없는 실행 시점 정보로 옵션 UI 를 구성할 수 있다는 것이 커스텀 에디터의 존재 이유입니다. StandardEditorProps 의 context 로 현재 쿼리 결과·다른 옵션 값에 접근하는 것도 같은 맥락에서 쓰입니다.
3.6 패널 컴포넌트가 받는 것 --- PanelProps
import { PanelProps } from '@grafana/data';
interface Props extends PanelProps<ScatterPanelOptions> {}
export const ScatterPanel: React.FC<Props> = ({
options, // setPanelOptions 로 선언한 옵션의 현재 값
data, // 쿼리 결과 (PanelData)
width, // 패널 폭 (px)
height, // 패널 높이 (px)
timeRange, // 대시보드 시간 범위
}) => { ... };
문서 표현으로 "The PanelProps interface exposes runtime information about the panel, such as panel dimensions, and the current time range" 입니다.
width / height 가 props 로 온다는 점이 중요합니다. 패널 크기가 바뀌면 컴포넌트가 새 값으로 다시 렌더링됩니다. 창 크기 변경·패널 리사이즈가 전부 여기로 들어옵니다. 8장의 갱신 주기 하한이 이 값에 의존합니다.
3.7 쿼리 결과 구조 --- DataFrame
data.series 는 DataFrame 배열입니다. 여기서 Grafana 의 데이터 모델이 행이 아니라 열 기반이라는 점이 드러납니다.
const frame = data.series[0];
if (!frame) {
return []; // 쿼리 결과가 아직 없거나 비어 있을 수 있다
}
// 컬럼(field) 을 이름으로 찾는다
const tsField = frame.fields.find((f) => f.name === 'ts_end');
const durationField = frame.fields.find((f) => f.name === 'duration');
const statusField = frame.fields.find((f) => f.name === 'status');
if (!tsField || !durationField || !statusField) {
return []; // 역참조 전에 확인한다 - 뒤늦은 체크는 이미 예외가 난 다음이다
}
// 각 field 는 values 배열을 가진다 - 인덱스가 행 번호
const rowCount = tsField.values.length;
for (let i = 0; i < rowCount; i++) {
points.push({
timestamp: tsField.values[i],
duration: Number(durationField.values[i]),
status: Number(statusField.values[i]),
});
}
fields[].values[i] 로 접근합니다. 공교롭게도 ClickHouse 와 같은 컬럼 지향 구조라, 데이터소스에서 패널까지 열 단위가 유지됩니다.
주의할 점은 필드가 없을 수 있다는 것입니다. 쿼리가 아직 안 돌았거나, 쿼리의 컬럼 이름이 코드가 기대하는 것과 다르거나, 결과가 비었을 때입니다. 방어 코드가 없으면 패널이 통째로 깨집니다.
if (!tsField || !durationField || !statusField) {
console.warn('Required columns not found. Check column mapping settings.');
return [];
}
4. 개발 환경 꾸리기
4.1 스캐폴딩
공식 도구로 시작합니다.
npx @grafana/create-plugin@latest
대화형 프롬프트로 플러그인 타입(panel)·조직명·플러그인명을 물어보고, 프로젝트 구조를 만들어 줍니다. 문서 표현으로 이 도구는 "a CLI application that simplifies Grafana plugin development by scaffolding a starter plugin, all required configuration, and a development environment using Docker Compose" 입니다.
생성되는 것 중 중요한 것들입니다.
.config/ # webpack·TypeScript·Jest 설정 (직접 수정 대상 아님)
src/
module.ts # 진입점
plugin.json # 메타데이터
components/ # React 컴포넌트
docker-compose.yaml # Grafana 개발 서버
package.json
.config/ 는 도구가 관리하는 영역이라 직접 고치지 않는 것이 원칙입니다. 도구 업데이트 시 덮어써집니다.
4.2 빌드와 감시 모드
npm run dev # webpack watch — 소스 변경 시 자동 재빌드
npm run build # 프로덕션 빌드 (dist/ 생성)
npm run typecheck
npm run lint
npm run dev 를 띄워 두면 소스를 고칠 때마다 dist/ 가 갱신됩니다.
4.3 Grafana 에 물리기
빌드 결과인 dist/ 를 Grafana 의 플러그인 디렉터리에 마운트합니다.
# docker-compose.yml
services:
grafana:
image: grafana/grafana:12.0.1
ports:
- "3000:3000"
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ../plugin/dist:/var/lib/grafana/plugins/myorg-apmscatter-panel:ro
environment:
GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: myorg-apmscatter-panel
GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS가 없으면 로드되지 않습니다. Grafana 는 서명되지 않은 플러그인을 기본적으로 거부합니다. 개발 중에는 이 환경변수에 플러그인 id 를 넣어 예외 처리합니다. 값이 plugin.json 의 id 와 정확히 일치해야 합니다.
마운트 경로의 디렉터리명도 id 와 맞추는 것이 안전합니다.
4.4 개발 루프
소스 수정 -> (npm run dev 가 자동 재빌드) -> 브라우저 새로고침
여기서 한 가지 걸림돌을 만났습니다. 브라우저가 플러그인 번들을 캐시합니다.
Grafana 는 플러그인 정적 파일에 캐시 헤더를 붙여 응답합니다. 확인해 보면 이렇습니다 (Grafana 12.0.1 로컬 Docker 구성에서 관찰한 값 --- 버전·프록시 구성에 따라 다를 수 있습니다).
Cache-Control: public, max-age=3600
한 시간 동안 재검증 없이 캐시된다는 뜻입니다. 코드를 고치고 새로고침해도 예전 번들이 그대로 돕니다. 저는 이걸 모르고 "수정이 반영이 안 된다"며 한참 헤맸습니다. 서버가 내려주는 파일의 해시는 새 빌드와 일치하는데 화면 동작만 옛날 것이었죠.
해결책은 캐시를 우회한 새로고침입니다.
- 하드 리로드 (Cmd/Ctrl + Shift + R)
- 개발자 도구를 열고 Network 탭의 "Disable cache" 체크
- 콘솔에서 강제 재검증:
await fetch('/public/plugins/myorg-apmscatter-panel/module.js', { cache: 'reload' });
location.reload();
증상이 "코드가 안 먹는다"로 나타나기 때문에 원인을 코드에서 찾게 됩니다. 플러그인 개발 중 수정이 반영되지 않으면 캐시부터 의심하시길 권합니다.
5. 컴포넌트 설계
5.1 패널 분해
패널 하나지만 안에서 하는 일이 여러 가지라, 역할별로 나눴습니다.
ScatterPanel 상태 관리 · 데이터 변환 · 조회 오케스트레이션
├── FilterBar 카테고리·상태 필터 토글
├── (URI 필터 바) URI 검색·자동완성·칩 목록
├── ScatterCanvas 점 렌더링 · 드래그 선택
├── PathStatsPanel URI별 통계 (건수·평균·최대)
└── DetailTable 선택 영역의 요청 목록 · 페이지네이션
경계를 잡은 기준은 "이 컴포넌트가 다시 그려져야 하는 이유가 무엇인가" 였습니다.
- ScatterCanvas 는 점 데이터·크기·시간 범위가 바뀔 때만 다시 그리면 됩니다. 필터 UI 상태에는 관심이 없습니다
- DetailTable 은 선택 영역이 바뀔 때만 갱신됩니다. 점 데이터와 무관합니다
- FilterBar 는 순수한 표시·입력 컴포넌트입니다
이렇게 나누면 무거운 캔버스 렌더링이 불필요하게 재실행되는 것을 막을 수 있습니다.

5.2 상태를 어디에 둘 것인가
ScatterPanel 이 상태를 소유하고 자식에게 값과 콜백을 내려줍니다.
export const ScatterPanel: React.FC<Props> = ({ options, data, width, height, timeRange }) => {
const [spanFilter, setSpanFilter] = useState<SpanFilterState>(...); // 카테고리·상태 필터
const [uriFilters, setUriFilters] = useState<UriFilter[]>([]); // URI 필터 칩
const [selection, setSelection] = useState<Selection | null>(null); // 드래그 선택 영역
const [details, setDetails] = useState<Transaction[]>([]); // 상세 목록
const [pagination, setPagination] = useState<PaginationState>(...);
...
};
패널 옵션(options)과 컴포넌트 상태(useState)의 구분 기준은 지속되어야 하는가입니다.
- 옵션 --- 대시보드에 저장됨. 점 크기, 임계값, 데이터소스 연결
- 상태 --- 세션 한정. 지금 켜둔 필터, 드래그한 영역, 현재 페이지
사용자가 대시보드를 다시 열었을 때 남아 있어야 하면 옵션, 아니면 상태입니다.
5.3 데이터 변환 파이프라인
쿼리 결과에서 화면에 그릴 점까지 두 단계를 거칩니다.
// 1단계: DataFrame -> ScatterPoint[] (필드 추출)
const allPoints = useMemo(() => { ... }, [data.series]);
// 2단계: 필터 적용
const points = useMemo(
() => allPoints.filter((p) => {
if (!spanFilter.categories[p.spanCategory ?? 'OTHER']) return false;
if (spanFilter.statusFilter === 'error' && p.status < 500) return false;
if (uriFilters.length > 0 && !matchesUriFilter(p, uriFilters)) return false;
return true;
}),
[allPoints, spanFilter, uriFilters]
);
두 단계로 나눈 이유는 의존성이 다르기 때문입니다. 1단계는 쿼리 결과가 바뀔 때만, 2단계는 필터가 바뀔 때만 다시 계산됩니다. 필터 토글 하나 눌렀다고 DataFrame 파싱을 다시 할 이유가 없습니다.
다만 이 구조에는 한계가 있습니다. 1단계가 전체 배열을 매번 새로 만듭니다. 점이 수만 개면 갱신마다 그만큼의 객체가 새로 할당됩니다. 8장에서 다시 이야기하겠습니다.
6. 렌더링
6.1 왜 Canvas 인가
점 하나를 DOM 요소로 만들면 안 됩니다. SVG <circle> 을 5만 개 만들면 브라우저가 멈춥니다. DOM 노드마다 레이아웃·스타일 계산이 붙기 때문입니다.
Canvas 는 픽셀 버퍼에 직접 그립니다. 노드가 생기지 않으니 개수에 대한 부담이 훨씬 적습니다. 대신 그려진 것에 이벤트를 붙일 수 없습니다. 클릭·드래그는 캔버스 전체에 걸고, 좌표를 역변환해 어떤 점인지 계산해야 합니다.
6.2 캔버스를 두 장 쓰기
드래그 선택 영역을 그리려면 마우스가 움직일 때마다 다시 그려야 합니다. 그때마다 점 5만 개를 다시 찍으면 드래그가 버벅입니다.
그래서 캔버스를 두 장 겹칩니다.
[아래] 점 캔버스 — 데이터가 바뀔 때만 다시 그림
[위] 오버레이 캔버스 — 드래그 사각형만. 마우스 이동마다 clearRect 후 재렌더
오버레이는 사각형 하나만 그리므로 매 프레임 다시 그려도 부담이 없습니다.
6.3 좌표 변환
데이터 좌표(시각, ms)를 화면 좌표(px)로 옮기는 함수가 필요합니다.
const MARGIN = { top: 20, right: 20, bottom: 40, left: 60 };
const plotWidth = width - MARGIN.left - MARGIN.right;
const plotHeight = height - MARGIN.top - MARGIN.bottom;
const scaleX = (ts: number) =>
MARGIN.left + ((ts - timeFrom) / (timeTo - timeFrom)) * plotWidth;
const scaleY = (durationMs: number) =>
MARGIN.top + plotHeight - (durationMs / maxDuration) * plotHeight;
Y축이 뒤집혀 있는 것에 주의해야 합니다. 캔버스 좌표는 위가 0 이므로, 값이 클수록 위로 가려면 plotHeight 에서 빼야 합니다.
역변환도 필요합니다. 드래그한 사각형을 시간·응답시간 범위로 되돌려야 상세 조회 쿼리를 만들 수 있습니다.

const invertX = (x: number) => timeFrom + ((x - MARGIN.left) / plotWidth) * (timeTo - timeFrom);
const invertY = (y: number) => ((MARGIN.top + plotHeight - y) / plotHeight) * maxDuration;
6.4 Y축 상한과 클램핑
응답시간은 꼬리가 깁니다. 30초짜리 요청 하나 때문에 Y축이 30초까지 늘어나면, 나머지 99.9% 가 바닥에 깔려 아무것도 안 보입니다.
그래서 Y축 상한을 백분위 기준으로 잡고(예: p99 × 1.1), 넘는 점은 천장에 붙여 그립니다. 이렇게 값을 상한에 가두는 것을 클램프(clamp)라고 부르겠습니다.
const clampedDuration = Math.min(point.duration, maxDuration);
const y = scaleY(clampedDuration);
여기에 함정이 있습니다. 천장에 수평선이 생깁니다. 그런데 9장에서 보듯 수평선은 "타임아웃 도달"을 뜻하는 중요한 패턴이기도 합니다. 클램프가 만든 수평선과 진짜 수평선을 구분할 수 없으면 오독이 발생합니다.
그래서 클램프된 점 개수를 화면에 표시합니다.
16,831 points (104 clamped) Y: 1,024ms
이 숫자가 있으면 "천장의 수평선은 104개가 눌린 결과"라고 읽을 수 있습니다.
6.5 색과 모양 --- 두 축을 독립적으로
시각 어휘를 두 축으로 나눴습니다.
- 색 = 상태 --- 정상(녹) / 느림(노랑) / 4xx(주황) / 5xx(빨강)
- 모양 = 카테고리 --- 원(HTTP) / 역삼각형(Messaging Consumer) / 삼각형(Messaging Producer) / 마름모(Scheduled)
색과 모양을 각각 다른 정보에 배정하면 두 정보를 동시에 읽을 수 있습니다. "빨간 마름모가 몰려 있다" = "스케줄러에서 5xx 가 난다" 로 바로 읽힙니다.
색만으로 구분하지 않은 데에는 접근성 이유도 있습니다. 적록색맹인 분에게 녹색 정상과 빨간 에러는 구분이 어렵습니다. 모양이라는 두 번째 채널이 있으면 색 인지와 무관하게 카테고리는 읽힙니다.
6.6 그리기 순서 최적화
캔버스 상태 변경(fillStyle 등)은 비용이 있습니다. 점마다 색을 바꾸면 그만큼 상태 전환이 일어납니다.
그래서 색·모양 조합으로 먼저 묶고, 그룹 단위로 그립니다.
// 색+모양 조합으로 그룹핑
const groups = new Map<string, ScatterPoint[]>();
points.forEach((p) => {
const key = `${getColor(p)}-${getShape(p)}`;
(groups.get(key) ?? groups.set(key, []).get(key)!).push(p);
});
// 그룹마다 fillStyle 한 번만 설정
groups.forEach((groupPoints, key) => {
ctx.fillStyle = colorOf(key);
groupPoints.forEach((p) => drawShape(ctx, scaleX(p.timestamp), scaleY(p.duration)));
});
조합이 20종이면 상태 전환도 20번으로 끝납니다.
다만 이것도 근본 해법은 아닙니다. 그룹 안에서는 여전히 점마다 path 연산을 합니다. 8장에서 이야기하겠습니다.
7. 조회 설계
7.1 문제 --- 종료 시각으로 그리고, 시작 시각으로 찾는다
1편 2.3절에서 X축을 종료 시각 으로 정했습니다. 그런데 테이블의 정렬 키는 시작 시각입니다.
종료 시각으로 조건을 걸면 이렇게 됩니다.
WHERE timestamp + toIntervalMillisecond(duration) BETWEEN :from AND :to
timestamp 가 계산식 안에 들어가는 순간 정렬 키가 무력화됩니다. 1편 3.4절에서 본 sparse index 를 못 씁니다. 인덱스가 granule 을 걸러주지 못하니 전 구간을 읽습니다.
7.2 해결 --- 조회 축을 물리화한다
조회가 항상 종료 시각으로 들어온다면, 종료 시각을 계산식이 아니라 컬럼으로 만들어 인덱스를 걸면 됩니다. 그래서 1편 7.2의 CREATE TABLE 에 이 두 줄을 처음부터 넣어 뒀습니다.
-- 1편 7.2 스키마에 이미 포함된 정의
ts_end DateTime64(3) MATERIALIZED toDateTime64(timestamp + toIntervalMillisecond(duration), 3),
INDEX idx_ts_end ts_end TYPE minmax GRANULARITY 1
MATERIALIZED 컬럼은 INSERT 시점에 서버가 계산해 저장하므로, 1편 8장의 Materialized View 는 이 컬럼을 몰라도 됩니다. 그리고 물리 정렬이 (서비스, 시작 시각) 순서인데 ts_end 는 시작 시각과 거의 단조라 granule 마다 min/max 가 촘촘하게 잡힙니다 --- 1편 7.5의 minmax skip index 가 정렬 키에 준하는 수준으로 granule 을 걸러 줍니다.
조회는 조회 축을 그대로 씁니다.
SELECT ts_end, duration, status, uri, method, span_category
FROM apm.transactions
WHERE ts_end >= :from
AND ts_end <= :to
AND timestamp <= :to -- 무손실 보조 조건: 시작 <= 종료이므로 결과 불변
AND (:namespace = '' OR service_namespace = :namespace)
AND (:service = '' OR service_name = :service)
timestamp <= :to 는 결과를 바꾸지 않으면서 일자 파티션과 정렬 키의 오른쪽 경계 프루닝을 덤으로 얻는 보조 조건입니다.
조회 축이 종료 시각 그 자체이므로 부수 성질이 하나 따라옵니다 --- 몇 분씩 행에 걸렸다 풀린 요청처럼 시작 시각이 조회 창보다 한참 앞서는 요청도, 종료 시각만 창 안이면 그대로 조회됩니다. 느린 요청일수록 잘 보여야 하는 관측 도구에서 중요한 성질입니다.
이 글의 SQL 에 나오는 :from 같은 표기는 자리를 나타내는 의사 표기입니다. 실제 구현은 패널(TypeScript)이 값을 보간해 완성된 SQL 을 보냅니다 --- ClickHouse 서버 파라미터 문법({from:DateTime64})과는 다른 것이니 그대로 실행하지 마세요. 네임스페이스·서비스를 지정하지 않는 전체 조회도 ts_end 인덱스가 시간 프루닝을 담당하므로 같은 형태로 동작합니다.

7.3 패널이 직접 쿼리하기
드래그로 영역을 선택하면 그 안의 요청 목록을 보여줘야 합니다. 그런데 화면에 그려진 점을 쓰면 안 됩니다. 상한에 걸려 잘렸을 수 있기 때문입니다(8.1).
그래서 패널이 직접 쿼리를 날립니다. Grafana 는 데이터소스 프록시 API 를 제공합니다.
import { getBackendSrv } from '@grafana/runtime';
const executeQuery = async (rawSql: string) => {
const response = await getBackendSrv().post('/api/ds/query', {
queries: [{
refId: 'A',
datasource: { type: 'grafana-clickhouse-datasource', uid: dataSourceUid },
rawSql,
format: 1,
}],
});
return parseFrames(response);
};
데이터소스 uid 는 하드코딩하지 않고 패널의 쿼리 설정에서 가져옵니다.
const dataSourceUid = useMemo(
() => data.request?.targets?.[0]?.datasource?.uid ?? 'fallback-uid',
[data.request?.targets]
);
이렇게 하면 대시보드에서 데이터소스를 바꿔도 패널이 따라갑니다.
상세 조회 쿼리는 선택 영역의 네 경계를 조건으로 씁니다.
SELECT toUnixTimestamp64Milli(ts_end) AS ts, trace_id, service_name, uri, method,
status, duration, error_type, error_message
FROM apm.transactions
WHERE <시간 범위 조건>
AND duration >= :minDuration
AND duration <= :maxDuration
<URI 필터 조건>
ORDER BY ts_end DESC, trace_id, span_id
LIMIT 100 OFFSET :offset
여기서는 ORDER BY 를 반드시 넣습니다. 페이지네이션에 정렬이 없으면 페이지를 넘길 때마다 다른 결과가 나옵니다. 그리고 ts_end 하나로는 부족합니다 --- 같은 종료 시각의 행들 사이 순서가 비결정적이라, trace_id, span_id 같은 tie-breaker 까지 붙여야 페이지 경계에서 중복·누락이 없습니다. (ts_end 는 7.2에서 물리화한 컬럼이라 그대로 씁니다.)

8. 만들고 나서야 보인 함정 3가지
여기부터는 화면이 일단 동작한 다음에 발견한 것들입니다. 셋 다 기능은 정상으로 보이는데 조용히 틀리거나 조용히 비싼 유형입니다.
8.1 ORDER BY 없는 LIMIT 은 무작위 절단이다
브라우저로 보내는 점 개수를 제한하려고 쿼리 끝에 LIMIT 을 걸었습니다.
...
LIMIT 100000
정렬 없이 LIMIT 만 걸면 어떤 10만 건이 남는지 정의되지 않습니다. ClickHouse 가 파트와 스레드를 읽는 순서대로 채우다 끊습니다. 병렬 읽기라 실행할 때마다 달라질 수도 있고, 시간축 한쪽에 쏠릴 수도 있습니다.
스캐터 차트에서 이건 치명적입니다.
- 사용자는 화면의 빈 구간을 트래픽 감소 로 읽지만 실제로는 절단일 수 있습니다
- 느린 요청이 사라진 것을 개선으로 오독할 수 있습니다
증상이 없는 것이 더 나쁩니다. 화면은 멀쩡해 보이고, 점 개수를 상한과 비교하기 전까지는 알 수 없습니다.
최소한의 방어는 상한 도달을 화면에 알리는 것입니다. 헤더의 점 개수가 상한과 같으면 경고를 띄웁니다. 근본 해법은 8.2 입니다.
8.2 픽셀보다 많은 점을 그리고 있다
트래픽이 조금만 있어도 15분 창에 수만~수십만 점이 들어옵니다. 그런데 플롯 영역의 픽셀 수는 폭 1,200 × 높이 400 = 약 48만입니다. 점 크기(기본 3px)를 감안하면 대부분의 점이 이미 같은 픽셀에 겹쳐 그려지고 있습니다.
즉 시각적으로 구별 불가능한 정보를 위해 다음을 전부 지불하고 있습니다.
- 원본 행을 JSON 으로 직렬화해 브라우저로 전송
- 브라우저에서 배열로 파싱 --- 5.3절에서 본 대로 갱신마다 전체 재생성
- 캔버스에 점마다 path 연산 --- 6.6절의 그룹핑으로도 개수 자체는 줄지 않음
해법은 서버에서 픽셀 격자로 집계해 내려보내는 것입니다. 상용 APM 도구들이 실제로 쓰는 방식이기도 합니다.
SELECT
floor((toUnixTimestamp64Milli(ts_end) - :from_ms) / :bucket_ms) AS tx, -- X 픽셀
if(duration > :max_duration, :cells_y, -- 상한 초과는 천장 셀로
least(floor(duration / :bucket_dur), :cells_y - 1)) AS ty, -- Y 픽셀 (0 .. cells_y-1)
span_category,
<상태 분류> AS status_class,
count() AS cnt
FROM apm.transactions
WHERE <시간 범위 + 필터>
GROUP BY tx, ty, span_category, status_class
:bucket_ms 는 시간 범위 / 플롯 폭(px), :bucket_dur 는 Y축 상한 / 플롯 높이(px) 입니다. Y축 상한을 넘는 점은 버리지 않고 천장 셀(ty = cells_y)로 모읍니다 --- 6.4절의 클램프 개수가 서버 집계만으로 정확히 나옵니다.
여기엔 순환이 하나 숨어 있습니다. :bucket_dur 를 정하려면 Y축 상한이 필요하고, 상한은 p99 로 잡는데, p99 를 구하려면 점이 필요합니다. 원본 점을 받던 시절에는 브라우저에서 계산하면 됐지만 이제 점이 없습니다. 그래서 갱신마다 2라운드로 조회합니다.
- 1라운드 --- count() + quantile(0.99)(duration) (헤더 건수·Y축 상한) 와 URI 통계를 병렬 실행
- 2라운드 --- 1라운드의 p99 로 :bucket_dur 를 확정하고 격자 조회
헤더의 점 개수와 사이드의 URI 통계도 이때 서버 전수 집계로 바꿨습니다. LIMIT 으로 잘린 표본을 브라우저에서 세던 8.1의 부정확성이 뿌리부터 사라집니다.
효과가 큽니다.
- 결과 행 수 --- 트래픽 비례(무제한)에서 플롯폭 × 높이 × 카테고리 고정으로
- 절단 문제 --- 소멸합니다. 모든 행이 집계에 기여하므로
- 밀도 표현 --- 겹쳐 그리기 대신 cnt 로 알파 농도 표현 (구현은 5단계)
- 드릴다운 --- 드래그 상세 조회(7.3)가 그대로 담당
ClickHouse 는 GROUP BY + count() 를 가장 잘하는 종류의 DB 이고, 1편 7.4절의 ts_end 인덱스가 시간 범위 프루닝을 이미 커버합니다.
로컬 실측 (6시간 범위 · 저부하 합성 트래픽 98,185건, 약 4.5건/초) --- 격자 결과는 36,413셀 · 1.12 MiB 로 트래픽과 무관한 상한에 묶였고, 헤더 건수와 URI 통계 합계가 일치했으며, 격자 총합의 원본 대비 오차는 0.2% 였습니다(격자 조회와 원본 대조 조회를 수십 초 간격으로 따로 실행해, 그 사이의 신규 적재분이 오차로 잡힌 것입니다).
잃는 것은 개별 점 단위의 hover 입니다. 다만 드래그 선택 후 상세 조회는 7.3절처럼 어차피 별도 쿼리이므로 실사용 흐름은 대부분 유지됩니다.

8.3 갱신 주기 × 조회 범위 = 부하 폭발
대시보드 자동 갱신을 5초로 두고 조회 범위를 6시간으로 늘리면 어떻게 될까요.
5초마다 6시간치를 통째로 다시 읽습니다. 그중 새로 생긴 데이터는 5초분, 전체의 0.023% 입니다. 나머지 99.98% 는 방금 읽은 것을 또 읽는 것입니다. 15분 창에서도 델타는 0.56%(중복 99.4%), 1시간이면 0.14%(중복 99.86%) --- 범위가 넓을수록 낭비 비율이 올라갑니다.
여기에 동시 사용자 수가 곱해집니다. 대시보드를 열어둔 사람이 열 명이면 그대로 열 배입니다.
그런데 화면은 바뀌지 않습니다. 플롯 폭이 1,200px 일 때 6시간 범위에서 1픽셀은 18초입니다. 5초 갱신은 1픽셀도 움직이지 못합니다. 눈에 보이는 변화 없이 DB 만 태우는 상태입니다.
대응 1 --- 조회 범위에 비례한 갱신 주기 하한
하한 = 조회 범위 / 플롯 폭(px) -> 가장 가까운 상위 갱신 단계로 올림
범위 × 플롯 폭별 하한값은 아래 도식의 매트릭스에 정리했습니다. 여기서 놓치기 쉬운 변수가 플롯 폭입니다. 3.6절에서 본 대로 width 는 props 로 들어오고, 창 크기·패널 크기에 따라 계속 바뀝니다. 같은 6시간이라도 좁은 창에서는 1분, 넓은 창에서는 30초가 하한입니다.
넓게 볼수록 빠른 갱신이 정당해지고, 좁게 볼수록 빨리 갱신할 이유가 사라집니다.
15분 범위는 어떤 폭에서도 5초라, 기존 사용 방식에는 영향이 없습니다.
적용 방식은 강제 교체가 아니라 하한입니다 --- 실효 갱신 주기 = max(사용자가 고른 주기, 현재 하한). 사용자의 원래 선택을 기억해 두고 매번 하한과 비교하므로, 창을 다시 넓혀 하한이 내려가면 원래 값으로 자연스럽게 돌아갑니다.
구현 위치도 중요합니다. 패널이 새 데이터를 "무시"하는 방식이면 Grafana 의 쿼리 러너가 이미 DB 를 때린 다음이라 소용이 없습니다. 그래서 대시보드의 refresh 설정 자체를 하한으로 끌어올리고, 대시보드에 걸린 데이터소스 쿼리는 SELECT 1 더미로 둡니다 --- 실제 조회는 전부 패널이 직접 소유합니다(7.3).

효과 측정
로컬에서 ClickHouse system.query_log 로 측정한 결과입니다 (로컬 실측).
보호 없음 (6시간 범위 · 5초 갱신) 분당 9회 조회 34 MiB 읽기
하한 적용 (6시간 범위 · 30초 하한) 분당 2회 조회 8 MiB 읽기
쿼리 한 건당 읽은 행은 94,208 건, 3.81 MiB 였습니다. 완전히 통제된 A/B 는 아니라 5초 구간의 이론값(분당 12회)에는 못 미치지만, 방향과 규모는 분명합니다.
-- 측정에 쓴 쿼리
SELECT toStartOfMinute(event_time) AS min, count() AS q,
formatReadableSize(sum(read_bytes)) AS total_read
FROM system.query_log
WHERE type = 'QueryFinish'
AND event_time > now() - INTERVAL 30 MINUTE
AND query LIKE '%span_category%' AND query LIKE '%transactions%'
GROUP BY min ORDER BY min;
대응 2--2시간 이상은 사전 집계 롤업
갱신 주기 하한은 조회 빈도 를 줄일 뿐, 한 번에 읽는 양은 여전히 조회 범위에 비례합니다. 8.2의 격자 집계도 결과 행 수를 고정할 뿐 스캔은 그대로입니다. 6시간이면 6시간치를 읽습니다.
스캔량까지 묶으려면 1편 8장의 도구를 한 번 더 쓰면 됩니다. Materialized View 는 Materialized View 가 채우는 테이블에도 걸 수 있습니다. 조회 전용 테이블에 다시 트리거를 걸어, 적재 시점에 집계 테이블을 함께 유지합니다.
CREATE TABLE apm.transactions_grid_10s (
service_namespace LowCardinality(String),
service_name LowCardinality(String),
end_bucket DateTime, -- 종료 시각 10초 버킷
duration_bucket UInt16, -- round(log10(greatest(duration, 1)) × 120) — 0ms 방어
span_category LowCardinality(String),
status_bucket LowCardinality(String), -- error / warn / ok
cnt UInt64
) ENGINE = SummingMergeTree()
PARTITION BY toDate(end_bucket)
ORDER BY (service_namespace, service_name, end_bucket,
duration_bucket, span_category, status_bucket)
TTL end_bucket + INTERVAL 7 DAY;
-- + 같은 방식의 60초 × method/uri 통계 테이블 (AggregatingMergeTree)
-- 조회는 항상 sum(cnt) ... GROUP BY 로 재집계한다 — SummingMergeTree 는
-- 머지가 끝나기 전까지 같은 키의 행이 여러 개 남아 있을 수 있다
시간축은 10초, duration 축은 로그 스케일 버킷(10배마다 120칸)입니다. 화면 Y축의 해상도가 어차피 픽셀이라, 버킷 하나의 상대 오차 10^(1/120) ≈ 1.9% 는 픽셀 아래로 숨습니다. p99 도 원값 대신 pow(10, duration_bucket / 120) 에 cnt 를 가중치로 준 quantileExactWeighted 로 근사합니다.
패널은 조회 범위에 따라 해상도를 고릅니다.
- 2시간 미만 --- raw 조회 (기본 플롯 폭 기준 10초 버킷이 픽셀보다 굵어지는 구간 --- 경계값은 폭에 따라 달라지는 경험값입니다)
- 2시간 이상 --- 롤업 조회
- URI 검색·Slow 필터가 걸리면 --- 롤업에 없는 축이므로 범위와 무관하게 raw 로 폴백
롤업은 종료 시각 버킷이 곧 정렬 키라, skip index 의 도움조차 없이 범위 조건이 정렬 키를 그대로 탑니다.
조심할 것이 두 가지 있습니다.
- 1편 8.1의 "기존 데이터는 처리하지 않는다"가 여기서도 반복됩니다. 롤업을 나중에 만들면 백필(INSERT INTO ... SELECT)이 필수입니다
- 집계 결과에 원본 컬럼과 같은 별칭을 붙이면(sum(cnt) AS cnt) ClickHouse 가 별칭을 우선 해석해 ILLEGAL_AGGREGATION 이 납니다. 서브쿼리로 이름을 분리해야 합니다
로컬 실측 (6시간 범위 · 고부하 합성 트래픽 원본 398만 행, 약 184건/초 --- 8.2 실측과는 다른 부하 조건입니다)
격자 스캔 3,983,616 → 466,944 행 (8.5× 감소)
URI 통계 스캔 486× 감소 (8,192 행)
조회 시간 78 ms → 28 ms
정확도 총 건수 오차 0.03% · p99 근사 오차 1.9%
트레이드오프는 렌더에서 보입니다. 로그 버킷을 선형 픽셀로 다시 비닝하므로 확대하면 가로 밴딩이 생깁니다. 6시간 화면에서 개별 점을 읽을 일은 없어 수용했습니다.

대응 3 --- 증분 조회: 갱신은 델타만 읽는다
대응 1이 갱신 빈도를, 대응 2가 넓은 범위의 스캔을 줄였습니다. 남은 것은 짧은 범위입니다. 10분 창을 5초마다 갱신하면 매번 10분치를 다시 읽습니다 --- 그중 새 데이터는 5초분뿐인데도.
델타만 읽으려면 커서가 필요한데, 이벤트 시각은 커서가 될 수 없습니다. collector 가 2초 배치로 모아 보내고 실패하면 수십 초 뒤 재시도하므로, 이벤트 시각은 뒤섞여 도착합니다. 커서(마지막으로 본 이벤트 시각) 이후만 조회하면 커서보다 과거의 이벤트 시각을 달고 늦게 적재된 행이 영원히 화면에서 빠집니다. 이벤트 시각 커서를 고집하면 지연 여유폭만큼 커서를 되감아 재조회하고 중복을 제거해야 하고, 그 여유폭보다 늦은 도착은 여전히 놓칩니다.
그래서 커서는 1편 7.2 스키마의 적재 시각 inserted_at 입니다. 적재 시각은 단조 증가라 "커서 초과분"만 읽으면 되고, collector 가 아무리 늦게 보내도 커서 뒤에 쌓이므로 되감기·중복 제거 없이 지연 도착을 놓치지 않습니다.
커서 경계는 서버 시각으로 고정합니다 --- 스냅샷·델타 모두 inserted_at <= 커서 상한을 함께 걸어(다음 델타는 > 이전 커서 AND <= 새 커서) 조회와 커서 전진 사이의 누락·중복을 원천 차단합니다. 전제는 단일 노드(하나의 서버 시계) 입니다 --- 다중 노드라면 노드 간 시계 차로 단조성이 깨질 수 있어 노드별 커서 같은 별도 설계가 필요합니다. 커서를 5초 격자로 내림하는 것(대응 4)은 캐시 정렬이면서, 방금 찍힌 inserted_at 이 아직 조회에 보이기 전에 커서가 그 값을 지나치는 경계 상황에 대한 안전 여유이기도 합니다.
동작은 스냅샷 1회 + 델타 반복입니다.
- 스냅샷 --- 창 전체를 (절대 시각 앵커 시간 버킷 × 로그 duration 버킷 × 카테고리 × 상태) 셀로 1회 수신
- 델타 --- 이후 갱신마다 커서 초과 적재분만 같은 셀 형태로 받아 가산. 창 왼쪽으로 빠진 버킷은 버림
셀 좌표가 조회 창과 무관한 절대 기준이라 창이 미끄러져도 상태가 유지되고, 셀이 정확한 카운트라 합산도 정확합니다. URI 통계는 격자 셀에 uri 축이 없으므로 별도 스트림 으로 받습니다 --- (60초 분버킷 × method × uri) 단위의 건수·합계·최대를 같은 커서의 스냅샷·델타로 수신해 가산합니다. 픽셀 재비닝·slow 파생·총건수·p99(로그 히스토그램 가중 근사)·URI 통계의 필터 적용은 전부 클라이언트 파생입니다 --- 8.2의 2라운드(요약 → 격자)가 이 경로에서는 아예 사라지고, 카테고리·상태 필터 토글이 서버 조회 없이 즉시 반영되는 부수 효과가 있습니다. 델타 조회는 시간 창 조건조차 주 조건이 아닙니다 --- inserted_at 범위만으로 최근 적재 파트가 minmax 인덱스에 바로 잡힙니다. URI 검색과 slow 필터는 증분 상태에 없는 축이라 대응 2와 같은 규칙으로 raw 전체 조회로 폴백합니다.
로컬 실측 (10분 창 · 5초 갱신 · 초당 200건 적재)
갱신당 스캔 121,000 → 1,000행 (델타 3~5 ms)
지연 도착 5분 전 이벤트 시각 500행을 적재 → 다음 델타에 무손실 반영
(URI 통계에 500건 · 999ms 정확값으로 등장)
탭 절전 복귀 밀린 적재분을 단일 델타(42,116행)로 캐치업 — 갭 없음

대응 4 --- 동시 사용자: 스냅샷 결과 캐시
같은 대시보드를 열 명이 열면 같은 쿼리가 열 번 실행됩니다. 이 구조에서 가장 무거운 쿼리는 초기 스냅샷이므로 여기에 ClickHouse query cache 를 겁니다.
... GROUP BY tb, db, spanCategory, statusBucket
SETTINGS use_query_cache = 1, query_cache_ttl = 30
함정은 쿼리 문자열이 일치해야 캐시가 맞물린다는 점입니다. 사용자마다 브라우저 시각 기준으로 from/to 가 밀리초 단위로 달라 그대로는 전원이 캐시 미스입니다. 그래서 스냅샷의 창 경계는 10초 격자로(내림/올림 --- 넓어진 만큼은 클라이언트 파생 창이 걸러냄), 커서는 5초 격자로 내림해 양자화합니다. 커서가 격자에 물려도 이중 경계 덕에 무손실입니다.
로컬 실측 --- 동일 스냅샷 반복 실행 시 첫 회만 스캔(113,500행 · 33ms)하고 이후 5회 전부 캐시 히트(6ms), system.query_log 의 ProfileEvents['QueryCacheHits'] 로 확인. 참고로 query cache 는 기본적으로 같은 ClickHouse 계정 안에서만 공유됩니다 --- Grafana 데이터소스가 계정 하나로 프록시하므로 사용자 열 명이 자연스럽게 한 캐시를 나눠 씁니다.
9. 화면을 읽는 법
만들어 놓고 나면 다음 질문은 "이 모양이 무슨 뜻인가"입니다.
- 정상 --- 낮은 응답시간대에 균일 밀집, 간헐적 outlier. 평상 상태이자 이상 판정의 기준선. 이 모양을 먼저 익힐 것
- 수평선 --- 특정 응답시간에 점이 가로로 늘어섬. 타임아웃 도달·외부 연동 지연·스레드풀 대기. 설정된 타임아웃 값과 위치 대조
- 수직선 --- 시작은 제각각인데 종료가 한 시점에 몰림. GC(Stop-the-World)·배포·재시작·DB Lock. GC 메트릭과 대조
- 부채꼴 --- 특정 시점부터 응답시간이 부채꼴로 퍼짐. 커넥션 풀·스레드 풀 포화 시작. 풀 메트릭 필수
- 급상승 --- 급격히 상승하며 악화. 풀 고갈 진행·외부 장애·메모리 임계. 메모리·GC 메트릭
- 공백 --- 특정 시간대에 점이 없음. 애플리케이션 다운·네트워크 단절·Agent 장애. 8.1 절단과 반드시 구분
- 하위 호출만 상승 --- HTTP Out 은 오르는데 HTTP In 은 그대로. 외부 API·하위 서비스 지연. uri 로 대상 특정
- 특정 URI 편중 --- 전체는 정상인데 한 엔드포인트만 상승. N+1·인덱스 미스 등. uri 필터로 좁히기
- 스케줄러 간섭 --- Scheduled 발생 시각에 진입 요청이 함께 상승. 배치가 자원 점유. Scheduled 카테고리를 켜고 시각 대조

실제 화면에서는 필터가 이 패턴들을 분리해 줍니다. Error 만 남기면 특정 구간의 5xx 몰림과 타임아웃 수평선이 드러나고 --- 오른쪽 통계도 에러 집합 기준으로 다시 집계됩니다.

카테고리를 HTTP Out 만 남기면 외부 호출의 응답 분포가 분리됩니다. "하위 호출만 상승" 패턴을 확인하는 방법입니다.

오독하기 쉬운 두 가지
Y축 천장의 수평선 --- 6.4절의 클램프가 만든 결과일 수 있습니다. 실제 분포가 아닙니다. 클램프된 개수 표시로 구분해야 합니다.
공백 vs 절단 --- 8.1의 절단이 만든 공백을 트래픽 감소로 읽으면 정반대의 결론에 도달합니다. 점 개수가 상한에 닿아 있는지 먼저 확인해야 합니다. (이 글의 최종 구조 --- 격자·롤업·증분 --- 에서는 절단 자체가 없으므로, 이 함정은 raw + LIMIT 구조를 쓰는 경우의 이야기입니다.)
두 경우 모두 화면이 자기 한계를 스스로 드러내게 만드는 것이 해법입니다. 숫자 하나를 헤더에 띄우는 것으로 해결됩니다.
10. 마치며
배운 점
만들면서 가장 크게 남은 것은 "동작한다"와 "맞다"가 다르다는 감각이었습니다.
LIMIT 절단은 화면이 멀쩡해 보였고, 픽셀 중복 렌더는 아무도 불평하지 않았습니다. 둘 다 증상이 없어서 발견이 늦었습니다. 이런 유형은 타입 체크·빌드·린트 같은 정적 검증으로는 잡히지 않고, 화면을 실제로 띄워 숫자를 대조해야 드러납니다.
플러그인 캐시(4.4)도 비슷합니다. "코드가 반영이 안 된다"는 증상이 코드 쪽을 의심하게 만들었지만 원인은 HTTP 캐시 헤더였습니다.
같은 것을 만드실 분께
- 점 하나의 정의부터 정하기 --- span 인지 요청인지, X축이 시작인지 종료인지
- 기존 트레이싱 경로는 건드리지 말고 분기하기
- 적재 시점 필터로 양을 줄이기 --- 조회 최적화보다 효과가 큽니다
- 처음부터 서버 집계로 가기 --- 원본 점을 브라우저로 보내는 구조는 결국 갈아엎게 됩니다
- 갱신 주기와 조회 범위의 곱을 항상 의식하기
- 증분을 하려면 적재 시각 컬럼부터 --- 이벤트 시각은 커서가 될 수 없습니다
- 화면이 자기 한계를 스스로 드러내게 만들기 --- 상한 도달·클램프 개수를 숨기지 않기
참고 자료
Grafana 플러그인
ClickHouse
OpenTelemetry
OpenTelemetry + ClickHouse 로 APM 스캐터 차트 만들기[2편] --- Grafana 커스텀 패널 was originally published in 여기어때 기술블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.