들어가며

pgmux에는 이미 Prometheus 메트릭(pgmux_query_duration_seconds)과 Audit Log가 있다. 하지만 “어떤 쿼리가 가장 많이 실행되고, 어떤 패턴이 느린가?“를 한눈에 보려면 별도의 집계가 필요하다.

PostgreSQL에는 pg_stat_statements라는 확장이 있다. 쿼리를 정규화하여 패턴별 실행 횟수, 총 실행 시간, 평균 시간 등을 추적한다. 이걸 프록시 레벨에서 구현하면:

  1. DB 접근 없이 쿼리 패턴을 분석할 수 있다
  2. 모든 DB 그룹의 쿼리를 한곳에서 집계할 수 있다
  3. Admin API로 런타임에 조회/초기화할 수 있다

이번 글에서는 Query Digest의 설계와 구현을 다룬다.


쿼리 정규화

Query Digest의 핵심은 정규화(normalization)다. 리터럴 값을 플레이스홀더로 치환하여 같은 패턴의 쿼리를 그룹핑한다:

-- 원본 쿼리들
SELECT * FROM users WHERE id = 42
SELECT * FROM users WHERE id = 7
SELECT * FROM users WHERE id = 100

-- 정규화 결과 (하나의 패턴)
SELECT * FROM users WHERE id = $1

pgmux는 이미 pg_query_go(PostgreSQL C 파서의 cgo 바인딩)를 사용하고 있었다. 이 라이브러리의 Normalize() 함수가 정확히 이 일을 해준다:

import pg_query "github.com/pganalyze/pg_query_go/v5"

normalized, err := pg_query.Normalize("SELECT * FROM users WHERE id = 42")
// normalized = "SELECT * FROM users WHERE id = $1"

이미 Query Mirroring의 mirror/stats.go에서 동일한 함수를 사용하고 있었으므로, 검증된 방법이다.


순환 버퍼와 백분위 계산

패턴별 P50/P99를 계산하려면 최근 N개의 실행 시간 샘플을 유지해야 한다. 무한히 쌓을 수 없으므로 순환 버퍼(circular buffer)를 사용한다:

type patternStats struct {
    mu       sync.Mutex
    count    int64       // 전체 실행 횟수 (버퍼 크기와 무관)
    totalMs  float64     // 전체 누적 시간
    minMs    float64     // 전체 최솟값
    maxMs    float64     // 전체 최댓값
    durs     []time.Duration  // 순환 버퍼
    idx      int              // 다음 쓰기 위치
    maxSamps int              // 버퍼 크기 (기본 1000)
}

버퍼가 가득 차면 가장 오래된 샘플을 덮어쓴다:

func (ps *patternStats) record(dur time.Duration) {
    ps.mu.Lock()
    defer ps.mu.Unlock()

    ps.count++
    ms := durationMs(dur)
    ps.totalMs += ms

    if ps.minMs < 0 || ms < ps.minMs {
        ps.minMs = ms
    }
    if ms > ps.maxMs {
        ps.maxMs = ms
    }

    if len(ps.durs) < ps.maxSamps {
        ps.durs = append(ps.durs, dur)
    } else {
        ps.durs[ps.idx] = dur
        ps.idx = (ps.idx + 1) % ps.maxSamps
    }
}

설계 결정: count, totalMs, minMs, maxMs는 전체 수명 동안의 값이다. 순환 버퍼에는 최근 샘플만 있지만, 이 통계는 리셋 없이 정확하다. P50/P99는 최근 샘플 기반이므로 “현재 성능"을 반영한다.

백분위 계산은 정렬 후 인덱스 접근:

func percentile(sorted []time.Duration, p float64) time.Duration {
    idx := int(float64(len(sorted)-1) * p)
    return sorted[idx]
}

이 패턴은 mirror/stats.go에서 이미 검증된 것을 그대로 가져왔다.


동시성: RWMutex + Double-Checked Locking

쿼리는 수천 개의 고루틴에서 동시에 들어온다. 패턴 맵 접근에 RWMutex + double-checked locking을 사용한다:

func (d *Digest) Record(query string, dur time.Duration) {
    normalized, err := pg_query.Normalize(query)
    if err != nil {
        normalized = query  // 파싱 실패 시 원본 사용
    }

    // 1차: Read Lock으로 기존 패턴 확인 (대부분 여기서 끝남)
    d.mu.RLock()
    ps, ok := d.patterns[normalized]
    d.mu.RUnlock()

    if !ok {
        // 2차: Write Lock으로 새 패턴 추가
        d.mu.Lock()
        ps, ok = d.patterns[normalized]
        if !ok {
            if len(d.patterns) >= d.maxPatterns {
                d.mu.Unlock()
                return  // 최대 패턴 수 초과 시 드롭
            }
            ps = newPatternStats(d.maxSamples)
            d.patterns[normalized] = ps
        }
        d.mu.Unlock()
    }

    ps.record(dur)
}

왜 이렇게?

  • 대부분의 쿼리는 이미 존재하는 패턴이다. RLock만으로 처리된다.
  • 새 패턴은 드물다. Write Lock 구간이 짧아 경합이 적다.
  • maxPatterns로 메모리 상한을 보장한다. 공격적인 쿼리 패턴 폭발을 방지.

메모리 바운드

프록시는 장기 실행 프로세스다. 메모리가 무한히 늘어나면 안 된다:

설정기본값역할
max_patterns1000추적할 최대 고유 패턴 수
samples_per_pattern1000패턴별 P50/P99 계산용 샘플 수

최대 메모리 = 1000 패턴 x 1000 샘플 x 8 bytes = ~8MB. 프록시 전체 메모리에서 무시할 수 있는 수준이다.


쿼리 경로에 삽입

Simple Query Protocol과 Extended Query Protocol 모두에서 기록한다:

// query.go — Simple Query Protocol (line 210)
elapsed := time.Since(start)
s.emitAuditEvent(clientConn, query, target, elapsed, false)
s.recordDigest(query, elapsed)  // ← 추가
s.mirrorQuery(msg, query, qtype, elapsed, parsedQuery)

// query.go — Extended Query Protocol (line 476)
s.emitAuditEvent(clientConn, "(extended query)", target, elapsed, false)
s.recordDigest("(extended query)", elapsed)  // ← 추가

recordDigest는 nil 체크를 포함한 래퍼다:

func (s *Server) recordDigest(query string, elapsed time.Duration) {
    if s.queryDigest == nil {
        return
    }
    s.queryDigest.Record(query, elapsed)
    if s.metrics != nil {
        s.metrics.DigestPatterns.Set(float64(s.queryDigest.PatternCount()))
    }
}

Prometheus Gauge도 함께 업데이트하여, 외부 모니터링에서도 패턴 수를 추적할 수 있다.


Admin API 엔드포인트

두 개의 엔드포인트를 추가했다:

GET /admin/queries/top

curl http://localhost:9091/admin/queries/top | jq
[
  {
    "query_pattern": "SELECT * FROM users WHERE id = $1",
    "count": 15423,
    "total_ms": 31205.4,
    "avg_ms": 2.02,
    "min_ms": 0.3,
    "max_ms": 150.2,
    "p50_ms": 1.5,
    "p99_ms": 12.3
  },
  {
    "query_pattern": "INSERT INTO logs (msg, created_at) VALUES ($1, $2)",
    "count": 8201,
    "total_ms": 49206.0,
    "avg_ms": 6.0,
    "min_ms": 1.1,
    "max_ms": 500.5,
    "p50_ms": 4.2,
    "p99_ms": 45.0
  }
]

실행 횟수 내림차순으로 정렬된 상위 100개 패턴을 반환한다.

POST /admin/queries/reset

통계를 초기화한다. 배포 후 새로운 기준선을 잡거나, 특정 시간대의 통계만 보고 싶을 때 유용하다.


설정

digest:
  enabled: true
  max_patterns: 1000         # 추적할 최대 고유 패턴 수
  samples_per_pattern: 1000  # 패턴별 P50/P99 계산용 샘플 수

enabled: false이면 아무것도 하지 않는다. pg_query.Normalize() 호출 비용(수 마이크로초)도 발생하지 않는다.


mirror/stats.go와의 관계

Query Mirroring의 mirror/stats.go와 Query Digest의 digest/digest.go는 구조가 거의 동일하다:

mirror/stats.godigest/digest.go
목적Primary vs Mirror 레이턴시 비교쿼리 패턴별 실행 통계
정규화pg_query.Normalize()pg_query.Normalize()
버퍼순환 버퍼 1000개순환 버퍼 (configurable)
백분위P50, P99P50, P99
추가 통계회귀 감지avg, min, max, total
메모리 제한없음max_patterns

의도적으로 별도 패키지로 분리했다. Mirror의 통계는 Mirror에 종속된 것이고, Digest는 독립적인 관측성 기능이다. 구조가 비슷하다고 합치면 책임이 모호해진다.


테스트

7개 단위 테스트를 작성했다:

=== RUN   TestDigestRecord         — 정규화 + 패턴 그룹핑
=== RUN   TestDigestTopN           — 상위 N개 반환
=== RUN   TestDigestMaxPatterns    — 최대 패턴 수 제한
=== RUN   TestDigestP50P99         — 백분위 정확도
=== RUN   TestDigestReset          — 통계 초기화
=== RUN   TestDigestConcurrency    — 100 고루틴 동시 기록
=== RUN   TestDigestCircularBuffer — 버퍼 초과 시 덮어쓰기
PASS (0.17s)

마무리

Query Digest는 pg_stat_statements의 프록시 버전이다. DB에 확장을 설치하지 않아도, 그리고 Multi-DB 환경에서도 모든 쿼리 패턴을 한곳에서 분석할 수 있다.

구현량은 적다 — digest.go 200줄, 나머지는 기존 코드에 호출 한 줄씩 추가. 기존 mirror/stats.go의 패턴을 재활용했기 때문이다. “비슷한 구조를 다른 목적으로 쓸 때 복사해서 독립 패키지로 만드는 것"이 Go에서는 자연스러운 선택이다.

GitHub PR #174