읽는 데 12분조회 5
홈페이지에 Grafana 대시보드 달기 (2) — Next.js 앱 지표를 올리고 대시보드를 코드로
버전 1.0.0
작성 기준: 2026-09-11
앞 글에서 Prometheus 와 Grafana 를 Docker 로 올리고, 대시보드를 사이트 관리 화면 안에 끼워 넣었다. 서버 자원, 컨테이너, DB, 바깥에서 본 가용성까지 보인다.
그런데 앱 안쪽은 아직 아무것도 안 보인다. 이런 것은 앱만 안다.
- Node 프로세스의 힙 사용량. 계속 우상향이면 어딘가 붙잡고 놓지 않는 것이다
- 이벤트 루프 지연. 한 요청이 오래 붙잡으면 늘어난다. 응답이 느린데 Caddy 쪽 지표는 멀쩡할 때 볼 곳이다
- 쌓인 글·댓글·안 읽은 메시지 수
- 지금 도는 앱 버전. 배포한 뒤 새 판이 실제로 떴는지 여기서 확인한다
이 글은 그 지표를 만들어 Grafana 대시보드에 올리는 과정이다. 그리고 40KB 짜리 대시보드 JSON 을 손으로 고치지 않는 방법도 함께 적었다.
앞 글의 구성을 그대로 전제한다 — 앱은 127.0.0.1:40000 에만 열려 있고,
Prometheus 는 컨테이너 안에 있고, 호스트에서 Caddy 가 돈다. 예시는 Next.js
(App Router) 지만, /metrics 를 낼 수 있는 앱이면 방식은 같다.

1. 무엇을 낼지 정하기
요청 수와 응답 시간은 Caddy 가, 컨테이너 메모리는 cAdvisor 가 이미 준다. 그래서 앱은 그 둘이 모르는 것만 낸다. 겹치게 만들면 어느 숫자를 믿어야 하는지 헷갈린다.
npm install prom-client
// lib/metrics.ts
import { Registry, collectDefaultMetrics, Gauge } from "prom-client";
import { sql } from "drizzle-orm";
import { appVersion } from "@/lib/app-version";
import { mergeContentCounts } from "@/lib/content-counts";
import { getDb } from "@/lib/db";
// 레지스트리를 모듈 수준에 한 번만 만든다. 개발 서버는 모듈을 다시
// 불러오는 일이 있어, 그때마다 기본 지표를 또 등록하면 중복 등록으로 터진다.
const registry = new Registry();
let started = false;
function start() {
if (started) return;
started = true;
// 힙·이벤트 루프 지연·GC. 컨테이너 바깥에서는 보이지 않는 것들이다.
collectDefaultMetrics({ register: registry, prefix: "myhome_" });
// 지금 도는 판. 배포 뒤 새 판이 실제로 떴는지 대시보드에서 본다.
new Gauge({
name: "myhome_build_info",
help: "지금 도는 앱 버전",
labelNames: ["version"],
registers: [registry],
collect() {
this.reset();
this.set({ version: appVersion() ?? "unknown" }, 1);
},
});
// 글·댓글·메시지 수. 긁힐 때 한 질의로 센다 — 지표마다 질의를 두면
// 15초마다 DB 를 여러 번 두드린다.
new Gauge({
name: "myhome_content",
help: "글·댓글·메시지 수",
labelNames: ["kind", "state"],
registers: [registry],
async collect() {
this.reset();
try {
const rows = await getDb().execute<{
kind: string;
state: string;
count: number;
}>(sql`
select kind,
case
when not published then 'draft'
when private then 'private'
else 'public'
end as state,
count(*)::int as count
from posts
group by kind, state
union all
select 'comment', 'all', count(*)::int from comments
union all
select 'message',
case when read_at is null then 'unread' else 'read' end,
count(*)::int
from messages
group by 2
`);
// 셀 것이 없는 칸도 0 으로 낸다
for (const row of mergeContentCounts([...rows])) {
this.set({ kind: row.kind, state: row.state }, row.count);
}
} catch {
// DB 를 못 읽었을 때는 0 을 깔지 않는다. 0 을 내면 "글이 다
// 사라졌다" 로 보인다 — 없는 것과 모르는 것은 다르다.
// 그 순간에는 이 지표만 빠지고, DB 가 죽은 것은 postgres
// exporter 가 알린다.
}
},
});
}
export async function renderMetrics() {
start();
return { body: await registry.metrics(), contentType: registry.contentType };
}
셀 것이 없으면 빈칸이 된다. 메시지가 0건이면 그 줄이 아예 안 나가고, 대시보드에는 0 이 아니라 빈칸이 보인다. "지표가 고장난 건가" 와 "정말 없는 건가" 가 구분되지 않는다. 그래서 있을 수 있는 칸을 미리 0 으로 깔고 센 값으로 덮는다.
// lib/content-counts.ts
export type ContentCount = { kind: string; state: string; count: number };
export const CONTENT_CELLS: { kind: string; state: string }[] = [
...["post", "note"].flatMap((kind) =>
["public", "private", "draft"].map((state) => ({ kind, state })),
),
{ kind: "comment", state: "all" },
{ kind: "message", state: "unread" },
{ kind: "message", state: "read" },
];
export function mergeContentCounts(rows: ContentCount[]): ContentCount[] {
const merged = new Map<string, ContentCount>();
for (const cell of CONTENT_CELLS) {
merged.set(`${cell.kind}/${cell.state}`, { ...cell, count: 0 });
}
// 깔아 둔 칸에 없는 것이 와도 버리지 않는다 — 나중에 상태가 하나 늘었을
// 때 그 값이 사라지면 곤란하다.
for (const row of rows) {
merged.set(`${row.kind}/${row.state}`, {
kind: row.kind,
state: row.state,
count: Number(row.count),
});
}
return [...merged.values()];
}
2. 누구에게 줄지
앱 안쪽 숫자는 바깥에 낼 것이 아니다. 문을 세 겹으로 둔다.
| 어디 | 무엇을 막나 |
|---|---|
공개 도메인 /metrics | Caddy 가 404. 있다는 것조차 알리지 않는다 |
지표 포트 :59201 | 사설 대역에서, /metrics 경로만 |
| 앱 | 사설에서 온 요청이거나 관리자로 로그인했을 때만 |
// app/metrics/route.ts
import { headers } from "next/headers";
import { isAdmin } from "@/lib/auth";
import { renderMetrics } from "@/lib/metrics";
import { isFromOurSide } from "@/lib/private-ip";
export const dynamic = "force-dynamic";
export async function GET() {
const forwarded = (await headers()).get("x-forwarded-for");
if (!isFromOurSide(forwarded) && !(await isAdmin())) {
// 없는 주소처럼 답한다
return new Response("Not Found", { status: 404 });
}
const { body, contentType } = await renderMetrics();
return new Response(body, {
headers: {
"Content-Type": contentType,
"Cache-Control": "no-store",
"X-Robots-Tag": "noindex, nofollow",
},
});
}
여기서 함정이 하나 있다. X-Forwarded-For 의 첫 값을 보면 안 된다.
X-Forwarded-For: 127.0.0.1, 203.0.113.9
첫 값은 요청자가 적어 보낸 것이다. 헤더에 127.0.0.1 을 넣어 보내는 것만으로
통과한다. Caddy 는 들어온 헤더 뒤에 실제 상대 주소를 덧붙인다. 그러니
마지막 값이 Caddy 가 직접 본 주소다. 그것만 믿는다.
// lib/private-ip.ts
const PRIVATE_PATTERNS = [
/^127\./, // 로컬
/^10\./, // 사설 A
/^192\.168\./, // 사설 C
/^172\.(1[6-9]|2\d|3[01])\./, // 사설 B (도커 기본 대역이 여기 있다)
/^::1$/, // 로컬 (IPv6)
/^f[cd]/i, // 사설 (IPv6 ULA)
];
export function isPrivateAddress(value: string | null | undefined) {
if (!value) return false;
// 포트와 대괄호를 벗긴다. IPv6 는 "[::1]:80", IPv4 는 "1.2.3.4:80" 으로
// 온다. 콜론을 무조건 자르면 "::1" 이 ":" 로 뭉개진다.
const trimmed = value.trim();
const bracketed = trimmed.match(/^\[(.+)\](?::\d+)?$/);
let bare = bracketed ? bracketed[1] : trimmed;
if (!bracketed && bare.split(":").length === 2) {
bare = bare.split(":")[0];
}
// IPv4 를 IPv6 로 감싼 표기("::ffff:127.0.0.1")도 벗긴다
const mapped = bare.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
if (mapped) bare = mapped[1];
if (!bare) return false;
return PRIVATE_PATTERNS.some((pattern) => pattern.test(bare));
}
export function isFromOurSide(forwardedFor: string | null | undefined) {
// 헤더가 없으면 앞단을 거치지 않고 곧바로 온 것이다. 앱 포트는
// 127.0.0.1 에만 묶여 있으니 그런 요청은 이 서버 안에서 온 것뿐이다.
if (!forwardedFor || forwardedFor.trim() === "") return true;
const hops = forwardedFor.split(",");
return isPrivateAddress(hops[hops.length - 1]);
}
3. Caddy 가 문을 하나 열어 준다
앱은 127.0.0.1:40000 에만 묶여 있다. 컨테이너 안의 Prometheus 가 거기
닿을 수 없다. 앱 포트를 밖으로 열면 Caddy 를 우회하는 문이 하나 더 생긴다.
그래서 Caddy 가 /metrics 하나만 내준다.
:59201 {
@metrics {
remote_ip 172.16.0.0/12 192.168.0.0/16 10.0.0.0/8 127.0.0.1/32
path /metrics
}
handle @metrics {
reverse_proxy 127.0.0.1:40000
}
respond 403
}
path /metrics 가 없으면 이 문으로 관리 화면까지 열린다. 경로를 못 박아야
한다.
그리고 공개 도메인에서는 없는 주소로 답한다. 사이트 블록 안, 맨 아래
reverse_proxy 앞에 넣는다.
handle /metrics {
respond 404
}
4. Prometheus 에 붙이고 확인하기
prometheus.yml 에 job 하나를 더한다.
- job_name: 'myhome-app'
static_configs:
- targets: ['host.docker.internal:59201']
labels:
instance: 'myhome-app'
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
cd ~/monitoring && docker compose restart prometheus
그리고 문이 제대로 열렸는지, 제대로 닫혔는지 하나씩 본다.
| 확인할 것 | 명령 | 기대값 |
|---|---|---|
| 지표가 나오나 | curl -s localhost:59201/metrics | grep myhome_content | 숫자 줄들 |
| 다른 경로는 막혔나 | curl -o /dev/null -w '%{http_code}' localhost:59201/admin | 403 |
| 공개 도메인에서는 | curl -o /dev/null -w '%{http_code}' https://도메인/metrics | 404 |
| 사설 주소를 적어 보내면 | curl -H 'X-Forwarded-For: 127.0.0.1, 8.8.8.8' -o /dev/null -w '%{http_code}' https://도메인/metrics | 404 |
| 타깃이 붙었나 | Prometheus → Status → Targets | myhome-app UP |
네 번째 줄이 이 절의 핵심이다. 그게 404 가 아니라 200 이면 첫 값을 보고
있다는 뜻이고, 누구나 X-Forwarded-For 를 적어 보내는 것만으로 앱 지표를
가져갈 수 있다.
값이 실제로 들어오는지는 Prometheus 에 직접 물어봐도 된다.
curl -sG localhost:59090/api/v1/query \
--data-urlencode 'query=myhome_content' | python3 -m json.tool | head -20
아래는 curl -4 localhost:59201/metrics 명령을 통해 메트릭 수집 여부를 확인한 결과이다.
❯ curl -4 localhost:59201/metrics
# HELP myhome_process_cpu_user_seconds_total Total user CPU time spent in seconds.
# TYPE myhome_process_cpu_user_seconds_total counter
myhome_process_cpu_user_seconds_total 738.7218199999987
...
# HELP myhome_nodejs_eventloop_lag_seconds Lag of event loop in seconds.
# TYPE myhome_nodejs_eventloop_lag_seconds gauge
myhome_nodejs_eventloop_lag_seconds 0.001026448
# HELP myhome_nodejs_eventloop_lag_min_seconds The minimum recorded event loop delay.
# TYPE myhome_nodejs_eventloop_lag_min_seconds gauge
myhome_nodejs_eventloop_lag_min_seconds 0.009363456
# HELP myhome_nodejs_eventloop_lag_max_seconds The maximum recorded event loop delay.
# TYPE myhome_nodejs_eventloop_lag_max_seconds gauge
myhome_nodejs_eventloop_lag_max_seconds 0.012541951
...
# HELP myhome_content 글·댓글·메시지 수
# TYPE myhome_content gauge
myhome_content{kind="post",state="public"} 4
myhome_content{kind="post",state="private"} 0
myhome_content{kind="post",state="draft"} 0
myhome_content{kind="note",state="public"} 2
myhome_content{kind="note",state="private"} 0
myhome_content{kind="note",state="draft"} 0
myhome_content{kind="comment",state="all"} 1
myhome_content{kind="message",state="unread"} 0
myhome_content{kind="message",state="read"} 1
5. 대시보드를 파일로 관리하기
Grafana 화면에서 패널을 만들면 그 정의가 컨테이너 볼륨 안에 들어간다. 볼륨을 지우는 순간 되살릴 수 없다. 그래서 프로비저닝으로 바꾼다 — 파일이 원본이고, 고치면 30초 안에 저절로 반영된다. 컨테이너를 다시 띄울 필요도 없다.
# grafana/provisioning/datasources/prometheus.yml
apiVersion: 1
datasources:
- name: prometheus
type: prometheus
access: proxy
# 같은 compose 네트워크 안이라 컨테이너 이름으로 닿는다
url: http://prometheus:9090
isDefault: true
# uid 를 박아 두는 이유: 대시보드 JSON 이 이 값을 가리킨다.
# 바꾸면 그 대시보드가 빈 화면이 된다.
uid: efr9pxuoq66f4a
editable: false
jsonData:
# 스크레이프 간격과 맞춘다. 이보다 짧게 질의하면 계단처럼 끊긴다
timeInterval: 15s
httpMethod: POST
# grafana/provisioning/dashboards/myhome.yml
apiVersion: 1
providers:
- name: myhome
orgId: 1
folder: ''
type: file
# true 로 두면 화면에서 지울 수 있고, 지운 뒤 다시 살아나 헷갈린다
disableDeletion: true
# 파일을 고치면 이 간격 안에 반영된다
updateIntervalSeconds: 30
# 화면에서 고친 것이 파일을 이기지 않게 한다. 원본은 파일이다
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
uid 를 박는 것이 중요하다. 화면에서 손으로 데이터 원본을 추가하면 Grafana
가 무작위 uid 를 붙이는데, 볼륨을 지우고 다시 만들면 그 값이 바뀐다. 대시보드
JSON 은 옛 uid 를 가리키고 있어서 패널이 전부 빈다.
JSON 40KB 를 손으로 고치지 않는다
대시보드 하나가 JSON 40KB 다. 손으로 고칠 크기가 아니고, 패널을 왜 그렇게 뒀는지가 어디에도 남지 않는다. 그래서 생성기를 만들었다. 파이썬 200줄이 JSON 을 만든다.
#!/usr/bin/env python3
"""대시보드 JSON 을 만든다. JSON 을 손으로 고치지 말고 이 파일을 고친다."""
import json
import pathlib
DS = {"type": "prometheus", "uid": "efr9pxuoq66f4a"}
pid = [0]
def nid():
pid[0] += 1
return pid[0]
def tgt(expr, legend=None):
t = {"datasource": DS, "editorMode": "code", "expr": expr, "range": True,
"refId": chr(65 + tgt.n)}
tgt.n += 1
if legend:
t["legendFormat"] = legend
return t
tgt.n = 0
def row(title, y):
return {"type": "row", "id": nid(), "title": title, "collapsed": False,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": y}, "panels": []}
def ts(title, x, y, w, h, targets, unit=None, desc="", fill=10):
return {
"type": "timeseries", "id": nid(), "title": title, "description": desc,
"datasource": DS, "gridPos": {"h": h, "w": w, "x": x, "y": y},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "fillOpacity": fill,
"lineWidth": 2, "showPoints": "never",
"axisSoftMin": 0},
"unit": unit,
},
"overrides": [],
},
"targets": targets,
"options": {"legend": {"displayMode": "table", "placement": "bottom",
"showLegend": True,
"calcs": ["mean", "max", "lastNotNull"]},
"tooltip": {"mode": "multi", "sort": "desc"}},
}
APP = 'job="myhome-app"'
panels = []
panels.append(row("애플리케이션 (Next.js)", 0))
panels.append(ts("힙 메모리", 0, 1, 8, 9,
[tgt("myhome_nodejs_heap_size_used_bytes{%s}" % APP, "쓰는 중"),
tgt("myhome_nodejs_heap_size_total_bytes{%s}" % APP, "확보한 힙")],
unit="bytes",
desc="Node 가 실제로 쓰는 힙이다. 계속 우상향이면 어딘가 "
"붙잡고 놓지 않는 것이다."))
panels.append(ts("이벤트 루프 지연", 8, 1, 8, 9,
[tgt("myhome_nodejs_eventloop_lag_p99_seconds{%s}" % APP, "99%")],
unit="s", fill=0,
desc="응답이 느린데 Caddy 쪽은 멀쩡하면 여기를 본다."))
panels.append(ts("콘텐츠 수", 16, 1, 8, 9,
[tgt('myhome_content{%s, kind=~"post|note"}' % APP,
"{{kind}} · {{state}}")],
fill=0,
desc="public 공개 · private 나만 보기 · draft 임시저장."))
dash = {
"title": "myhome",
"uid": "myhome",
"panels": panels,
"refresh": "1m",
"schemaVersion": 41,
"time": {"from": "now-6h", "to": "now"},
"timezone": "browser",
"tags": ["myhome"],
}
out = pathlib.Path(__file__).parent / "dashboards" / "myhome.json"
with open(out, "w") as f:
json.dump(dash, f, ensure_ascii=False, indent=2)
print(out, len(panels), "panels")
gridPos 의 x·y·w·h 가 자리다. 폭은 24 칸이 한 줄이라 w=8 이면
셋이 나란히 선다. 자리를 손으로 계산하니 겹치기 쉬워서, 만든 결과를 좌표로
훑어 겹침을 확인하는 스크립트도 같이 두었다.
desc 는 패널 옆 물음표에 뜨는 설명이다. "무엇을 보는 그래프이고 어떤 값이면
이상한지" 를 여기 적어 두면, 몇 달 뒤에 봐도 판단이 된다.
python3 grafana/build-dashboard.py
# 파일만 바꾸면 30초 안에 반영된다
6. 패널 이름은 남이 알아볼 말로
처음에는 "겉에서 본 것", "주소마다 살아 있나" 처럼 내 말투로 적었다. 남에게 보여줄 화면이라는 것을 깨닫고 흔히 쓰는 말로 맞췄다. 지금은 네 줄이고, 괄호 안이 어디서 잰 값인지다.
- 가용성 (외부 프로브)
- 트래픽 · 응답 시간 (Caddy)
- 리소스 (컨테이너 · DB)
- 애플리케이션 (Next.js)
이게 중요한 이유가 있다. 이 화면에는 "응답 시간" 이 두 군데 있다. 바깥에서 잰 것은 DNS 와 네트워크까지 들어가고, Caddy 가 잰 것은 서버 안에서 걸린 시간이다. 둘이 벌어지면 네트워크 쪽 이야기이고, 같이 오르면 우리 쪽이다. 어디서 잰 값인지 이름에 없으면 그 판단이 안 된다.
한 가지 더. 상태 타일에 주소 아홉 개를 세웠더니 이름이 뭉개져 읽히지 않았다. Grafana 가 타일 높이에 맞춰 글자를 줄이는데, 값을 먼저 챙기고 이름을 줄인다. 크기를 직접 정해 주면 된다.
"options": {"text": {"titleSize": 13, "valueSize": 15}, ...}
마지막으로
숫자를 매일 보는 것은 아니다. 다만 "느린데 왜 느린지 모르겠다" 는 상황이 왔을 때, 볼 곳이 있다는 것과 없다는 것은 다르다.
그리고 지표는 만들어 두면 스스로 일을 한다. 이 대시보드를 붙인 날, "안 읽은 메시지" 칸이 1 로 떠 있는 것을 보고 연락 폼에 문의가 하나 들어와 있는 것을 알았다. 그것을 읽음으로 넘길 방법이 화면에 없다는 것도 그때 알았다 — 칸은 DB 에 처음부터 있었는데 그 값을 채우는 화면을 만든 적이 없었다. 지표가 "쓰다 만 기능" 을 찾아 준 셈이다.
댓글
첫 댓글을 남겨보세요.