[관측 확장 #1] 활동 모니터를 PostgreSQL 위에 다시 그리기 — 누적 카운터와 순간 표본은 다른 물건이다

한 줄 요약: 진단 화면은 베낄 수 있지만 그 화면을 떠받치는 계측은 베낄 수 없습니다. SQL Server 의 활동 모니터를 PostgreSQL 대시보드로 옮기면서, 같은 창이라도 뒤에 있는 데이터의 성격이 다르면 그 창이 답할 수 있는 질문도 달라진다는 것을 정리했습니다. 핵심은 누적 카운터와 순간 표본의 차이입니다.

이 글의 위치: 기술 블로그 → 인프라 · 데이터베이스. 작성·검증 방식은 Editorial Policy 참조. 관측 스택 자체를 세우는 과정은 홈서버 하나로 관측성부터 GitOps까지에 있고, 이 글은 그 위에 올린 PostgreSQL 대시보드를 진단용 화면으로 한 단계 밀어붙이면서 만난 개념들을 정리한 것입니다.


진단 화면은 왜 그 순서로 생겼나

DB 관측 화면을 새로 만들 때 가장 먼저 정해야 하는 것은 패널 종류가 아니라 질문의 순서입니다. 상용 도구의 진단 화면이 참고할 만한 이유가 여기에 있습니다. 오래 다듬어진 화면일수록 위에서 아래로 읽으면 그대로 범위를 좁혀가는 순서가 됩니다.

묻는 순서 질문 필요한 데이터의 성격
개요 전체가 바쁜가? 현재 값 · 게이지
프로세스 누가 붙어 있나? 지금 이 순간의 목록
리소스 대기 무엇을 기다리느라 못 나가고 있나? 구간 누적 또는 반복 표본
파일 I/O 저장장치가 발목을 잡나? 누적 카운터
비용이 큰 쿼리 범인이 누구인가? 쿼리 단위 누적 통계

이 순서 자체는 특정 엔진의 지혜가 아니라 RDBMS 일반에 통합니다. 그래서 빌릴 가치가 있습니다. 다만 오른쪽 열이 문제입니다. 같은 순서를 다른 엔진 위에 올리려면, 그 엔진이 각 질문에 답할 데이터를 어떤 형태로 주는지부터 확인해야 합니다. 여기서부터 화면은 공짜인데 데이터는 공짜가 아니라는 이야기가 시작됩니다.

출발점 — 무엇이 보이고, 무엇을 물을 수 없나

연결 수, DB별 크기, 트랜잭션, 캐시 적중률 패널이 있는 PostgreSQL Grafana 대시보드
연결·크기·처리량·디스크 축만 있는 상태의 PostgreSQL 대시보드. 값은 전부 정상으로 보입니다.

이 시점의 대시보드가 답하는 질문은 “얼마나 있나”였습니다. 연결이 몇 개인지, DB 가 몇 MiB 인지, 초당 커밋이 몇 건인지. 나쁘지 않지만 진단 순서와 나란히 놓으면 빈자리가 분명합니다. “바쁘다”는 알 수 있는데 “왜 바쁜지”를 물을 수 없습니다.

연결 수는 보이지만 그 연결들이 무엇을 기다리는지가 없고, 트랜잭션 수는 보이지만 어떤 쿼리가 비싼지가 없습니다. 채워야 할 축은 셋이었습니다 — 대기 · 비용이 큰 쿼리 · 파일 I/O. 아래는 그 셋을 하나씩 붙이면서 만난 개념들입니다.

대기 통계 — 누적 카운터와 순간 표본은 다른 물건이다

세 축 중 성격이 가장 크게 갈린 것이 대기(wait)입니다. 그리고 이 차이는 PostgreSQL 만의 사정이 아니라 관측 지표 일반에 적용되는 구분이라, 이 글에서 가장 오래 다룰 값이 있습니다.

누적 카운터와 순간 표본의 차이를 t0~t1 구간으로 비교한 개념도
같은 대기 그래프라도, 엔진이 누적을 세어 주느냐 순간 상태만 알려주느냐에 따라 그 그래프가 보장하는 것이 다릅니다.

누적 카운터는 엔진이 대기 유형별 총 대기 시간을 계속 더해 줍니다. SQL Server 의 sys.dm_os_wait_stats 가 이 형태입니다. 두 시점의 값을 빼면 그 구간에 그 유형으로 얼마나 기다렸는지가 바로 나옵니다. 중요한 것은 하한이 없다는 점입니다. 1 ms 짜리 대기도 합계에 들어가 있습니다.

PostgreSQL 에는 그런 뷰가 없습니다. pg_stat_activitywait_event_type · wait_event 컬럼은 이름이 비슷해 대응되는 것처럼 보이지만 성격이 다릅니다. 이 컬럼들은 조회하는 그 순간 각 백엔드가 무엇을 기다리고 있는지만 알려 줍니다. 누적이 아니라 스냅샷입니다.

  • 누적: “지난 1시간 동안 PAGEIOLATCH_SH 로 총 42초 기다렸다” → 뺄셈 한 번
  • 표본: “지금 이 순간 3번 백엔드가 DataFileRead 를 기다리는 중이다” → 1시간 치를 알려면 그 순간을 여러 번 모아야 한다

즉 표본만 주는 엔진에서 대기 창을 재현하려면, 없는 누적값을 표본에서 직접 만들어야 합니다. 자주 찍어서 대기 유형별 등장 횟수를 세고, 그 빈도를 분포로 읽는 방식입니다. 통계적 프로파일링이지 정확한 회계가 아닙니다. 이 문장이 이 절의 전부라고 해도 됩니다.

표본을 쌓는 자리 — 확장이냐 exporter 냐

표본을 쌓는 방법은 크게 둘입니다. 하나는 pg_wait_sampling 처럼 DB 안에서 샘플링해 주는 확장을 얹는 것이고, 다른 하나는 바깥의 exporter 가 스크레이프마다 집계하는 것입니다. 전자가 정확하지만 이미지에 확장이 들어 있어야 하고(공식 alpine 이미지에는 없습니다) 없으면 이미지를 포크해야 합니다. 유지 비용을 감안해 후자를 골랐습니다.

SELECT
    coalesce(wait_event_type, 'Running') AS wait_event_type,
    coalesce(wait_event,      'Running') AS wait_event,
    count(*)                             AS backends
FROM pg_stat_activity
WHERE backend_type = 'client backend'
  AND pid <> pg_backend_pid()
  AND state IS DISTINCT FROM 'idle'
GROUP BY 1, 2

여기서 개념적으로 중요한 조건이 state = 'idle' 제외입니다. 유휴 커넥션은 전부 Client/ClientRead 로 잡히는데, 이건 DB 가 기다리는 것이 아니라 커넥션 풀이 연결을 물고만 있는 상태입니다. 빼지 않으면 이 값 하나가 나머지 대기를 전부 덮어버립니다. 커넥션 수 자체는 이미 다른 패널이 답하고 있으므로 대기 패널에서는 셈에서 뺐습니다.

exporter 를 하나 더 띄울 때 주의할 것이 둘 있었습니다. 첫째, 자동 DB 탐색 모드에서 커스텀 쿼리를 얹으면 DB 마다 한 번씩 실행됩니다. 클러스터 전역 뷰를 읽는 쿼리라면 같은 값의 시계열이 DB 수만큼 생깁니다. 둘째, --disable-default-metrics 는 레거시 기본 지표만 끄고 신형 collector 는 그대로 둡니다. --no-collector.<이름> 로 하나씩 꺼야 기존 exporter 와 지표가 겹치지 않습니다.

표본 주기가 곧 해상도의 하한이다

스크레이프 주기를 15초로 잡았습니다. 같은 대시보드의 다른 수집 대상이 60초인데 여기만 다릅니다. 누적 카운터라면 주기는 그래프의 매끄러움만 좌우하지만, 순간 표본에서는 주기가 곧 표본 주기이고 그게 곧 해상도의 하한이기 때문입니다. 60초면 1분에 한 번 훔쳐보는 셈이라 분포라고 부를 수 없습니다.

그럼 15초는 충분한가. 이건 논증이 아니라 측정으로 답할 문제라, 같은 구간을 1초 간격으로도 재서 나란히 놓았습니다. 락 경합 5 세션과 공유버퍼보다 큰 테이블을 훑는 3 세션을 90초 동안 걸어 둔 구간입니다.

대기 1초 실측 (66 표본) 15초 표본 (7 표본) 차이
Lock/tuple 51.9 % 50.0 % −1.9 %p
Timeout/PgSleep 17.3 % 16.7 % −0.6 %p
Lock/transactionid 17.3 % 16.7 % −0.6 %p
Running (대기 없음) 9.6 % 13.9 % +4.3 %p
IO/DataFileRead 2.4 % 2.8 % +0.4 %p
LWLock/BufferMapping 1.1 % 0 % 사라짐
IPC/ExecuteGather 0.3 % 0 % 사라짐
IPC/BgWorkerShutdown 0.3 % 0 % 사라짐
관측된 대기 종류 8 종 5 종 3 종 소실

읽는 법은 이렇습니다. 상위 5 종의 비중은 ±4.3 %p 안에서 재현됩니다. 15초 표본으로도 “지금 무엇이 이 DB 를 붙들고 있는가”에는 제대로 답합니다. 그런데 1 % 미만으로 등장하는 대기는 통째로 사라졌습니다. 존재하지 않아서가 아니라 표본이 그걸 잡을 만큼 촘촘하지 않아서입니다.

표본 주기 15초  →  15초에 한 번 스치는 대기만 보인다
                   그보다 드물거나 짧은 것은 있어도 0 으로 찍힌다

큰 것은 맞히고 작은 것은 못 봅니다. 그게 이 방식의 정직한 성능이고, 주기를 줄여도 사라지지 않는 성질입니다 — 1초로 줄이면 하한이 1초로 내려갈 뿐 “표본보다 짧은 것은 안 보인다”는 구조는 그대로입니다. 그래서 패널 설명에 표본 주기를 적고, 그 값을 그대로 보여주는 stat 패널을 옆에 하나 뒀습니다. 숨기면 없는 정밀도를 있는 것처럼 읽게 되기 때문입니다.

비용이 큰 쿼리 — 데이터는 쉬운데 카디널리티가 어렵다

이 축은 대응이 비교적 깔끔합니다. pg_stat_statements 확장이 정규화된 쿼리별로 호출 수·총 실행시간·평균·I/O 를 누적해 줍니다. 성격이 누적이라 rate() 를 씌우면 그대로 시계열이 됩니다.

대신 전제가 하나 있습니다. 확장이라 미리 켜져 있어야 하고, shared_preload_libraries 에 넣는 일이라 재시작이 필요합니다. 즉 이 패널을 붙이는 작업은 “확장을 설치”가 아니라 계획된 재시작을 하나 사는 일입니다. 운영 중인 DB 라면 이 글에서 유일하게 창이 필요한 지점이고, 재시작 없이 켜는 방법은 없습니다.

args:
  - -c
  - shared_preload_libraries=pg_stat_statements
  - -c
  - pg_stat_statements.max=1000
  - -c
  - pg_stat_statements.track=top      # all 로 두면 함수 내부 문장까지 잡힌다
  - -c
  - track_io_timing=on                # 아래 파일 I/O 절에서 필요

라이브러리를 올리는 것과 뷰를 만드는 것은 별개라 재시작 뒤 CREATE EXTENSION 이 한 번 더 필요합니다. track=top 을 고른 이유는 화면이 필요로 하는 것이 최상위 문장이기 때문입니다. all 은 함수 내부 문장까지 전부 추적해 코어가 적은 서버에서는 값에 비해 비쌉니다.

화면이 공짜로 하던 일 — 정렬하고 버리기

여기가 원본 화면을 그대로 옮기면 안 되는 지점입니다. pg_stat_statements 는 정규화된 쿼리마다 한 행이라 워크로드에 따라 수천 행이 됩니다. queryid 를 라벨로 붙여 전부 시계열로 내보내면 그 수만큼 시계열이 생기고, 쿼리 구성이 바뀔 때마다 라벨 값 집합이 갈립니다. 카디널리티 폭발의 교과서적 형태입니다.

원본 화면에는 이 문제가 없습니다. 정렬해서 상위 N 개만 보여주고 나머지는 버리기 때문입니다. 화면이니까 공짜로 되는 일이고, 시계열 DB 에서는 그 “버리는 일”을 수집 단계에서 명시적으로 해야 합니다.

-- exporter 커스텀 쿼리. 상위 N 개만 내보낸다.
SELECT
    queryid::text,
    calls,
    total_exec_time,
    mean_exec_time,
    shared_blks_read
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

쿼리 텍스트를 라벨에 넣고 싶은 유혹이 강한데 더 나쁩니다. 라벨 값이 길고 고유해 저장 비용이 크고, 무엇보다 테이블명·컬럼명이 메트릭에 그대로 실려 나갑니다. queryid 만 내보내고 실제 텍스트는 필요할 때 DB 에서 되짚는 편이 낫습니다.

SELECT query FROM pg_stat_statements WHERE queryid = -4536548328108278500;

다만 queryid객체가 바뀌면 달라집니다. 그래서 그래프에서 눈에 띄는 선을 발견하고 바로 DB 에 물어보는 흐름은 잘 되지만, “지난주에 문제였던 그 쿼리”를 id 로 기억해 두는 용도로는 쓸 수 없습니다.

이 패널을 혼자 보면 틀린 답이 나온다

부하를 걸어 두고 상위 쿼리를 보면 이런 그림이 나옵니다.

queryid               calls  total_ms   mean_ms   blks_read  query
-4536548328108278500      5  354,761.9  70,952.38    140,079  UPDATE ... WHERE id = $1
 5567840230862044934    606   94,699.6     156.27 16,291,236  SELECT count(*) ... LIKE
-3416356442043621232      1   90,030.3  90,030.26          0  SELECT pg_sleep($1)

1 위 UPDATE 의 평균이 70초입니다. 일이 무거워서가 아닙니다. 다른 트랜잭션이 그 행을 잡고 있어서 기다린 시간입니다. total_exec_time 은 락 대기를 포함하기 때문입니다. 3 위가 pg_sleep 인 것도 같은 이유입니다.

비용 패널만 본 결론 :  이 UPDATE 를 튜닝해야 한다      ← 틀림
대기 패널을 같이 본 결론:  Lock/tuple 로 51.9 % 가 묶여 있다
                          → 그 행을 붙들고 있는 쪽이 문제다

원본 화면이 “리소스 대기”와 “비용이 큰 쿼리”를 나란히 놓은 것은 화면을 채우려는 배치가 아니었습니다. 한쪽만 보면 오독하기 때문에 같이 보라고 나눈 것입니다. 옮길 때 이 배치까지 같이 옮겨야 합니다.

파일 I/O — 축이 달라지면 질문이 달라진다

원본의 데이터 파일 I/O 창은 물리 파일 단위로 읽기/쓰기량과 응답시간을 보여줍니다. PostgreSQL 16 의 pg_stat_io 는 같은 축이 아닙니다 — 백엔드 유형 · 대상 · 컨텍스트 단위로 읽기/쓰기/확장/적중을 나눠 줍니다. 그래서 질문이 “어느 파일이 느린가”에서 “어떤 작업이 I/O 를 유발하는가”로 바뀝니다.

전제도 하나 있습니다. read_time · write_timetrack_io_timing = on 이라야 채워집니다. 꺼져 있으면 두 컬럼이 전부 0 인데, 0 은 “빠르다”와 구분되지 않습니다.

축이 바뀐 것이 손해가 아니라는 걸 보여주는 관측이 하나 있었습니다. 부하를 걸고 본 결과입니다.

backend_type        object    context     reads       hits        read_ms   ms/read
background worker   relation  bulkread    10,601,653   5,710,067  29,994.3    0.003
client backend      relation  bulkread     5,830,903   3,171,795  16,487.9    0.003
client backend      relation  normal          41,321 154,443,434     122.8    0.003
autovacuum worker   relation  vacuum          59,069      14,099     119.2    0.002

읽기의 최대 주체가 client backend 가 아니라 background worker 입니다. 병렬 쿼리 워커입니다. 세션 3 개를 던졌는데 실제로 저장장치를 때린 것은 그 세션들이 띄운 워커였습니다. 파일 단위로 봤다면 “이 테이블이 많이 읽힌다”까지만 나오고 누가 읽는지는 안 나왔을 정보입니다.

두 번째로, 블록당 읽기 지연이 0.003 ms — 3 마이크로초입니다. 이건 저장장치 속도가 아니라 OS 페이지 캐시입니다. pg_stat_ioreads 는 “PostgreSQL 의 공유 버퍼에 없어서 OS 에 요청한 블록”이지 “디스크에서 온 블록”이 아닙니다. 둘은 다릅니다. 그렇게 읽어댔는데도 IO/DataFileRead 대기가 2.4 % 밖에 안 나온 이유가 이것입니다 — 기다릴 일이 없었던 겁니다.

이 서버의 저장장치·네트워크 계층을 따로 재본 적이 있는데(측정 보고서), 거기서 나온 “페이지 캐시 858 MB/s vs 실제 디스크 150~333 MB/s”와 같은 이야기입니다. 지표를 볼 때 그 값이 어느 계층에서 온 건지 모르면 엉뚱한 결론으로 갑니다. 여기서는 ms/read 가 수백 마이크로초로 올라가는 순간이 “이제 진짜 디스크를 때리기 시작했다”는 신호입니다.

그래서 패널을 처리량(초당 읽기/쓰기 블록)과 지연(블록당 ms)으로 나눴습니다. 처리량만 보면 캐시에서 읽는 것과 디스크에서 읽는 것이 구분되지 않습니다.

세 축을 붙인 뒤 — 이전에는 물을 수 없던 것들

리소스 대기, 백엔드 상태, 비용이 큰 쿼리, 데이터 파일 I/O 패널이 나란히 배치된 Grafana 대시보드
대기 · 백엔드 상태 · 비용이 큰 쿼리 · 파일 I/O 를 한 줄에 붙인 화면. 맨 아래 stat 이 대기 표본 주기(15s)입니다.

먼저 분명히 해 둘 것은, 아래 어느 것도 “장애를 잡았다”는 이야기가 아니라는 점입니다. 이 서버는 지금 멀쩡합니다. 그런데 멀쩡하다는 걸 아는 것도 이전에는 못 하던 일이었습니다.

1. “값을 볼 수 없는 상태”는 그 값이 없는 화면으로는 드러나지 않는다

축을 붙이면서 가장 먼저 드러난 것은 대기 분포가 아니라 계측 자체가 꺼져 있었다는 사실이었습니다.

shared_preload_libraries =            (빈 값)
track_io_timing          = off

이전 대시보드로는 이걸 볼 방법이 없습니다. 연결 수도, DB 크기도, 트랜잭션 수도 전부 정상으로 나옵니다. 빈 패널이 없으니 빠진 것이 없어 보였던 것입니다. 참조 화면을 기준으로 삼는 것의 가장 큰 소득이 여기였습니다 — 빈칸이 어디인지 알려주는 화면이 있으면, 없는 것을 없다고 알아볼 수 있습니다.

2. 정상 상태에서 대기 패널이 비어 있는 것은 고장이 아니라 정보다

부하를 걷어내고 보면 대기 패널은 거의 비어 있습니다. 4 분(15초 표본 16 개) 동안 대기 중인 백엔드가 잡힌 표본이 1 개였습니다.

클라이언트 백엔드 24 개 중  active 1 · idle 23 · idle in transaction 0
공유버퍼 적중률             99.973 % (client backend, normal 컨텍스트)
                            99.982 % (autovacuum worker)

이 값이 볼 가치가 있는 이유는, 앞으로 이 서버가 느려질 때 DB 를 후보에서 뺄 수 있는 근거가 되기 때문입니다. 이전에는 “연결 24 개, 캐시 적중률 높음”까지만 보였고 그건 “DB 가 한가한가”에 대한 답이 아니었습니다. idle in transaction 이 0 이라는 것도 이제야 보입니다 — 이 값이 쌓이기 시작하면 애플리케이션이 트랜잭션을 열어두고 놓지 않는다는 뜻이고, 락 대기의 선행 지표입니다.

3. 남겨두는 한계

정직하게 적어 둡니다. 실제 트래픽에서 이 패널들이 무언가를 잡아낸 적은 아직 없습니다. 위의 대기 분포는 합성 부하에서 나온 값입니다. 계측이 맞게 작동한다는 것까지는 확인했지만 그 이상은 아니고, 생기면 그때 씁니다.

정리 — 화면은 이식이 쉽고 데이터는 어렵다

진단 화면을 다른 엔진 위에 옮길 때 실제로 옮겨지는 것은 질문의 순서와 배치이고, 옮겨지지 않는 것은 그 질문에 답하는 데이터의 성격입니다. 이번 작업에서 세 축이 각각 달랐습니다.

PostgreSQL 쪽 사정 옮길 때 해야 하는 일
비용이 큰 쿼리 누적 통계가 그대로 있다 상위 N 만 남기고 버리기 — 카디널리티
파일 I/O 형태가 다르다 (파일 → 백엔드·컨텍스트) 질문을 바꿔서 읽기 · 캐시와 디스크 구분
리소스 대기 아예 없다 (순간 표본만) 표본을 쌓아 분포로 만들고, 표본 주기를 함께 표시

레퍼런스로 삼을 화면을 고를 때는 그 화면이 어떤 엔진의 어떤 계측 위에 서 있는지까지 같이 봐야 한다는 것. 그게 이번에 남은 문장입니다.

다음 편 — 같은 문제를 지도 위에서

참고 문서

다음 글은 접속자 IP 를 좌표로 바꿔 지도에 찍는 작업입니다. 흥미로운 것은 이 글의 카디널리티 문제가 거기서 그대로 반복된다는 점입니다. 좌표는 사실상 연속값이라 라벨에 넣는 순간 시계열이 접속 IP 수만큼 생깁니다. queryid 와 정확히 같은 실수를 훨씬 큰 규모로 저지르는 셈입니다.

다만 결론이 달랐습니다. 여기서는 상위 20 개만 남기고 버릴 수 있었는데, 거기서는 버릴 방법이 막혀 있어 감수하고 감시하는 쪽으로 정리됐습니다. 그 이야기와, 지도 4장을 세우며 만난 똑같이 생긴 “No data” 네 가지2편에 적었습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다