이채강

읽는 데 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 를 낼 수 있는 앱이면 방식은 같다.

그림2-1.png


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. 누구에게 줄지

앱 안쪽 숫자는 바깥에 낼 것이 아니다. 문을 세 겹으로 둔다.

어디무엇을 막나
공개 도메인 /metricsCaddy 가 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/admin403
공개 도메인에서는curl -o /dev/null -w '%{http_code}' https://도메인/metrics404
사설 주소를 적어 보내면curl -H 'X-Forwarded-For: 127.0.0.1, 8.8.8.8' -o /dev/null -w '%{http_code}' https://도메인/metrics404
타깃이 붙었나Prometheus → Status → Targetsmyhome-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")

gridPosx·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 에 처음부터 있었는데 그 값을 채우는 화면을 만든 적이 없었다. 지표가 "쓰다 만 기능" 을 찾아 준 셈이다.

댓글

첫 댓글을 남겨보세요.

이름 20자, 댓글 1000자까지

← 글 목록으로