들어가며
“테스트가 통과한다"와 “프로덕션에서 안전하다"는 완전히 다른 이야기다.
6편까지 기능적으로 완성했다고 생각했지만, QA 리뷰에서 운영 시 장애로 직결되는 버그 4건이 발견됐다. 거기에 “프록시를 여러 대 띄우면 동작하나?“라는 질문도 받았다. 대답은 **“캐시 정합성이 깨진다”**였다.
이번 편에서 수정한 것:
- [CRITICAL] OOM — 거대 쿼리 결과의 무한 버퍼링
- [CRITICAL] 좀비 헬스체크 — Pool.Close() 후에도 살아있는 고루틴
- [MAJOR] 트랜잭션 릭 — 세미콜론 복합 쿼리에서 COMMIT 미감지
- [MAJOR] 캐시 무효화 누락 — CTE/다중 테이블 미지원
- [SCALING] Redis Pub/Sub 기반 멀티 인스턴스 캐시 무효화
🔥 CRITICAL #1: OOM 위험
문제
relayAndCollect()는 백엔드 응답을 클라이언트에 릴레이하면서 동시에 캐시용 버퍼에 수집한다:
// Before: 응답 크기에 관계없이 무한정 수집
buf = append(buf, msgBytes...)
SELECT * FROM ten_gigabyte_table → 10GB가 buf에 쌓임 → OOM 패닉.
캐시의 MaxSize 검사는 Set() 내부에서만 이뤄지므로, 그 전에 이미 메모리를 다 잡아먹는다.
수정
수집 중에 max_result_size를 실시간으로 체크하고, 초과하면 버퍼를 즉시 해제한다. 클라이언트로의 릴레이는 계속한다:
if !oversize {
buf = append(buf, msgBytes...)
if maxSize > 0 && len(buf) > maxSize {
buf = nil // 메모리 즉시 해제
oversize = true
}
}
relayAndCollect가 nil을 반환하면 호출부에서 캐시 저장을 건너뛴다.
🔥 CRITICAL #2: 좀비 헬스체크
문제
Pool.Close() 후에도 StartHealthCheck로 시작된 고루틴이 살아있다. 이 고루틴이 healthCheck()를 실행하면:
// healthCheck 내부: Close()로 numOpen=0이 된 상태에서
for p.numOpen < p.cfg.MinConnections {
conn, _ := p.newConn() // 새 DB 커넥션 생성!
p.numOpen++
}
닫힌 풀에서 좀비 커넥션이 계속 생성된다.
수정
type Pool struct {
// ...
done chan struct{} // Close 시 닫힘
}
func (p *Pool) Close() {
if p.closed { return }
p.closed = true
close(p.done) // 헬스체크 고루틴 종료 신호
// ...
}
func (p *Pool) StartHealthCheck(ctx context.Context, interval time.Duration) {
go func() {
for {
select {
case <-ctx.Done(): return
case <-p.done: return // Close 시 즉시 종료
case <-ticker.C: p.healthCheck()
}
}
}()
}
func (p *Pool) healthCheck() {
if p.closed { return } // 이중 방어
// ...
}
🚨 MAJOR #3: 트랜잭션 릭
문제
SELECT 1; COMMIT;
PG의 Simple Query Protocol은 세미콜론으로 구분된 여러 문장을 한 번에 보낼 수 있다. 기존 파서는 첫 번째 키워드만 확인하므로 SELECT로 분류하고, COMMIT을 놓친다. inTransaction이 영원히 true로 남아 모든 후속 쿼리가 writer로 쏠린다.
수정
세미콜론으로 분리해서 모든 문장을 스캔:
func splitStatements(query string) []string {
// 세미콜론으로 분리, 단 따옴표 내부의 세미콜론은 무시
// "INSERT INTO t VALUES ('a;b')" → 1개의 문장
}
func (s *Session) updateTransactionState(query string) {
for _, stmt := range splitStatements(query) {
upper := strings.ToUpper(strings.TrimSpace(stmt))
if strings.HasPrefix(upper, "BEGIN") { s.inTransaction = true }
if strings.HasPrefix(upper, "COMMIT") { s.inTransaction = false }
}
}
🚨 MAJOR #4: 다중 테이블 캐시 무효화
문제
WITH x AS (UPDATE users SET score=0)
UPDATE ranking SET total=0;
ExtractTables가 첫 번째 테이블(ranking)만 추출하고 CTE 내부의 users를 놓친다. users 캐시가 무효화되지 않아 stale 데이터를 반환한다.
수정
- 멀티 스테이트먼트: 세미콜론 분리 후 각 문장에서 테이블 추출
- CTE:
WITH절 내부를 스캔하여INSERT INTO,UPDATE,DELETE FROM뒤의 테이블명 추출 Classify도 CTE 내 write 키워드를 감지하도록 확장
// "WITH x AS (UPDATE users ...) UPDATE ranking ..." → ["users", "ranking"]
func extractCTETables(query string) []string {
// INSERT INTO, UPDATE, DELETE FROM 키워드를 모두 찾아서 테이블명 추출
}
🌐 멀티 인스턴스 스케일링
문제
프록시를 LB 뒤에 여러 대 띄우면:
Proxy A: SELECT * FROM users → 캐시 저장
Proxy B: UPDATE users SET ... → Proxy B 캐시만 무효화
Proxy A: SELECT * FROM users → stale 캐시 반환 ❌
해결: Redis Pub/Sub 캐시 무효화 브로드캐스트
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Proxy A │ │ Proxy B │ │ Proxy C │
│ │ │ WRITE │ │ │
│ Cache ✓ │ │ Cache ✓ │ │ Cache ✓ │
└────┬────┘ └────┬────┘ └────┬────┘
│ subscribe │ publish │ subscribe
└───────┐ ┌────┘ ┌───────────┘
▼ ▼ ▼
┌──────────────────┐
│ Redis Pub/Sub │
│ channel: │
│ pgmux:invalidate
└──────────────────┘
- Proxy B에서 쓰기 발생 → 로컬 캐시 무효화 + Redis에
"users"publish - Proxy A, C가 subscribe 중 →
"users"수신 → 로컬 캐시 무효화 - Full flush는
"*"메시지로 브로드캐스트
cache:
enabled: true
invalidation:
mode: "pubsub" # "local" or "pubsub"
redis_addr: "redis:6379"
channel: "pgmux:invalidate"
mode: "local"이면 기존처럼 로컬-only로 동작한다 (하위 호환).
배운 점
- 테스트 통과 ≠ 프로덕션 안전 — 정상 경로만 테스트하면 엣지 케이스(거대 쿼리, 복합 문장, 좀비 고루틴)를 놓친다
- 고루틴은 반드시 종료 경로가 있어야 한다 —
go func()하나 띄우면 반드시 대응하는 종료 채널이 필요하다 - 파서의 한계를 인정하라 — 완벽한 SQL 파서 없이는 CTE, 서브쿼리를 100% 커버할 수 없다. 최선의 노력을 하되, TTL이라는 안전망이 있다
- 수평 확장은 공유 상태를 제거해야 가능하다 — 인메모리 캐시는 본질적으로 로컬 상태. 멀티 인스턴스를 지원하려면 브로드캐스트 메커니즘이 필수
프로젝트 소스코드: github.com/jyukki97/pgmux
💬 댓글