들어가며
“장애를 막을 수 없다면, 장애가 번지는 것을 막아야 한다.”
DB가 느려지면 어떻게 될까? 커넥션 풀이 고갈되고, 대기 큐가 넘치고, 프록시를 거치는 모든 서비스가 동시에 멈춘다. 하나의 장애가 전체 시스템으로 **연쇄 전파(Cascading Failure)**된다.
이번 편에서 구현한 것:
- Circuit Breaker — 장애 감지 시 요청을 빠르게 실패시켜 백엔드를 보호
- Token Bucket Rate Limiter — 초당 요청 수를 제한하여 과부하 방지
- Prometheus 메트릭 연동
⚡ Circuit Breaker
상태 머신
Circuit Breaker는 전기 차단기에서 영감을 받은 패턴이다. 세 가지 상태를 순환한다:
성공률 정상 에러율 ≥ threshold
┌──── Closed ────────────► Open ◄──┐
│ (정상) (차단) │ 실패
│ ▲ │ │
│ │ N회 연속 성공 │ │
│ │ ▼ │
│ └──────────── Half-Open ──┘
│ (시험 통과)
└────────────────────────────────────┘
- Closed: 정상 운영. 모든 요청 허용. 에러율을 윈도우 단위로 측정.
- Open: 차단 상태. 모든 요청 즉시 실패.
openDuration후 Half-Open으로 전이. - Half-Open: 제한된 수의 요청만 허용. 성공하면 Closed로, 실패하면 다시 Open으로.
구현
type CircuitBreaker struct {
cfg BreakerConfig
mu sync.Mutex
state State
successes int
failures int
total int
openedAt time.Time
halfOpenOK int
}
func (cb *CircuitBreaker) Allow() error {
cb.mu.Lock()
defer cb.mu.Unlock()
switch cb.state {
case StateClosed:
return nil
case StateOpen:
if time.Since(cb.openedAt) >= cb.cfg.OpenDuration {
cb.state = StateHalfOpen
cb.halfOpenOK = 0
return nil // 시험 요청 허용
}
return fmt.Errorf("circuit breaker is open")
case StateHalfOpen:
return nil
}
return nil
}
Rolling Window 평가
에러율을 판단하는 핵심 로직:
func (cb *CircuitBreaker) evaluateWindow() {
if cb.total < cb.cfg.WindowSize {
return // 윈도우가 안 찼으면 판단하지 않음
}
errorRate := float64(cb.failures) / float64(cb.total)
if errorRate >= cb.cfg.ErrorThreshold {
cb.state = StateOpen
cb.openedAt = time.Now()
}
cb.resetCounters() // 윈도우 리셋 (트립 여부와 무관)
}
여기서 중요한 설계 결정: evaluateWindow는 RecordSuccess와 RecordFailure 모두에서 호출한다.
처음에는 RecordSuccess에서 maybeResetWindow(), RecordFailure에서 maybeTrip()을 분리했다가 버그가 발생했다:
Window: [성공, 성공, 성공, 실패, 실패, 실패, 실패, 실패, 실패, 성공(10번째)]
↑
마지막이 성공이면
maybeResetWindow() → 카운터 리셋
maybeTrip()은 호출되지 않음!
윈도우의 마지막 요청이 성공이면 카운터가 먼저 리셋되어 트립을 놓친다. 해결책은 하나의 함수에서 “평가 → 트립 판단 → 리셋"을 원자적으로 수행하는 것이다.
Half-Open 복구
func (cb *CircuitBreaker) RecordSuccess() {
switch cb.state {
case StateHalfOpen:
cb.halfOpenOK++
if cb.halfOpenOK >= cb.cfg.HalfOpenMax {
cb.state = StateClosed // 충분한 성공 → 정상 복구
cb.resetCounters()
}
}
}
func (cb *CircuitBreaker) RecordFailure() {
switch cb.state {
case StateHalfOpen:
cb.state = StateOpen // 하나라도 실패 → 다시 차단
cb.openedAt = time.Now()
}
}
Half-Open에서는 보수적으로 판단한다. N회 연속 성공이면 복구, 1회라도 실패하면 즉시 다시 Open. 불안정한 백엔드에 트래픽을 보내는 것보다 빠른 실패가 낫다.
프록시 통합
Writer와 각 Reader에 독립된 Circuit Breaker를 할당한다:
// Writer CB 체크
func (s *Server) acquireWriterConn(ctx context.Context, bound *pool.Conn) (*pool.Conn, bool, error) {
if s.writerCB != nil {
if err := s.writerCB.Allow(); err != nil {
return nil, false, fmt.Errorf("writer circuit breaker open: %w", err)
}
}
conn, err := s.writerPool.Acquire(ctx)
if err != nil {
if s.writerCB != nil {
s.writerCB.RecordFailure()
}
return nil, false, err
}
return conn, true, nil
}
쿼리 성공 시 RecordSuccess(), 실패 시 RecordFailure()를 호출한다. CB가 Open이면 풀 Acquire 자체를 시도하지 않으므로, 이미 문제가 있는 백엔드에 커넥션 시도가 쌓이는 것을 방지한다.
🪣 Token Bucket Rate Limiter
알고리즘
초당 rate개의 토큰이 버킷에 추가됨
버킷 최대 용량 = burst
요청 시 토큰 1개 소비
토큰 없으면 거부
type RateLimiter struct {
mu sync.Mutex
rate float64 // 초당 토큰 추가량
burst int // 버킷 최대 용량
tokens float64 // 현재 토큰 수
lastTime time.Time // 마지막 리필 시각
}
func (rl *RateLimiter) Allow() bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
elapsed := now.Sub(rl.lastTime).Seconds()
rl.lastTime = now
// 경과 시간만큼 토큰 리필
rl.tokens += elapsed * rl.rate
if rl.tokens > float64(rl.burst) {
rl.tokens = float64(rl.burst)
}
if rl.tokens >= 1.0 {
rl.tokens--
return true
}
return false
}
Token Bucket의 장점:
- burst 허용: 순간적으로 burst만큼의 요청을 한꺼번에 처리 가능
- 평균 rate 제어: 장기적으로는 초당 rate개의 요청만 통과
- 구현이 단순: 타이머나 슬라이딩 윈도우 없이
elapsed * rate로 리필
프록시 통합
// 쿼리 루프 내부
if s.rateLimiter != nil && !s.rateLimiter.Allow() {
slog.Warn("rate limited", "remote", clientConn.RemoteAddr())
if s.metrics != nil {
s.metrics.RateLimited.Inc()
}
s.sendError(clientConn, "too many requests")
protocol.WriteMessage(clientConn, protocol.MsgReadyForQuery, []byte{'I'})
continue
}
Rate limit에 걸리면 ErrorResponse + ReadyForQuery를 보낸다. ReadyForQuery를 보내야 클라이언트가 다음 쿼리를 보낼 수 있다 — 안 보내면 클라이언트가 영원히 응답을 기다린다.
설정
circuit_breaker:
enabled: true
error_threshold: 0.5 # 50% 에러율에서 트립
open_duration: 10s # Open 유지 시간
half_open_max: 3 # Half-Open에서 필요한 성공 횟수
window_size: 10 # 에러율 측정 윈도우
rate_limit:
enabled: true
rate: 1000 # 초당 1000 요청
burst: 100 # 순간 최대 100 요청
📊 Prometheus 메트릭
// metrics.go
RateLimited: prometheus.NewCounter(prometheus.CounterOpts{
Name: "pgmux_rate_limited_total",
Help: "Total number of rate-limited requests",
}),
pgmux_rate_limited_total이 급증하면 rate 설정을 올리거나, 클라이언트의 쿼리 패턴을 점검해야 한다는 신호다.
배운 점
- Circuit Breaker는 “빠른 실패"의 구현체 — 느린 실패보다 빠른 실패가 낫다. 30초 타임아웃을 기다리는 것보다 즉시 에러를 반환하는 것이 시스템 전체에 이롭다.
- 상태 머신 설계에서 원자성이 핵심 — evaluateWindow의 “평가 → 트립 → 리셋"을 분리하면 race condition이 생긴다. 하나의 함수에서 원자적으로 처리해야 한다.
- Token Bucket은 burst를 자연스럽게 허용 — 고정 윈도우 카운터와 달리, 순간적인 트래픽 급증을 burst 범위 내에서 수용하면서도 장기 평균을 제어할 수 있다.
- Rate Limit 응답에는 반드시 ReadyForQuery가 필요 — PG 프로토콜에서 클라이언트는 ReadyForQuery를 받아야 다음 쿼리를 보낸다. 이걸 빠뜨리면 커넥션이 교착 상태에 빠진다.
프로젝트 소스코드: github.com/jyukki97/pgmux
💬 댓글