들어가며

이전 글에서 Per-User/Per-IP Rate Limiting을 구현했다. 이번에는 프록시의 cold start 문제를 해결한다.

pgmux의 커넥션 풀은 기존에 모든 연결을 lazy creation으로 처리했다. 설정에 min_connections: 5가 있어도 풀 생성 시점에는 연결을 하나도 만들지 않고, 첫 클라이언트 요청이 올 때 비로소 연결했다. 문제는 프록시 재시작이나 config reload 직후, 첫 N개 요청이 연결 수립 비용을 고스란히 부담한다는 것이다.

이번 구현의 목표:

  • 프록시 시작 시 min_connections까지 백그라운드 사전 연결
  • Config reload로 새 Reader 추가 시에도 즉시 warming
  • 실패해도 프록시 기동을 막지 않는 fault-tolerant 설계

기존 구조의 문제

Pool.New()의 동기 생성

원래 Pool.New()에는 MinConnections만큼 동기적으로 연결을 생성하는 코드가 있었다:

// 이전 코드
for i := 0; i < cfg.MinConnections; i++ {
    conn, err := p.newConn()
    if err != nil {
        p.Close()
        return nil, fmt.Errorf("pre-create connection %d: %w", i, err)
    }
    p.idle = append(p.idle, conn)
    p.numOpen++
}

하지만 newDatabaseGroup()에서는 이 값을 항상 0으로 설정했다:

wp, err := pool.New(pool.Config{
    MinConnections: 0, // lazy creation; backend may not be ready at startup
    // ...
})

이유는 프록시 시작 시점에 백엔드가 준비되지 않았을 수 있기 때문이다. Writer가 아직 부팅 중일 때 동기 연결을 시도하면 프록시 전체가 기동에 실패한다.

결과적으로 MinConnections 설정값은 health check 주기까지 효과가 없었다. health check 간격이 idle_timeout / 2 (기본 5분)이므로, 프록시 시작 후 최대 5분간 모든 연결이 on-demand로 생성됐다.


설계: New와 Warm의 분리

핵심 아이디어는 풀 생성과 사전 연결을 분리하는 것이다:

단계역할블로킹실패 시
Pool.New()풀 구조체 초기화만즉시 반환에러 반환
Pool.Warm(ctx)MinConnections까지 백그라운드 연결비차단 (goroutine)로그만 남김
healthCheck()주기적으로 MinConnections 유지비차단로그만 남김
시작                   Warm goroutine           Health Check
 │                         │                       │
 ├─ Pool.New() ────────────┤                       │
 │  (구조체만 생성)         │                       │
 │                         │                       │
 ├─ Pool.Warm(ctx) ────►   ├─ conn 1 생성          │
 │  (즉시 반환)            ├─ conn 2 생성          │
 │                         ├─ conn N 생성          │
 │                         └─ "pool warmed" 로그   │
 │                                                  │
 │  ... 5분 후 ...                                  │
 │                                                  ├─ 만료 연결 정리
 │                                                  ├─ MinConnections 미만이면 보충
 │                                                  └─ 반복

구현

Pool.Warm()

func (p *Pool) Warm(ctx context.Context) {
    target := p.cfg.MinConnections
    if target <= 0 {
        return
    }

    go func() {
        var created int
        for i := 0; i < target; i++ {
            select {
            case <-ctx.Done():
                return
            case <-p.done:
                return
            default:
            }

            p.mu.Lock()
            if p.closed || p.numOpen >= target {
                p.mu.Unlock()
                break
            }
            p.numOpen++ // 슬롯 예약
            p.mu.Unlock()

            conn, err := p.newConn()

            p.mu.Lock()
            if err != nil {
                p.numOpen--
                p.mu.Unlock()
                slog.Warn("warm: pre-create connection failed",
                    "addr", p.cfg.Addr, "error", err)
                continue // 실패해도 다음 연결 시도
            }
            if p.closed {
                p.numOpen--
                p.mu.Unlock()
                conn.Close()
                return
            }
            p.idle = append(p.idle, conn)
            created++
            p.mu.Unlock()
        }
        if created > 0 {
            slog.Info("pool warmed", "addr", p.cfg.Addr,
                "connections", created, "target", target)
        }
    }()
}

주요 설계 결정:

  1. 비차단: go func()로 즉시 반환. 프록시 기동을 막지 않는다.
  2. Fault-tolerant: 개별 연결 실패는 continue로 건너뛴다. 백엔드 부팅 지연이나 일시적 네트워크 문제에 대응.
  3. 세 가지 종료 조건: ctx.Done() (전역 셧다운), p.done (풀 Close), p.closed (double check).
  4. 슬롯 예약 패턴: numOpen++ → unlock → dial → lock → idle에 추가. health check의 replenish 패턴과 동일.

DatabaseGroup.WarmPools()

func (g *DatabaseGroup) WarmPools(ctx context.Context) {
    if g.writerPool != nil {
        g.writerPool.Warm(ctx)
    }
    g.mu.RLock()
    pools := g.readerPools
    g.mu.RUnlock()
    for _, p := range pools {
        p.Warm(ctx)
    }
}

Writer 1개 + Reader N개의 모든 풀을 한 번에 warming한다. 각 Warm()이 goroutine을 띄우므로 모든 백엔드에 대해 병렬로 사전 연결된다.

Server에서의 호출

시작 시 — health check 시작 직후:

for name, dbg := range s.dbGroups {
    // ... health check 시작 ...
    dbg.WarmPools(ctx)
}

Reload 시 — 기존/새 그룹 모두:

for name, dbCfg := range newDBs {
    if existing, ok := s.dbGroups[name]; ok {
        existing.Reload(dbCfg, newCfg.CircuitBreaker)
        existing.WarmPools(context.Background())
    } else {
        dbg := newDatabaseGroup(name, dbCfg, newCfg.CircuitBreaker)
        dbg.WarmPools(context.Background())
        s.dbGroups[name] = dbg
    }
}

Reload에서는 context.Background()를 사용한다. 풀의 done 채널이 Close 시 goroutine을 정리하므로 lifecycle 관리는 풀 자체가 담당한다.


역할 정리: Warm vs Health Check

WarmHealth Check
실행 시점시작/reload 시 1회idle_timeout / 2 주기 반복
목적초기 사전 연결만료 정리 + 최소 유지
실패 처리건너뛰고 계속건너뛰고 다음 주기
완료 후goroutine 종료풀 종료까지 반복

Warm은 “빠른 초기화”, Health Check은 “지속적 유지"를 담당한다. MinConnections 설정값 하나로 두 메커니즘이 동작하므로 추가 설정이 필요 없다.


메트릭

기존 풀 게이지로도 warming 효과를 관찰할 수 있다:

  • pgmux_pool_connections_open — warming 직후 min_connections까지 증가
  • pgmux_pool_connections_idle — 동일하게 증가

추가로 warming 전용 카운터를 도입했다:

  • pgmux_pool_warm_connections_total — 사전 생성된 커넥션 수 (role/addr별)
  • pgmux_pool_warm_errors_total — warming 실패 횟수 (role/addr별)

테스트

Pool.Warm 기본 동작

func TestPool_Warm(t *testing.T) {
    p, _ := New(Config{MinConnections: 3, MaxConnections: 10, ...})
    defer p.Close()

    p.Warm(context.Background())
    time.Sleep(200 * time.Millisecond) // 백그라운드 완료 대기

    numOpen, numIdle := p.Stats()
    // numOpen == 3, numIdle == 3
}

Context 취소 시 조기 종료

func TestPool_WarmRespectsCancel(t *testing.T) {
    p, _ := New(Config{MinConnections: 5, MaxConnections: 10, ...})
    ctx, cancel := context.WithCancel(context.Background())
    cancel() // 즉시 취소
    p.Warm(ctx)

    time.Sleep(100 * time.Millisecond)
    numOpen, _ := p.Stats()
    // numOpen < 5 (대부분 0)
}

New()는 더 이상 사전 생성하지 않음

func TestPool_NewDoesNotPreCreate(t *testing.T) {
    p, _ := New(Config{MinConnections: 3, MaxConnections: 10, ...})
    numOpen, numIdle := p.Stats()
    // numOpen == 0, numIdle == 0
}

Race detector 포함 전체 테스트 통과:

go test -race ./internal/pool/ → PASS
go test ./internal/... → 15 packages PASS

마무리

Connection Warming은 작은 변경이지만 운영 관점에서 중요하다:

  1. Cold start tail latency 완화 — 프록시 재시작 후 첫 요청의 레이턴시 스파이크 제거
  2. Config reload 안정성 — 새 Reader 추가 시 즉시 연결 준비
  3. 기존 동작 호환 — min_connections: 0이면 이전과 동일하게 lazy creation

Pool.New() → Pool.Warm() 분리 패턴은 “구조체 초기화는 실패할 수 없게, I/O는 별도로” 라는 Go 관용 패턴과 맞닿아 있다. Database driver의 sql.Open() (초기화만) + db.Ping() (실제 연결) 분리와 같은 맥락이다.

다음 글에서는 Replica Freshness Policy — LSN 기반 causal consistency를 넘어 max_replica_lag_bytes로 뒤처진 Reader를 자동 제외하는 기능을 구현한다.