들어가며

4편까지 각 컴포넌트(Pool, Router, Cache)를 독립적으로 만들고 테스트했다. 이제 진짜 연결할 차례다.

지금까지 만든 것들:

  1. PG wire protocol 프록시 (메시지 단위 릴레이)
  2. 커넥션 풀링 (DialFunc, Acquire/Release/Discard)
  3. R/W 쿼리 자동 분산 (Session + RoundRobin)
  4. LRU 쿼리 캐싱 + 테이블별 무효화

문제는 — 이것들이 서로 연결되어 있지 않았다는 것이다.

통합 전 상태

// Before: NewServer에서 Pool, Router, Cache를 초기화하지 않음
func NewServer(cfg *config.Config) *Server {
    return &Server{
        listenAddr: cfg.Proxy.Listen,
        writerAddr: fmt.Sprintf("%s:%d", cfg.Writer.Host, cfg.Writer.Port),
    }
}

// Before: 쿼리를 무조건 writer 1대로 직접 릴레이
func (s *Server) relayQueries(ctx context.Context, clientConn, backendConn net.Conn) {
    // ... 파싱이나 분류 없이 단순 전달
}

각 컴포넌트가 독립적으로 존재만 하고, 프록시에 실제로 통합되지 않은 상태였다.

통합 설계

핵심 결정: Writer는 전용, Reader는 풀링

Client connects
Read startup (SSL negotiate)
Connect to WRITER (direct, per-client)
    ↓ Auth relay (bidirectional)
Session created (Router)
Query Loop:
  classify → route → cache check → forward → cache store/invalidate
  • Writer 연결: 클라이언트별 전용 (auth relay로 인증 후 유지)
  • Reader 연결: Pool에서 관리, RoundRobin으로 분배 (쿼리마다 acquire/release)

Writer를 전용으로 한 이유는 PG auth relay 때문이다. 클라이언트의 인증을 writer로 중계하는 과정에서 해당 연결이 이미 설정되므로, 그대로 세션에 묶는 게 자연스럽다.

Pool에 DialFunc 추가

Reader 풀은 PG 인증까지 완료된 연결을 관리해야 한다. 기존 Pool은 raw TCP dial만 했는데, DialFunc을 추가해서 PG-aware 연결 생성을 지원한다:

type DialFunc func() (net.Conn, error)

type Config struct {
    DialFunc          DialFunc // nil이면 기존 TCP dial 사용
    Addr              string
    MinConnections    int
    MaxConnections    int
    // ...
}

서버 초기화 시:

for _, addr := range readerAddrs {
    addr := addr
    p, _ := pool.New(pool.Config{
        DialFunc: func() (net.Conn, error) {
            return pgConnect(addr, cfg.Backend.User, cfg.Backend.Password, cfg.Backend.Database)
        },
        MinConnections: 0, // lazy creation
        MaxConnections: cfg.Pool.MaxConnections,
    })
    s.readerPools[addr] = p
}

PG 인증 구현: MD5 + SCRAM-SHA-256

Reader 풀의 DialFunc에서 pgConnect()를 호출한다. 이 함수는 TCP 연결 후 PG startup handshake + 인증까지 완료한다.

PostgreSQL 16의 기본 인증이 SCRAM-SHA-256이므로, 이걸 직접 구현해야 했다:

func handleSCRAM(conn net.Conn, mechanisms []byte, user, password string) error {
    // 1. SASLInitialResponse: client-first-message
    clientNonce := generateNonce()
    clientFirstBare := fmt.Sprintf("n=%s,r=%s", user, clientNonce)

    // 2. Read SASLContinue: server-first-message (nonce, salt, iterations)
    // 3. Compute PBKDF2 → HMAC-SHA-256 → client proof
    saltedPassword := pbkdf2.Key([]byte(password), salt, iterations, 32, sha256.New)
    clientKey := hmacSHA256(saltedPassword, []byte("Client Key"))
    // ... XOR로 proof 생성

    // 4. Read SASLFinal: server signature 검증
}

SCRAM 인증의 핵심은 비밀번호를 직접 전송하지 않는다는 것이다. 대신 challenge-response 방식으로 서로를 검증한다. 4단계 핸드셰이크(InitialResponse → Continue → Response → Final)를 거친다.

Auth Relay 버그 수정

초기 구현에서 relayAuth()는 백엔드→클라이언트 방향만 처리하고 있었다. PG 인증은 양방향이다:

Backend → AuthenticationMD5Password → Client
Client  → PasswordMessage          → Backend   ← 이 방향이 빠져있었다!
Backend → AuthenticationOk          → Client

수정:

func (s *Server) relayAuth(clientConn, backendConn net.Conn) error {
    for {
        msg, _ := protocol.ReadMessage(backendConn)
        protocol.WriteMessage(clientConn, msg.Type, msg.Payload)

        // 인증 요청이면 클라이언트 응답을 읽어서 백엔드로 전달
        if msg.Type == MsgAuthentication {
            authType := binary.BigEndian.Uint32(msg.Payload[0:4])
            if authNeedsResponse(authType) { // MD5(5), SCRAM(10,11)
                clientMsg, _ := protocol.ReadMessage(clientConn)
                protocol.WriteMessage(backendConn, clientMsg.Type, clientMsg.Payload)
            }
        }
    }
}

Extended Query Protocol 지원

lib/pq 같은 드라이버는 파라미터가 있는 쿼리에 Extended Query Protocol을 쓴다:

Parse(P) → Bind(B) → Execute(E) → Sync(S)

이 메시지들은 개별적으로 ReadyForQuery를 생성하지 않는다. Sync 메시지가 올 때만 백엔드가 ReadyForQuery를 보낸다:

if msg.Type != protocol.MsgQuery {
    protocol.WriteMessage(writerConn, msg.Type, msg.Payload)
    // Sync일 때만 응답을 대기
    if msg.Type == protocol.MsgSync {
        s.relayUntilReady(clientConn, writerConn)
    }
    continue
}

쿼리 라우팅 플로우

통합 후 실제 쿼리 처리 흐름:

query := protocol.ExtractQueryText(msg.Payload)
route := session.Route(query)  // Session이 트랜잭션/R-A-W 상태 추적

if route == RouteWriter {
    forwardAndRelay(clientConn, writerConn, msg)
    // 쓰기 쿼리면 캐시 무효화
    if Classify(query) == QueryWrite {
        for _, table := range ExtractTables(query) {
            queryCache.InvalidateTable(table)
        }
    }
} else { // RouteReader
    // 캐시 확인
    if cached := queryCache.Get(CacheKey(query)); cached != nil {
        clientConn.Write(cached) // 캐시 히트: PG 응답 바이트 그대로 전송
        continue
    }
    // Reader 풀에서 연결 획득 (RoundRobin)
    addr := balancer.Next()
    rConn, _ := readerPools[addr].Acquire(ctx)
    // 쿼리 전달 + 응답 수집 + 캐시 저장
    collected, _ := relayAndCollect(clientConn, rConn)
    queryCache.Set(CacheKey(query), collected, nil)
    readerPools[addr].Release(rConn)
}

캐시는 PG wire protocol의 원본 바이트를 그대로 저장한다. 캐시 히트 시 직렬화/역직렬화 없이 바이트를 바로 전송한다.

E2E 테스트: Docker 환경

# docker-compose.yml: Primary + Replica x2
services:
  primary:   # port 15432, wal_level=replica
  replica1:  # port 15433, pg_basebackup from primary
  replica2:  # port 15434, pg_basebackup from primary

실제 PostgreSQL 클러스터를 띄우고, 프록시를 통해 lib/pq로 접속하는 E2E 테스트:

func TestE2E_ProxyIntegration(t *testing.T) {
    db, _ := sql.Open("postgres", "postgres://postgres:postgres@127.0.0.1:15440/testdb?sslmode=disable")

    t.Run("ReadQuery", func(t *testing.T) {
        // SELECT → reader로 라우팅 → RoundRobin 분배
    })
    t.Run("WriteQuery", func(t *testing.T) {
        // INSERT → writer → Extended Query Protocol 정상 처리
    })
    t.Run("Transaction", func(t *testing.T) {
        // BEGIN~ROLLBACK → 전체 writer 라우팅
    })
    t.Run("CacheHit", func(t *testing.T) {
        // 동일 쿼리 두 번 → 두 번째는 캐시 히트
    })
}

프록시 디버그 로그 (실제 출력):

query routed sql="SELECT name FROM users ORDER BY id LIMIT 3" route=reader
cache set   sql="SELECT name FROM users ORDER BY id LIMIT 3" size=98
query routed sql="DELETE FROM users WHERE name = 'dave'" route=writer
cache invalidated table=users
query routed sql="BEGIN READ WRITE" route=writer
query routed sql="INSERT INTO users ..." route=writer
query routed sql="SELECT name FROM users WHERE name = 'tx_user'" route=writer  ← 트랜잭션 내
query routed sql=ROLLBACK route=writer

벤치마크 결과

Apple M4 Pro 기준:

핵심 경로 성능

컴포넌트연산ns/opalloc
CacheKey 생성 (FNV-1a)150
CacheGet (Hit)360
CacheGet (Miss)60
CacheSet1081
CacheInvalidate (100건)7,9010
RouterRoundRobin Next1.80
RouterClassify (SELECT)1,34245
RouterClassify (힌트 주석)1261
RouterSession Route1,29946
RouterExtractTables1895

캐시 히트 36ns는 DB 왕복(~1ms)의 27,000분의 1이다. 캐시 히트율이 높을수록 전체 성능이 크게 향상된다.

테스트 커버리지

패키지테스트 수내용
config8파싱, validation, 기본값
protocol5메시지 읽기/쓰기, startup, SSL
proxy3TCP 접속, graceful shutdown, parseSize
pool6min 생성, 재사용, 타임아웃, 만료, 헬스체크
router34파서 19, 세션 4, 밸런서 5, 테이블추출 6
cache8get/set, TTL, LRU, max_size, 무효화
integration5라우터+캐시 연동, 트랜잭션, LB
e2e4R/W 라우팅, 트랜잭션, 캐시 (Docker PG)
합계73

잘한 점

1. 메시지 단위 릴레이 설계 처음부터 바이트 릴레이가 아닌 메시지 단위로 설계한 덕분에, 라우팅과 캐싱을 끼워넣기가 쉬웠다. Extended Query Protocol 지원도 메시지 타입만 보고 분기하면 됐다.

2. 컴포넌트 독립성 → 통합 용이 Pool, Router, Cache가 서로 의존하지 않아서 단위 테스트가 깔끔했고, 통합할 때도 서버 코드만 수정하면 됐다. 인터페이스 분리의 힘.

3. 0 alloc 캐시 + PG 바이트 직접 캐싱 캐시의 핫 경로에서 메모리 할당이 0이다. 그리고 PG wire protocol 바이트를 그대로 캐시하므로, 캐시 히트 시 직렬화 오버헤드가 없다.

4. SCRAM-SHA-256 직접 구현 PostgreSQL 16의 기본 인증 방식을 직접 구현하면서, challenge-response 인증의 원리를 깊이 이해했다.

아쉬운 점과 개선 방향

1. Extended Query Protocol 라우팅 미지원6편에서 해결 Parse 메시지에서 SQL을 추출하여 Prepared Statement도 reader로 라우팅하도록 구현했다.

2. 파서 성능 정규식 기반 파서가 1.3μs인 건 아쉽다. 수동 파싱으로 바꾸면 100ns 이하로 줄일 수 있을 것이다.

3. Writer 커넥션 풀링 현재 writer는 클라이언트별 전용 연결이다. PgBouncer처럼 트랜잭션 풀링 모드를 구현하면 writer 연결도 공유할 수 있다.

4. 캐시 무효화의 한계 JOIN 쿼리의 테이블 추출이 미지원이고, SELECT의 FROM절 테이블 추출도 없다.

배운 것

  1. 바이너리 프로토콜은 생각보다 단순하다 — PG wire protocol은 type+length+payload, 이 세 가지만 알면 된다
  2. 인증 프로토콜은 생각보다 복잡하다 — SCRAM-SHA-256은 PBKDF2 + HMAC + challenge-response 4단계. MD5보다 훨씬 정교하다
  3. 통합이 진짜 어렵다 — 개별 컴포넌트 100% 테스트 통과해도, 조합하면 auth relay 방향 문제 같은 예상 못한 버그가 나온다
  4. Extended Query Protocol을 무시하면 안 된다 — 실제 드라이버는 대부분 Extended Query를 쓴다. Simple Query만 지원하면 INSERT/UPDATE가 안 된다
  5. 테스트 가능한 구조가 먼저다 — 인터페이스 분리, 의존성 주입이 테스트를 쉽게 만든다

마무리

빈 디렉토리에서 시작해서 커넥션 풀링, R/W 분산, 쿼리 캐싱을 갖춘 DB 프록시를 만들었다. 총 코드 약 2,000줄, 테스트 73건이다. Docker Compose로 Primary + Replica x2 환경을 구성하고, 실제 드라이버(lib/pq)로 E2E 테스트까지 통과했다.

직접 만들어보면서 가장 크게 느낀 건, **프록시는 결국 “중간에서 메시지를 읽고, 판단하고, 전달하는 것”**이라는 점이다. 그런데 그 “판단"을 제대로 하려면, 프로토콜의 세부사항(인증 방향, Extended Query 메시지 흐름, ReadyForQuery 타이밍)을 정확히 알아야 한다. 추상적인 이해와 동작하는 구현 사이의 간극을 채우는 과정이 가장 값진 경험이었다.

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