들어가며

5편까지 프록시의 핵심 기능(풀링, 라우팅, 캐싱)을 통합하고 E2E 테스트로 검증했다. 하지만 운영 환경에서 쓰려면 **관측 가능성(Observability)**과 제어 인터페이스가 필요하다.

이번 편에서 추가한 것:

  1. Prometheus 메트릭 — 쿼리 라우팅, 캐시, 풀 상태를 숫자로 추적
  2. Prepared Statement 라우팅 — Extended Query Protocol의 SELECT도 reader로 보내기
  3. Admin API — 런타임 상태 조회와 캐시 수동 제어

1. Prometheus 메트릭

왜 메트릭인가

로그로는 “지금 캐시 히트율이 몇 %인지”, “reader 풀 연결이 몇 개 열려있는지"를 실시간으로 파악하기 어렵다. Prometheus + Grafana 조합이면 대시보드 하나로 전부 볼 수 있다.

메트릭 설계

type Metrics struct {
    // 쿼리 라우팅
    QueriesRouted  *prometheus.CounterVec    // {target="writer|reader"}
    QueryDuration  *prometheus.HistogramVec  // 쿼리 처리 시간
    ReaderFallback prometheus.Counter        // reader 장애로 writer fallback 횟수

    // 캐시
    CacheHits          prometheus.Counter    // 캐시 히트
    CacheMisses        prometheus.Counter    // 캐시 미스
    CacheEntries       prometheus.Gauge      // 현재 캐시 항목 수
    CacheInvalidations prometheus.Counter    // 캐시 무효화 횟수

    // 커넥션 풀
    PoolOpenConns  *prometheus.GaugeVec      // {role, addr}
    PoolIdleConns  *prometheus.GaugeVec      // {role, addr}
    PoolAcquires   *prometheus.CounterVec    // 커넥션 획득 횟수
    PoolAcquireDur *prometheus.HistogramVec  // 커넥션 획득 대기 시간
}

Counter는 단조 증가, Gauge는 현재값, Histogram은 분포를 추적한다. 라벨(target, role, addr)로 차원을 분리해서 writer/reader별, 서버별로 분석할 수 있다.

계측 위치

메트릭을 어디에 심느냐가 중요하다. 핫 경로에 최소한으로 넣었다:

// relayQueries — 쿼리 라우팅 후
start := time.Now()
if route == router.RouteWriter {
    s.handleWriteQuery(...)
} else {
    s.handleReadQuery(...)
}
if s.metrics != nil {
    s.metrics.QueriesRouted.WithLabelValues(target).Inc()
    s.metrics.QueryDuration.WithLabelValues(target).Observe(time.Since(start).Seconds())
}

// handleReadQuery — 캐시 히트/미스
if cached := s.queryCache.Get(key); cached != nil {
    s.metrics.CacheHits.Inc()
    return clientConn.Write(cached)
}
s.metrics.CacheMisses.Inc()

// handleReadQuery — reader fallback
if readerAddr == "" {
    s.metrics.ReaderFallback.Inc()
    return s.forwardAndRelay(clientConn, writerConn, msg)
}

if s.metrics != nil 가드로 메트릭이 비활성화되면 오버헤드가 0이다.

/metrics 엔드포인트

// main.go
if cfg.Metrics.Enabled {
    mux := http.NewServeMux()
    mux.Handle("/metrics", promhttp.Handler())
    go http.ListenAndServe(cfg.Metrics.Listen, mux)
}

curl http://localhost:9090/metrics로 Prometheus scrape 형식의 메트릭을 확인할 수 있다:

# HELP pgmux_queries_routed_total Total number of queries routed by target.
# TYPE pgmux_queries_routed_total counter
pgmux_queries_routed_total{target="reader"} 142
pgmux_queries_routed_total{target="writer"} 38

# HELP pgmux_cache_hits_total Total number of cache hits.
pgmux_cache_hits_total 89
pgmux_cache_misses_total 53

2. Prepared Statement 라우팅

문제: Extended Query가 전부 writer로 간다

5편에서 Extended Query Protocol을 지원했지만, Parse/Bind/Execute 메시지를 무조건 writer로 전달했다. 실제로 lib/pq$1 파라미터를 쓰는 SELECT도 Extended Query로 보내므로, 이러면 reader를 전혀 활용하지 못한다.

해결: Parse 메시지에서 SQL 추출

PG의 Parse 메시지 포맷:

Parse (P):
  statement_name (string\0)
  query_text     (string\0)
  param_count    (int16)
  param_oids     (int32 × param_count)

여기서 query_text를 추출하면 Classify()로 Read/Write를 판단할 수 있다:

func ParseParseMessage(payload []byte) (stmtName, query string) {
    nameEnd := indexOf(payload, 0)
    stmtName = string(payload[:nameEnd])
    rest := payload[nameEnd+1:]
    queryEnd := indexOf(rest, 0)
    query = string(rest[:queryEnd])
    return stmtName, query
}

세션별 Statement 맵

Prepared statement은 이름(stmt1)으로 참조되므로, Parse 시점에 라우팅 결과를 저장해두고, Bind/Execute에서 참조한다:

type Session struct {
    // ...기존 필드
    stmtRoutes map[string]Route  // statement name → route
}

func (s *Session) RegisterStatement(name, query string) Route {
    route := s.routeLocked(query)
    s.stmtRoutes[name] = route
    return route
}

func (s *Session) StatementRoute(name string) Route {
    if route, ok := s.stmtRoutes[name]; ok {
        return route
    }
    return RouteWriter  // 안전한 기본값
}

unnamed statement("")은 매번 덮어쓰고, Close('S') 메시지가 오면 맵에서 제거한다.

배치 라우팅

Extended Query는 Parse-Bind-Execute가 한 묶음으로 오고, Sync에서 한꺼번에 실행된다. 배치 내에 하나라도 writer 쿼리가 있으면 전체를 writer로 보낸다:

case protocol.MsgParse:
    stmtName, query := protocol.ParseParseMessage(msg.Payload)
    route := session.RegisterStatement(stmtName, query)
    if route == router.RouteWriter {
        extRoute = router.RouteWriter
    }
    extBuf = append(extBuf, msg)

case protocol.MsgSync:
    if extRoute == router.RouteReader {
        s.handleExtendedRead(ctx, clientConn, writerConn, extBuf, msg, ...)
    } else {
        // 배치 전체를 writer로 전달
        for _, m := range extBuf {
            protocol.WriteMessage(writerConn, m.Type, m.Payload)
        }
        protocol.WriteMessage(writerConn, msg.Type, msg.Payload)
        s.relayUntilReady(clientConn, writerConn)
    }
    extBuf = extBuf[:0]  // 배치 리셋

이로써 SELECT * FROM users WHERE id = $1 같은 파라미터 쿼리도 reader로 라우팅된다.

3. Admin API

엔드포인트 설계

MethodPath설명
GET/admin/health백엔드 TCP 연결 상태
GET/admin/stats풀, 캐시 통계
GET/admin/config현재 설정 (비밀번호 마스킹)
POST/admin/cache/flush전체 캐시 비우기
POST/admin/cache/flush/{table}특정 테이블 캐시만 무효화

별도 포트(기본 9091)에서 제공해서 외부 노출을 방지한다.

구현 포인트

Health check — TCP dial로 백엔드 연결 가능 여부를 확인:

func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
    writerAddr := fmt.Sprintf("%s:%d", s.cfg.Writer.Host, s.cfg.Writer.Port)
    writerHealthy := checkTCP(writerAddr)  // net.DialTimeout 2초
    // ... readers도 동일
}

Config 마스킹 — 비밀번호를 "********"로 치환해서 반환:

safe.Backend.Password = "********"

Table-specific flush — URL 경로에서 테이블명을 추출해서 해당 테이블의 캐시만 무효화:

func (s *Server) handleCacheFlush(w http.ResponseWriter, r *http.Request) {
    path := strings.TrimPrefix(r.URL.Path, "/admin/cache/flush/")
    if path != "" {
        s.cache.InvalidateTable(path)  // 테이블별 무효화
    } else {
        s.cache.FlushAll()  // 전체 비우기
    }
}

FlushAll()은 cache에 새로 추가한 메서드로, 모든 항목과 인덱스를 초기화한다.

설정

metrics:
  enabled: true
  listen: "0.0.0.0:9090"

admin:
  enabled: true
  listen: "0.0.0.0:9091"

테스트

Phase 7에서 추가된 테스트:

패키지테스트내용
metrics1Prometheus 메트릭 등록 및 수집 검증
protocol3ParseParseMessage, ParseBindMessage, ParseCloseMessage
router3Prepared statement 라우팅, 트랜잭션 내 동작, unnamed 덮어쓰기
admin6health, stats, config 마스킹, 전체 flush, 테이블 flush, method 제한

전체 테스트: 92건 (서브테스트 포함), 모두 통과.

전체 아키텍처 (최종)

┌─────────────┐
│ Application │
└──────┬──────┘
       │ PG Wire Protocol
┌─────────────────────────────────────┐
│            pgmux                  │
│                                      │
│  ┌──────────┐  ┌──────────────────┐ │
│  │  Parser   │  │ Statement Map   │ │
│  │ (Q/P/B/E) │  │ (name→route)    │ │
│  └─────┬─────┘  └────────────────┘ │
│        │                             │
│  ┌─────▼──────────────┐             │
│  │  Session Router     │  ← Metrics │
│  │  (R/W + R-A-W)      │             │
│  └─────┬──────────┬───┘             │
│        │          │                  │
│  ┌─────▼───┐ ┌───▼──────────┐      │
│  │  Cache  │ │ RoundRobin   │      │
│  │  (LRU)  │ │ + Pool       │      │
│  └─────────┘ └──────────────┘      │
│                                      │
│  ┌──────────────┐ ┌──────────────┐  │
│  │ /metrics     │ │ /admin/*     │  │
│  │ :9090        │ │ :9091        │  │
│  └──────────────┘ └──────────────┘  │
└────────┬──────────────┬──────────────┘
         │              │
    ┌────▼────┐   ┌────▼────┐
    │ Writer  │   │ Readers │
    │(Primary)│   │(Replica)│
    └─────────┘   └─────────┘

마무리

프록시의 핵심 기능은 5편에서 완성했다. 이번 편에서는 운영에 필요한 것들을 추가했다:

  • Prometheus 메트릭: 캐시 히트율, 쿼리 분포, 풀 상태를 실시간으로 모니터링
  • Prepared Statement 라우팅: Extended Query Protocol의 SELECT도 reader로 보내서 reader 활용률 극대화
  • Admin API: 런타임 상태 조회, 수동 캐시 제어, 비밀번호 마스킹

코드를 짜는 것과 운영 가능한 시스템을 만드는 것 사이에는 관측 가능성이라는 간극이 있다. 메트릭과 관리 인터페이스가 그 간극을 메워준다.

프로젝트 소스코드: github.com/jyukki97/pgmux