Go 언어 Circuit Breaker 구현 가이드

서론

Circuit Breaker는 일종의 안전장치로 작동합니다. 의존하는 외부 서비스에서 문제가 발생했을 때 적시에 오류를 처리할 수 있습니다. 이를 통해 두 가지 주요 효과를 얻을 수 있습니다:

  • 상위 서비스가 자사 서비스에 대한 접근 의존도를 줄여 캐스케이드 실패 방지
  • 요청 빈도를 낮춰 상위 서비스의 복구 시간 단축

이 패턴은 매우 광범위하게 활용됩니다. 일반적인 애플리케이션에서 외부 API 호출 시 사용할 수도 있고, 웹 게이트웨이나 마이크로서비스 아키텍처에서도 널리 사용됩니다. 본 문서에서는 Sony에서 오픈소스로 공개한 gobreaker 라이브러리의 소스 코드를 분석하여 Circuit Breaker 패턴을 학습하겠습니다.

1. Circuit Breaker의 동작 모드

gobreaker는 마이크로소프트 Azure 클라우드 디자인 패턴 문서에서 설명한 Circuit Breaker 패턴의 Go 언어 구현체입니다. Sony사에서 공개했으며, 현재 약 1.2K의 Star를 보유하고 있으며 많은 개발자들이 사용하고 있습니다.

상태 머신 정의

Circuit Breaker는 세 가지 상태를 가지며, 네 가지 상태 전이情形이 존재합니다:

  • 닫힘(Closed) 상태: 서비스가 정상적으로 작동 중
  • 열림(Open) 상태: 서비스에 이상 발생
  • 반열림(Half-Open) 상태: 일부 요청만 제한적으로 허용

네 가지 상태 전이:

  • 닫힘 상태에서 실패가 발생하고 특정 조건을 만족하면 열림 상태로 전환됩니다.
  • 열림 상태에서 설정된 시간이 경과하면 반열림 상태로 변경되어 서비스 가용성을 확인합니다.
  • 반열림 상태에서 실패가 발생하면 다시 닫힘 상태로 돌아갑니다.
  • 반열림 상태에서 일정 수의 요청이 연속으로 성공하면 닫힘 상태로 전환되어 정상 요청을 허용합니다.

2. gobreaker 라이브러리 구현 분석

위에서 설명한 상태 머신을 바탕으로 gobreaker>는 실용적인 Circuit Breaker를 구현합니다.

2.1 CircuitBreaker 구조체 정의

type CircuitBreaker struct {
    identifier     string
    maxConcurrent  uint32
    samplingWindow time.Duration
    openDuration   time.Duration
    shouldTrip     func(Counts) bool
    onTransition   func(string, State, State)

    mu         sync.Mutex
    current    State
    generation uint64
    metrics    Counts
    expiresAt  time.Duration
}

커스터마이징 가능한 파라미터:

  • maxConcurrent: 최대 동시 요청 수. 반열림 상태에서 이 수 이하의 요청이 성공하면 Circuit Breaker를 닫습니다.
  • samplingWindow: 정상 상태 통계 주기. 0으로 설정하면 매번 카운트를 초기화합니다.
  • openDuration: 열림 상태에서 다시 요청을 시도할 수 있는 시간
  • shouldTrip: Circuit Breaker를 열지 결정하는 사용자 정의 함수
  • onTransition: 상태 변경 시 실행되는 후크 함수

2.2 요청 실행 흐름

Circuit Breaker의 실행은 크게 세 단계로 구성됩니다: ①요청 전 상태 검증 ②실제 서비스 요청 ③요청 결과에 따른 상태 및 카운트 업데이트

func (cb *CircuitBreaker) Execute(work func() (interface{}, error)) (interface{}, error) {
    // ① 요청 전 검증
    gen, validationErr := cb.preRequestCheck()
    if validationErr != nil {
        return nil, validationErr
    }

    defer func() {
        if recovered := recover(); recovered != nil {
            // ③ panic 처리
            cb.postRequestUpdate(gen, false)
            panic(recovered)
        }
    }()

    // ② 실제 요청 수행
    outcome, reqErr := work()

    // ③ 카운트 업데이트
    cb.postRequestUpdate(gen, reqErr == nil)
    return outcome, reqErr
}

2.3 요청 전 검증 로직

요청 전에는 현재 Circuit Breaker의 상태를 확인합니다. 열림 상태이면 요청을 차단하고, 반열림 상태이면서 최대 요청 수를 초과하면 역시 요청을 거부합니다.

func (cb *CircuitBreaker) preRequestCheck() (uint64, error) {
    cb.mu.Lock()
    defer cb.mu.Unlock()

    now := time.Now()
    condition, generation := cb.evaluateState(now)

    if condition == StateOpen {
        return generation, ErrOpenState
    } else if condition == StateHalfOpen && cb.metrics.Requests >= cb.maxConcurrent {
        return generation, ErrTooManyRequests
    }

    cb.metrics.recordRequest()
    return generation, nil
}

현재 상태 계산은 기존 상태에 기반합니다. 열림 상태이면 타임아웃 확인 후 반열림으로 변경할지 판단하고, 닫힘 상태이면 주기 확인을 통해 다음 세대로 전환할지 결정합니다.

func (cb *CircuitBreaker) evaluateState(now time.Time) (State, uint64) {
    switch cb.current {
    case StateClosed:
        if !cb.expiresAt.IsZero() && cb.expiresAt.Before(now) {
            cb.transitionToNextGen(now)
        }
    case StateOpen:
        if cb.expiresAt.Before(now) {
            cb.changeState(StateHalfOpen, now)
        }
    }
    return cb.current, cb.generation
}

주기 길이 설정도 현재 상태에 따라 결정됩니다. 정상 상태(닫힘)면 interval 주기를 사용하고, 열림 상태면 타임아웃 시간을 기준으로 반열림으로의 전이 시점을 결정합니다.

2.4 요청 후 처리 로직

각 요청 완료 후에는 성공/실패 여부에 따라 Circuit Breaker의 카운트를 업데이트합니다.

func (cb *CircuitBreaker) postRequestUpdate(previousGen uint64, success bool) {
    cb.mu.Lock()
    defer cb.mu.Unlock()

    now := time.Now()

    state, generation := cb.evaluateState(now)
    if generation != previousGen {
        return
    }

    if success {
        cb.handleSuccess(state, now)
    } else {
        cb.handleFailure(state, now)
    }
}

반열림 상태에서의 처리:

요청이 성공하면 연속 성공 횟수가 maxConcurrent 이상인지 확인하고,达标하면 닫힘 상태로 전환합니다. 반열림 상태에서 실패하면 즉시 열림 상태로 변경됩니다.

닫힘 상태에서의 처리:

요청 성공 시 카운트를 업데이트하고, 실패 시 shouldTrip 함수를 호출하여 열림 상태로 전환할지 결정합니다.

고려사항

원격 서비스나 제3자 서비스를 빈번하게 호출하는 경우 실패 가능성이 충분히 존재합니다. Circuit Breaker를 사용하면 이러한 불안정한 서비스로 인해 자사 서비스가 과부하 상태로 빠지는 것을 방지할 수 있습니다.

다만, Circuit Breaker 내부에서 상당한 통계 데이터와 mutex를 유지해야 하므로 어느 정도의 오버헤드가 발생합니다. 특히 반열림 상태에서 요청이 과도하게 발생할 수 있는데, 이는 연속 성공 횟수가 최대 요청 수에 도달하지 못하기 때문입니다. 따라서 응답 시간이 긴 서비스를 빈번하게 호출하는 경우, 많은 too many requests 오류가 발생할 수 있습니다.

태그: Golang circuit-breaker gobreaker microservices resilience-pattern

7월 21일 06:20에 게시됨