들어가며

pgmux는 33편에 걸쳐 커넥션 풀링, R/W 분산, 캐싱, 방화벽, Prepared Statement Multiplexing, Query Mirroring, CI/CD까지 구현했다. 기능 면에서는 PgBouncer를 넘어섰지만, 한 가지 결정적인 갭이 남아있었다.

Multi-Database 지원.

PgBouncer, PgCat, Odyssey 모두 단일 프록시 인스턴스에서 여러 데이터베이스를 동시에 프록시할 수 있다. pgmux는 backend.database 하나만 지원했다. 마이크로서비스 환경에서 서비스별로 DB가 다를 때, pgmux 인스턴스를 DB 수만큼 띄워야 했다.

이번 글에서는 Multi-Database Routing의 설계 결정과 구현 과정을 다룬다.


경쟁 제품 비교

기능PgBouncerPgCatOdysseypgmux (before)pgmux (after)
Multi-DBOOOXO

PgBouncer는 [databases] 섹션에서 DB별 설정을 정의한다:

[databases]
mydb = host=primary1 port=5432 dbname=mydb
otherdb = host=primary2 port=5432 dbname=otherdb

pgmux도 비슷한 개념이 필요했다. 클라이언트가 psql -d mydb로 접속하면 mydb 풀을, psql -d otherdb로 접속하면 otherdb 풀을 사용해야 한다.


설계 원칙

1. 완전 하위호환

기존 single-DB config는 한 글자도 바꾸지 않고 동작해야 한다:

# 기존 config — 그대로 동작
writer:
  host: primary.db
  port: 5432
readers:
  - host: replica1.db
    port: 5432
backend:
  user: postgres
  password: secret
  database: mydb

2. 글로벌 vs Per-DB 리소스

모든 것을 DB별로 분리할 필요는 없다:

리소스범위이유
Writer/Reader 풀Per-DBDB별로 다른 서버를 가리킨다
밸런서Per-DBReader 목록이 DB별로 다르다
Circuit BreakerPer-DB한 DB의 장애가 다른 DB에 영향을 주면 안 된다
캐시글로벌 (키에 DB명 혼합)단일 LRU가 메모리 효율적
Rate Limiter글로벌프록시 전체 부하 제어
Mirror글로벌미러링 타겟은 하나
Audit Logger글로벌감사 로그는 통합

3. 라우팅 메커니즘

PostgreSQL Wire Protocol에서 클라이언트가 접속할 때 보내는 StartupMessagedatabase 파라미터가 포함된다. pgmux는 이미 이 파라미터를 파싱하고 있었으므로, 이 값으로 DB 그룹을 분기하면 된다.


Config 설계

# 새로운 multi-db config
databases:
  mydb:
    writer:
      host: primary-1.db
      port: 5432
    readers:
      - host: replica-1a.db
        port: 5432
    backend:
      user: postgres
      password: secret
      database: mydb
  otherdb:
    writer:
      host: primary-2.db
      port: 5432
    backend:
      user: admin
      password: secret
      database: otherdb

규칙: databases가 있으면 top-level writer/readers/backend는 무시된다. 없으면 top-level에서 자동 합성한다.

type DatabaseConfig struct {
    Writer  DBConfig      `yaml:"writer"`
    Readers []DBConfig    `yaml:"readers"`
    Backend BackendConfig `yaml:"backend"`
    Pool    PoolConfig    `yaml:"pool"`
}

// ResolvedDatabases는 databases가 없으면 top-level에서 합성한다.
func (c *Config) ResolvedDatabases() map[string]DatabaseConfig {
    if len(c.Databases) > 0 {
        return c.Databases
    }
    name := c.Backend.Database
    return map[string]DatabaseConfig{
        name: {Writer: c.Writer, Readers: c.Readers,
               Backend: c.Backend, Pool: c.Pool},
    }
}

ResolvedDatabases() 패턴이 하위호환의 핵심이다. 기존 config에서는 top-level 값으로 단일 DatabaseConfig를 자동 생성하므로, 이후 코드가 항상 map[string]DatabaseConfig를 다루면 된다.


DatabaseGroup 추상화

가장 중요한 구조체 설계. Per-DB 리소스를 하나로 묶는 DatabaseGroup을 만들었다:

type DatabaseGroup struct {
    mu          sync.RWMutex
    name        string
    writerAddr  string
    writerPool  *pool.Pool
    readerPools map[string]*pool.Pool
    balancer    *router.RoundRobin
    writerCB    *resilience.CircuitBreaker
    readerCBs   map[string]*resilience.CircuitBreaker
    backendCfg  config.BackendConfig
}

기존 Server 구조체에 흩어져 있던 writerPool, readerPools, balancer, writerCB, readerCBs를 모두 DatabaseGroup으로 옮겼다.

생성

func newDatabaseGroup(name string, dbCfg config.DatabaseConfig,
    cbCfg config.CircuitBreakerConfig) *DatabaseGroup {
    writerAddr := fmt.Sprintf("%s:%d", dbCfg.Writer.Host, dbCfg.Writer.Port)
    // Writer pool 생성 (MinConnections: 0, lazy)
    // Reader pools 생성 (각각 독립 풀)
    // Circuit breakers 생성
    return dbg
}

Pool의 MinConnections을 0으로 설정하여 lazy creation을 사용한다. 프록시 시작 시 모든 DB의 백엔드가 준비되지 않았을 수 있기 때문이다.

Hot Reload

설정 변경 시 기존 Reader가 추가/제거될 수 있다:

func (g *DatabaseGroup) Reload(dbCfg config.DatabaseConfig,
    cbCfg config.CircuitBreakerConfig) {
    g.mu.Lock()
    defer g.mu.Unlock()

    // 새 Reader 주소 목록
    newReaderAddrs := ...

    // 기존 풀 재사용, 새 풀 생성, 제거된 풀 Close
    for _, addr := range newReaderAddrs {
        if p, ok := g.readerPools[addr]; ok {
            newPools[addr] = p  // 기존 유지
        } else {
            // 새 풀 생성
        }
    }
    for addr, p := range g.readerPools {
        if _, ok := newPools[addr]; !ok {
            p.Close()  // 제거된 Reader
        }
    }
}

Server 리팩터링

Server 구조체가 크게 바뀌었다:

// Before
type Server struct {
    writerAddr  string
    writerPool  *pool.Pool
    readerPools map[string]*pool.Pool
    balancer    *router.RoundRobin
    writerCB    *resilience.CircuitBreaker
    readerCBs   map[string]*resilience.CircuitBreaker
    // ...
}

// After
type Server struct {
    dbGroups  map[string]*DatabaseGroup
    defaultDB string
    // ... (글로벌 리소스는 그대로)
}

6개의 필드가 dbGroups 맵 하나로 교체되었다. 하위호환을 위해 기존 getter를 유지한다:

func (s *Server) WriterPool() *pool.Pool {
    return s.dbGroups[s.defaultDB].WriterPool()
}

handleConn에서 DB 라우팅

클라이언트 접속 시 StartupMessagedatabase 파라미터로 DB 그룹을 결정한다:

func (s *Server) handleConn(ctx context.Context, clientConn net.Conn) {
    // ... StartupMessage 파싱 후
    dbName := params["database"]
    if dbName == "" {
        dbName = s.defaultDB
    }
    dbg := s.resolveDBGroup(dbName)
    if dbg == nil {
        s.sendError(clientConn, fmt.Sprintf("unknown database %q", dbName))
        return
    }
    // Auth는 dbg.writerAddr로 relay
    // 쿼리 루프에 dbg 전달
    s.relayQueries(ctx, clientConn, session, ct, dbg)
}

쿼리 경로에 dbg 전달

가장 노동 집약적인 부분. 모든 쿼리 처리 함수에 dbg *DatabaseGroup 파라미터를 추가해야 했다:

파일함수변경
query.gorelayQueriess.writerPooldbg.writerPool
backend.goacquireWriterConns.writerCBdbg.writerCB
backend.gofallbackToWriters.writerAddrdbg.writerAddr
query_read.gohandleReadQueryTraceds.balancerdbg.balancer
query_extended.goexecuteSynthesizedQuerys.writerPooldbg.writerPool
lsn.gopollReaderLSNs모든 DB 그룹 순회

패턴은 단순하다 — s.writerPooldbg.writerPool으로, s.balancerdbg.balancer로 교체. 하지만 함수가 10개 이상이라 누락 없이 바꾸는 것이 관건이었다.


캐시 키 격리

같은 SQL이라도 DB가 다르면 결과가 다르다. 캐시 키에 DB명을 혼합해야 한다:

func mixDBName(key uint64, dbName string) uint64 {
    if dbName == "" {
        return key
    }
    h := fnv.New64a()
    h.Write([]byte(dbName))
    return key ^ h.Sum64()
}

FNV-1a 해시의 XOR 혼합을 선택한 이유:

  • 충돌 가능성: 이론적으로 존재하지만, DB명은 소수(수십 개 이하)이고 XOR의 분포가 균일하므로 실무에서 문제 없다
  • 성능: FNV-1a는 allocation 없이 몇 나노초면 끝난다
  • 단순함: 별도의 해시 테이블이나 프리픽스 없이 기존 uint64 키를 그대로 사용

Admin API 변경

DB별 health check와 pool stats를 제공한다:

// GET /admin/health
{
  "databases": {
    "mydb": {
      "writer": {"addr": "primary-1:5432", "healthy": true},
      "readers": [
        {"addr": "replica-1a:5432", "healthy": true}
      ]
    },
    "otherdb": {
      "writer": {"addr": "primary-2:5432", "healthy": true},
      "readers": []
    }
  }
}

Health check는 writer와 모든 reader를 병렬로 TCP 체크한다. DB 그룹 간에도 병렬이고, 그룹 내 writer/reader 간에도 병렬이다.


Data API 변경

?database= 쿼리 파라미터로 DB 그룹을 선택한다:

# mydb에 쿼리
curl -X POST "http://localhost:8080/v1/query?database=mydb" \
  -H "Authorization: Bearer key" \
  -d '{"sql": "SELECT * FROM users"}'

# otherdb에 쿼리
curl -X POST "http://localhost:8080/v1/query?database=otherdb" \
  -H "Authorization: Bearer key" \
  -d '{"sql": "SELECT * FROM orders"}'

생략하면 default DB를 사용한다.


Hot Reload: DB 그룹 Diff

설정 리로드 시 DB 그룹의 추가/제거/업데이트를 처리한다:

func (s *Server) Reload(newCfg *config.Config) error {
    newDBs := newCfg.ResolvedDatabases()
    oldGroups := s.getDBGroups()

    for name, dbCfg := range newDBs {
        if g, ok := oldGroups[name]; ok {
            g.Reload(dbCfg, newCfg.CircuitBreaker)  // 업데이트
        } else {
            newGroups[name] = newDatabaseGroup(...)   // 추가
        }
    }
    for name, g := range oldGroups {
        if _, ok := newDBs[name]; !ok {
            g.Close()  // 제거
        }
    }
}

검증

# 컴파일
$ go build ./...
# ✅

# 전체 테스트 (14 packages)
$ go test ./...
# ok  github.com/jyukki97/pgmux/internal/config    8.247s
# ok  github.com/jyukki97/pgmux/internal/proxy      3.769s
# ok  github.com/jyukki97/pgmux/internal/admin       2.624s
# ok  github.com/jyukki97/pgmux/internal/dataapi     1.192s
# ... (14 packages all pass)

# Race detector
$ go test -race ./internal/proxy/ ./internal/admin/ ./internal/dataapi/
# ok  (no races)

기존 single-DB config로 실행해도 동작이 완전히 동일하다. ResolvedDatabases()가 top-level 값에서 자동 합성하기 때문이다.


마무리

이번 작업으로 pgmux는 경쟁 제품(PgBouncer, PgCat, Odyssey) 대비 모든 핵심 기능을 갖추게 되었다:

기능PgBouncerPgCatOdysseypgmux
Transaction PoolingOOOO
R/W SplittingXOOO
Multi-DBOOOO
Query CachingXXXO
Query FirewallXXXO
AST ParserXXXO
Prepared Stmt MuxXXXO
Query MirroringXXXO
Audit LogXXXO
Data APIXXXO

DatabaseGroup 추상화가 핵심이었다. Per-DB 리소스를 하나의 구조체로 캡슐화하니, Server가 단순해지고 Hot Reload도 DB 그룹 단위로 깔끔하게 처리된다.

변경량은 16파일, +888 -499줄. 대부분은 s.writerPooldbg.writerPool 같은 기계적 치환이었지만, 설계 결정(글로벌 vs per-DB, 캐시 키 혼합 방식, 하위호환 전략)이 구현보다 중요했다.

GitHub PR #172