Go의 Circuit Breaker 패턴: 연쇄 장애 방지

Go 마이크로서비스에서 연쇄 장애를 방지하세요.

Page content

서킷 브레이커는 실패하는 종속성(의존 서비스)에 대한 과도한 호출을 차단하여, 고르틴(goroutine), 소켓, 메모리를 고갈시키며 전체 시스템이 무너지기 전에 연쇄 장애(cascading failures)를 방지합니다.

핵심적인 어려움은 상태 기계(state machine) 자체를 구현하는 것이 아니라, 서킷 브레이커를 어디에 배치할지, 무엇을 실패로 간주할지, 타임아웃 및 재시도와 어떻게 상호작용할지, 그리고 서킷이 열렸을 때 서비스에서 어떤 행동을 취해야 하는지를 결정하는 데 있습니다.

서킷 브레이커

Go 언어에서 서킷 브레이커 패턴은 특히 외부 호출 주변에서 매우 유용합니다. HTTP API, 결제 게이트웨이, 검색 서비스, 이메일 제공업체, LLM 게이트웨이, 내부 마이크로서비스, 그리고 느려지거나 과부하 상태가 되거나 부분적으로 사용 불가능해질 수 있는 기타 종속성들입니다. 잘 사용하면 연쇄 장애를 줄일 수 있지만, 잘못 사용하면 또 다른 난해한 장애 모드가 될 수 있습니다.

서킷 브레이커가 해결하는 문제

분산 시스템은 깔끔하게 고장 나지 않습니다.

종속성이 완전히 다운되지 않았을 수도 있습니다. 다음과 같은 상태일 수 있습니다:

  • 500 에러 반환
  • 429 과부하 응답 반환
  • TCP 연결은 받지만 응답하지 않음
  • 300밀리초 대신 30초 걸려 응답
  • 일부 요청에서만 실패
  • 모든 클라이언트가 동시에 재시도하여 과부하 상태

가장 최악의 경우는 종종 하드웨어적인 고장이 아니라, 느린 종속성입니다.

느린 호출은 고르틴, 소켓, 데이터베이스 연결, 메모리, 워커 용량을 소모합니다. 이미 비정상 상태인 종속성을 기다리고 있는 서비스라면, 해당 서비스 또한 비정상 상태가 될 수 있습니다.

서킷 브레이커는 종속성이 실패 임계값을 넘으면 빠르게 실패(fail fast)함으로써 이러한 상황을 방지합니다.

서비스가 영원히 다음 과정을 반복하지 않도록 합니다:

요청 -> 종속성 호출 -> 대기 -> 타임아웃 -> 재시도 -> 대기 -> 실패

대신 서비스는 결국 다음과 같은 동작을 하게 됩니다:

요청 -> 서킷 오픈 -> 즉시 폴백 또는 에러 반환

이 빠른 실패는 항상 즐겁지는 않지만, 예측 가능합니다. 예측 가능한 실패는 느린 붕괴보다 운영하기 훨씬 쉽습니다.

서킷 브레이커 상태 세 가지

대부분의 서킷 브레이커는 세 가지 상태를 사용합니다.

닫힘(Closed)

서킷은 정상 작동 중일 때 닫혀 있습니다.

요청이 통과합니다. 브레이커는 성공과 실패를 기록합니다. 실패 횟수 또는 비율이 임계값을 넘으면 브레이커가 열립니다.

닫혀 있다고 해서 “영원히 안전"하다는 뜻은 아닙니다. “현재 트래픽이 허용됨"을 의미합니다.

열림(Open)

종속성이 비정상 상태라고 판단되면 서킷이 열립니다.

요청은 즉시 거부됩니다. 서비스는 폴백, 캐시된 응답, 저하된 응답, 또는 명확한 상류 에러를 반환해야 합니다.

서킷 오픈은 종속성을 복구하지 않습니다. 종속성에 복구 시간을 주고, 호출자에게 자원을 낭비하지 않도록 보호합니다.

반열림(Half-Open)

냉각 시간(cool-down)이 지나면 브레이커는 반열림 상태로 진입합니다.

제한된 수의 테스트 요청만 통과시킵니다. 성공하면 브레이커가 닫히고, 실패하면 다시 열립니다.

반열림 상태는 두 가지 나쁜 극단을 피하는 데 중요합니다:

  • 종속성을 다시 시도하지 않음
  • 너무 빨리 전체 트래픽을 다시 보냄

상태 전환은 다음과 같습니다:

stateDiagram-v2 [*] --> Closed Closed --> Open: Failure threshold reached Open --> HalfOpen: Timeout elapsed HalfOpen --> Closed: Trial succeeds HalfOpen --> Open: Trial fails

서킷 브레이커 vs 타임아웃 vs 재시도

서킷 브레이커, 재시도, 타임아웃을 서로 대체 가능한 것으로 다루는 것은 흔한 실수입니다. 이들은 관련이 있지만 다른 문제를 해결합니다.

타임아웃

타임아웃은 단일 작업이 실행될 수 있는 시간을 제한합니다.

Go에서는 일반적으로 아웃바운드 호출에 데드라인 또는 타임아웃이 설정된 context.Context를 전달하는 것을 의미합니다.

타임아웃은 다음 질문에 답합니다:

이 한 번의 호출에 얼마나 기다릴 의향이 있는가?

재시도

재시도는 실패가 일시적일 가능성이 있을 때 작업을 반복합니다.

재시도는 짧은 네트워크 결함, 임시 503 응답, 연결 재설정, 기타 일시적 장애에 유용합니다.

재시도는 다음 질문에 답합니다:

이 호출을 다시 시도해야 하는가?

서킷 브레이커

서킷 브레이커는 종속성이 아마도 비정상 상태일 때 호출을 차단합니다.

다음 질문에 답합니다:

지금 이 종속성을 호출해야 하는가?

속도 제한기(Rate Limiter)

속도 제한기는 시간 경과에 따라 허용되는 트래픽 양을 제어합니다.

다음 질문에 답합니다:

이 호출자가 얼마나 많은 트래픽을 보내야 하는가?

벌크헤드(Bulkhead)

벌크헤드는 자원을 격리하여 하나의 종속성이 모든 자원을 소모하지 못하도록 합니다.

다음 질문에 답합니다:

이 종속성이 내 서비스에 얼마나 큰 피해를 줄 수 있는가?

이러한 패턴들은 함께 사용할 때 가장 강력합니다. 타임아웃 없이 서킷 브레이커만 사용하면 약합니다. 지터(jitter) 없이 재시도를 사용하면 재시도 폭풍(retry storms)이 발생할 수 있습니다. 메트릭 없이 폴백만 사용하면 장애를 숨길 수 있습니다.

Go에서 서킷 브레이커를 사용해야 하는 경우

서버와 독립적으로 실패할 수 있는 종속성을 호출할 때 서킷 브레이커를 사용하십시오.

다음은 적합한 후보입니다:

  • 외부 HTTP API
  • 결제 처리기
  • 이메일 및 SMS 제공업체
  • 검색 서비스
  • 추천 서비스
  • LLM 추론 게이트웨이
  • 내부 마이크로서비스 엔드포인트
  • 제3자 SaaS API
  • 느리거나 과부하 상태인 판독 전용 서비스

호출자가 우아하게 저하(degrade)할 수 있는 경우 서킷 브레이커는 특히 유용합니다.

예를 들어:

  • 캐시된 제품 데이터 반환
  • 추천 블록 건너뛰기
  • 결제 제공업체를 일시적으로 사용 불가 표시
  • 종속성이 복구된 후에도 성공하지 못한 메시지를 위해 사망자 큐(dead-letter queue)를 백업으로 하여 나중에 작업 큐에 넣기
  • 부분적 응답 반환
  • 명확한 임시 에러로 빠르게 실패

중요한 질문은 “이 호출이 실패할 수 있는가?“가 아닙니다. 모든 것은 실패할 수 있습니다. 더 나은 질문은 다음과 같습니다:

만약 이 종속성이 실패한다면, 전체 트래픽을 계속 보내야 하는가?

답이 ‘아니오’라면 서킷 브레이커가 도움이 될 수 있습니다.

서킷 브레이커를 사용하지 말아야 하는 경우

패턴이 책임감 있어 보인다고 해서 모든 함수에 서킷 브레이커를 추가하지 마십시오.

서킷 브레이커는 일반적으로 다음 경우에 유용하지 않습니다:

  • 로컬 프로세스 내부 함수 호출
  • 단일 모놀리식 내 단순 CRUD
  • 유효성 검사 로직
  • 결정론적 비즈니스 규칙
  • CPU 전용 로컬 작업
  • 유용한 폴백이 없는 코드 경로
  • 비정방성(non-idempotent) 쓰기 작업
  • 이미 강력한 워크플로우 레이어로 보호된 종속성

서킷 브레이커는 기본적인 위생(management)을 대체하지 않습니다:

  • 타임아웃 설정
  • 컨텍스트 전파
  • 연결 풀 올바르게 사용
  • 명시적 에러 처리
  • 안전한 재시도 구현
  • 실패율 관찰

나쁜 서킷 브레이커는 시스템을 추론하기 어렵게 만들 수 있습니다. 실제 문제를 숨기거나, 트래픽을 너무 공격적으로 거부하거나, 복구 과정에서 혼란스러운 동작을 생성할 수 있습니다.

약간은 의견이 담긴 규칙은 단순합니다:

서킷 브레이커는 모든 곳에 아니라, 종속성 경계에서 추가하십시오.

Go 서킷 브레이커 라이브러리 선택

기본적인 서킷 브레이커를 직접 구현할 수 있지만, 대부분의 프로덕션 Go 서비스는 라이브러리를 사용해야 합니다.

가장 일반적인 간단한 선택지는 sony/gobreaker입니다.

이 라이브러리는 다음을 제공합니다:

  • 닫힘, 열림, 반열림 상태
  • 구성 가능한 실패 임계값
  • 구성 가능한 열림 상태 타임아웃
  • 상태 변경 콜백
  • 요청 카운터
  • v2의 범용 지원
  • 작은 API 표면

더 큰 복원력 파이프라인의 경우, 재시도, 타임아웃, 폴백, 속도 제한, 벌크헤드 격리, 서킷 브레이커 등 여러 정책을 조합하는 라이브러리를 살펴볼 수 있습니다. 이는 작업 주위에 단일 복원력 레이어를 원할 때 유용합니다.

하지만 많은 Go 서비스에게 gobreaker면 충분합니다.

Go 서킷 브레이커 패키지 비교

Go 표준 라이브러리에는 내장된 서킷 브레이커가 없습니다. 실제로는 작은 서킷 브레이커 라이브러리, 큰 복원력 프레임워크, 또는 오래된 Hystrix 스타일 패키지 중에서 선택합니다.

대부분의 새로운 Go 서비스에서 결정은 단순합니다:

  • 작고 집중된 서킷 브레이커가 필요하면 sony/gobreaker 사용
  • 재시도, 타임아웃, 폴백, 벌크헤드, 속도 제한 등 다른 복원력 정책과 조합된 서킷 브레이커가 필요하면 failsafe-go 사용
  • 이미 이를 사용하는 레거시 코드가 없는 한 hystrix-go를 새 프로젝트에 사용하지 않음
패키지 최적의 용도 장점 단점
sony/gobreaker/v2 HTTP/RPC 클라이언트 주변의 단순 서킷 브레이커 작은 API, 범용 v2 지원, 명확한 상태 모델, 종속성 클라이언트 래핑 용이 서킷 브레이킹만 해결; 재시도, 타임아웃, 폴백은 별도 조합 필요
failsafe-go 전체 복원력 정책 조합 재시도, 폴백, 서킷 브레이커, 타임아웃, 벌크헤드, 속도 제한기, 캐시, 헤지, 적응형 제한기, 적응형 스로틀러 정책 학습해야 할 개념이 많음; 기본 브레이커만 원한다면 필요 이상으로 무거움
afex/hystrix-go 레거시 Hystrix 스타일 시스템 익숙한 Hystrix 개념, 명령어 스타일 실행, 역사적 사용 기록 오래된 디자인; 새로운 Go 서비스에 최적의 기본값은 아님
go-kit/kit/circuitbreaker Go kit 엔드포인트 기반 서비스 Go kit 미들웨어 스타일 및 엔드포인트 아키텍처 적합 이미 Go kit를 사용하는 서비스에서만 유용
cep21/circuit Hystrix 유사 서킷 브레이커 동작 기능적인 Hystrix 스타일 접근 방식 단순한 기본값으로 덜 일반적; 작은 서비스에 필요 이상일 수 있음

제 추천은 의도적으로 심플합니다: 서킷 브레이커만 필요하면 sony/gobreaker/v2로 시작하십시오. 한 곳에서 완전한 복원력 정책을 표현하고 싶다면 failsafe-go를 사용하십시오.

이 분리는 아키텍처를 깔끔하게 유지합니다. 작은 서비스 클라이언트가 실패하는 종속성에 대한 호출을 중단하기 위해 전체 복원력 프레임워크가 필요하지는 않습니다. 그러나 게이트웨이, 집계기, API 클라이언트 SDK, 또는 고통량 통합 레이어는 조합된 정책에서 혜택을 받을 수 있습니다.

gobreaker 설치

새 코드에는 v2 패키지를 사용하십시오:

go get github.com/sony/gobreaker/v2

그런 다음 가져옵니다:

import "github.com/sony/gobreaker/v2"

Go의 기본 서킷 브레이커

HTTP 호출 주변의 작은 예시입니다.

package main

import (
    "context"
    "errors"
    "fmt"
    "io"
    "net/http"
    "time"

    "github.com/sony/gobreaker/v2"
)

var ErrTemporaryUnavailable = errors.New("dependency temporarily unavailable")

type UserClient struct {
    baseURL string
    http    *http.Client
    cb      *gobreaker.CircuitBreaker[[]byte]
}

func NewUserClient(baseURL string) *UserClient {
    settings := gobreaker.Settings{
        Name:        "user-service",
        MaxRequests: 3,
        Interval:    30 * time.Second,
        Timeout:     10 * time.Second,
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            return counts.ConsecutiveFailures >= 5
        },
        OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) {
            fmt.Printf("circuit breaker %s changed from %s to %s\n", name, from, to)
        },
    }

    return &UserClient{
        baseURL: baseURL,
        http: &http.Client{
            Timeout: 3 * time.Second,
        },
        cb: gobreaker.NewCircuitBreaker[[]byte](settings),
    }
}

func (c *UserClient) GetUser(ctx context.Context, userID string) ([]byte, error) {
    result, err := c.cb.Execute(func() ([]byte, error) {
        req, err := http.NewRequestWithContext(
            ctx,
            http.MethodGet,
            c.baseURL+"/users/"+userID,
            nil,
        )
        if err != nil {
            return nil, err
        }

        resp, err := c.http.Do(req)
        if err != nil {
            return nil, err
        }
        defer resp.Body.Close()

        if resp.StatusCode >= 500 {
            return nil, fmt.Errorf("user service returned %d", resp.StatusCode)
        }

        if resp.StatusCode == http.StatusNotFound {
            return nil, fmt.Errorf("user not found")
        }

        if resp.StatusCode >= 400 {
            return nil, fmt.Errorf("user service client error: %d", resp.StatusCode)
        }

        return io.ReadAll(resp.Body)
    })

    if errors.Is(err, gobreaker.ErrOpenState) {
        return nil, ErrTemporaryUnavailable
    }

    if errors.Is(err, gobreaker.ErrTooManyRequests) {
        return nil, ErrTemporaryUnavailable
    }

    return result, err
}

이것은 완전한 프로덕션용 클라이언트는 아니지만, 다음과 같은 형태를 보여줍니다:

  • 브레이커가 아웃바운드 호출을 감쌉니다
  • HTTP 요청에 컨텍스트가 전달됩니다
  • HTTP 클라이언트에 타임아웃이 있습니다
  • 서버 측 실패는 브레이커 실패로 카운트됩니다
  • 열린 서킷 에러는 애플리케이션 에러로 매핑됩니다

gobreaker 설정 구성

핵심 설정들은 이해할 가치가 있습니다.

Name

Name은 브레이커를 식별합니다.

안정적이고 구체적인 이름을 사용하십시오:

payment-api
search-service
llm-gateway
user-service

모호한 이름은 피하십시오:

http-client
external-call
default

이름은 로그와 메트릭에서 필요합니다.

MaxRequests

MaxRequests는 브레이커가 반열림 상태일 때 허용되는 요청 수를 제어합니다.

작은 수가 일반적으로 더 안전합니다. 반열림의 목적은 전체 트래픽을 즉시 보내는 것이 아니라 복구를 테스트하는 것입니다.

Interval

Interval은 브레이커가 닫혀 있을 때 내부 카운터를 지우는 시기를 제어합니다.

제로인 경우 카운터가 자동으로 지워지지 않습니다. 0이 아닌 간격은 브레이커에 롤링-ISH한 메모리 창을 제공하지만, 전체 슬라이딩 윈도우 구현과는 다릅니다.

Timeout

Timeout은 브레이커가 열려 있다가 반열림으로 전환되기 전에 머무는 시간을 제어합니다.

타임아웃이 너무 짧으면, 복구되지 않은 종속성을 계속 probing하게 됩니다. 너무 길면 복구가 지연됩니다.

10초에서 30초처럼 보수적인 것으로 시작한 후, 프로덕션 메트릭을 바탕으로 튜닝하십시오.

ReadyToTrip

ReadyToTrip은 브레이커가 열려야 하는 시기를 결정합니다.

간단한 규칙은 연속 실패입니다:

ReadyToTrip: func(counts gobreaker.Counts) bool {
    return counts.ConsecutiveFailures >= 5
}

이해하기 쉽지만, 대용량 서비스에는 적합하지 않을 수 있습니다.

다른 옵션은 최소 요청 수 이후의 실패 비율입니다:

ReadyToTrip: func(counts gobreaker.Counts) bool {
    total := counts.Requests
    failures := counts.TotalFailures

    if total < 20 {
        return false
    }

    return float64(failures)/float64(total) >= 0.5
}

이는 작은 샘플 크기 이후에 서킷이 열리는 것을 피합니다.

OnStateChange

OnStateChange는 로그 또는 메트릭을 발생시켜야 하는 곳입니다.

최소한 다음을 기록하십시오:

  • 브레이커 이름
  • 이전 상태
  • 새 상태
  • 타임스탬프

프로덕션 시스템에서는 브레이커 상태를 메트릭으로 노출하십시오. 로그는 디버깅에 유용하지만, 메트릭은 알람 및 대시보드에 더 좋습니다.

IsSuccessful

IsSuccessful는 어떤 에러가 실패로 카운트되어야 하는지 결정합니다.

이것이 중요합니다.

모든 에러가 브레이커를 열어야 하는 것은 아닙니다. 예를 들어, 사용자 서비스로부터의 404 Not Found는 유효한 비즈니스 결과일 수 있습니다. 400 Bad Request는 종속성의 문제가 아니라 호출자의 문제일 수 있습니다.

503 Service Unavailable, 타임아웃, 연결 재설정, 또는 429 Too Many Requests는 실제 종속성 건강 신호일 수 있습니다.

여기서 주의하십시오. 잘못된 에러를 카운트하는 것은 노이즈가 많은 서킷 브레이커를 만드는 가장 쉬운 방법 중 하나입니다.

무엇을 실패로 간주해야 하는가?

여기서 엔지니어링 판단이 중요합니다.

일반적으로 다음을 실패로 카운트하십시오:

  • 네트워크 타임아웃
  • 연결 거부
  • 연결 재설정
  • HTTP 500
  • HTTP 502
  • HTTP 503
  • HTTP 504
  • 반복적인 429 응답
  • 종속성으로부터의 잘못된 응답
  • 아웃바운드 호출 중 컨텍스트 데드라인 초과

일반적으로 다음을 종속성 실패로 카운트하지 마십시오:

  • 유효성 검사 에러
  • 로컬 직렬화 에러
  • 예상되는 404 응답
  • 호출자 측 인증 실패
  • 비즈니스 규칙 거부
  • 사용자 입력 에러

브레이커는 일반적인 애플리케이션 실패가 아닌 종속성 건강을 나타내야 합니다.

서킷 브레이커와 context.Context

Go에서는 서킷 브레이커가 context.Context를 대체해서는 안 됩니다.

서킷 브레이커는 호출을 시도할지 여부를 결정합니다. 컨텍스트는 해당 호출이 얼마나 오래 실행될 수 있는지와 호출자가 사라졌을 때 멈춰야 하는지 제어합니다.

좋은 아웃바운드 호출에는 보통 둘 다 있어야 합니다:

ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()

data, err := client.GetUser(ctx, userID)

컨텍스트는 호출 체인을 통해 흐릅니다:

들어오는 요청 컨텍스트
-> 서비스 메서드
-> 클라이언트 메서드
-> HTTP 요청
-> 종속성

요청 스코프 코드 내부에 분리된 백그라운드 컨텍스트를 생성하지 마십시오. 사용자 요청이 취소되면, 하류 작업도 보통 멈추어야 합니다.

조용한 규칙은 다음과 같습니다:

브레이커는 시스템을 보호합니다. 컨텍스트는 요청을 보호합니다.

보통 둘 다 필요합니다.

서킷 브레이커와 재시도

재시도와 서킷 브레이커는 잘 작동할 수 있지만, 순서가 중요합니다.

가장 안전한 기본값은 다음과 같습니다:

시도당 타임아웃
백오프 및 지터와 함께 재시도
종속성 호출 주변 서킷 브레이커

하지만 만능 답은 없습니다. 무엇을 카운트하고 싶은지 생각하십시오.

각 재시도 시도가 브레이커를 통과한다면, 하나의 사용자 요청이 여러 실패를 기여할 수 있습니다. 이는 브레이커를 더 빨리 열게 할 수 있으며, 이는 좋거나 나쁠 수 있습니다.

브레이커가 전체 재시도 작업을 감싸면, 브레이커는 사용자 요청당 하나의 최종 성공 또는 실패를 봅니다. 이는 더 조용하지만, 실패한 시도 수를 숨길 수 있습니다.

많은 애플리케이션 서비스에 대해 다음 형태가 합리적입니다:

사용자 요청
-> 서킷 브레이커
   -> 재시도 정책
      -> 타임아웃이 있는 단일 HTTP 시도

이는 브레이커가 종속성 작업이 호출자에게 최종적으로 성공했는지 추적함을 의미합니다.

하위 레벨 클라이언트의 경우 다음 형태도 의미가 있을 수 있습니다:

사용자 요청
-> 재시도 정책
   -> 서킷 브레이커
      -> 타임아웃이 있는 단일 HTTP 시도

이는 브레이커가 각 시도를 보호함을 의미합니다.

더 중요한 규칙은 다음과 같습니다:

무작정 재시도하지 마십시오.

다음들을 사용하십시오:

  • 작은 최대 재시도 횟수
  • 지수 백오프
  • 지터
  • 시도당 타임아웃
  • 전체 요청 데드라인
  • 쓰기에 대한 정방성(idempotency)
  • 재시도 시도에 대한 메트릭

이것들이 없으면, 재시도는 작은 장애를 더 큰 것으로 만들 수 있습니다. 재시도 안전성에 대해 더 자세히 보려면 실제로 작동하는 분산 시스템의 정방성을 참조하십시오.

서킷 브레이커와 정방성(Idempotency)

서킷 브레이커는 종종 재시도와 함께 나타나며, 재시도는 정방성에 대한 질문을 제기합니다.

읽기 작업의 경우 재시도는 보통 안전합니다.

쓰기 작업의 경우 재시도는 위험할 수 있습니다.

다음 결제 호출을 고려하십시오:

POST /charge

요청이 타임아웃되면, 결제는 실패했을까요? 아마도 그렇습니다. 성공했지만 응답이 손실되었을까요? 또한 아마도 그렇습니다.

정방성 키 없이 재시도하면, 두 번 충전할 수 있습니다.

쓰기 작업에는 다음 중 하나 이상을 사용하십시오:

  • 정방성 키
  • 요청 ID
  • 작업 ID
  • 고유 제약 조건
  • 트랜잭셔널 아웃박스
  • 워크플로우 오케스트레이션
  • 명시적 조정

서킷 브레이커는 실패하는 결제 제공업체에 계속 호출하는 것을 멈추게 할 수 있지만, 안전하지 않은 재시도를 안전하게 만들 수는 없습니다.

서킷 브레이커와 폴백

서킷이 열리면 서비스는 계획이 필요합니다.

가능한 폴백 전략은 다음과 같습니다:

  • 캐시된 데이터 반환
  • 경고와 함께 오래된 데이터 반환
  • 중요하지 않은 섹션 생략
  • 나중에 위해 작업 큐에 넣기
  • 다른 제공업체로 전환
  • 임시 에러 반환
  • 저하된 기능 표시
  • 빠르게 요청 실패

폴백은 정직해야 합니다.

예를 들어, 다음은 일반적으로 좋습니다:

{
  "status": "temporary_unavailable",
  "message": "Recommendations are temporarily unavailable"
}

다음은 위험합니다:

{
  "recommendations": []
}

빈 목록은 유효한 결과처럼 보일 수 있습니다. 이는 장애를 숨기고, 사용자를 혼란스럽게 하며, 디버깅을 더 어렵게 만들 수 있습니다.

침묵하는 폴백은 유혹적입니다. 그러나 위험합니다.

서킷 브레이커와 관찰 가능성(Observability)

관찰 가능성이 없는 서킷 브레이커는 대부분 놀람 생성기입니다.

최소한 다음 메트릭을 추적하십시오:

  • 현재 브레이커 상태
  • 상태 변경
  • 허용된 호출
  • 거부된 호출
  • 성공
  • 실패
  • 타임아웃
  • 폴백 응답
  • 재시도 시도
  • 하류 지연 시간
  • 하류 상태 코드

유용한 레이블은 다음과 같습니다:

  • 브레이커 이름
  • 종속성 이름
  • 작업 이름
  • 상태 클래스
  • 에러 카테고리

사용자 ID, 전체 URL, 요청 ID, 또는 원본 에러 메시지 같은 높은 카디널리티 레이블을 피하십시오.

대시보드에서 다음 질문에 답할 수 있어야 합니다:

  • 현재 어떤 서킷 브레이커가 열려 있는가?
  • 얼마나 자주 열리는가?
  • 어떤 종속성이 열림을 유발했는가?
  • 사용자가 폴백 응답을 보고 있는가?
  • 브레이커가 열린 후 지연 시간이 개선되었는가?
  • 브레이커가 열리기 전에 재시도 볼륨이 급증했는가?
  • 종속성이 복구되었는가?

브레이커를 관찰할 수 없으면, 이를 튜닝할 수 없습니다. 메트릭과 잘 맞는 구조화된 로깅을 위해 Go의 slog를 사용한 구조화된 로깅을 참조하십시오.

더 프로덕션 친화적인 HTTP 클라이언트 형태

실제 서비스에 대해 핸들러 전반에 서킷 브레이커 로직을 산만하게 배치하지 마십시오.

종속성 주변에 작은 클라이언트 패키지를 만드십시오.

예제 구조:

internal/
  userservice/
    client.go
    errors.go
    metrics.go

핸들러는 gobreaker의 세부 사항을 알 필요가 없습니다. 도메인 레벨 클라이언트 메서드에 의존해야 합니다:

type UserService interface {
    GetUser(ctx context.Context, userID string) (*User, error)
}

그런 다음 구현체는 다음을 포함할 수 있습니다:

  • HTTP 요청 생성
  • 컨텍스트 전파
  • 브레이커 실행
  • 상태 코드 처리
  • 응답 디코딩
  • 메트릭
  • 에러 매핑

이렇게 하면 복원력 정책이 종속성 경계 근처에 가까워집니다. 경계에서의 에러 분류에 대해 더 자세히 보려면 Go 에러 처리 아키텍처: 경계와 패턴을 참조하십시오.

애플리케이션 아키텍처에서 서킷 브레이커의 위치

서킷 브레이커 패턴은 통합 경계에 속합니다.

Go 애플리케이션에서는 보통 다음과 같습니다:

graph LR A[Handler] --> B[Application Service] B --> C[Dependency Client] C --> D[Circuit Breaker] D --> E[HTTP / RPC / DB / Queue]

가능하면 비즈니스 로직에서 브레이커를 멀리 두십시오.

비즈니스 레이어는 다음과 같은 도메인 에러를 이해해야 합니다:

결제 제공업체 사용 불가
추천 서비스 사용 불가
프로필 서비스 타임아웃

gobreaker 상태를 이해할 필요는 없습니다.

이 분리는 아키텍처를 깔끔하게 유지합니다:

  • 트랜스포트 관심사는 클라이언트에 머무름
  • 복원력 정책은 종속성 근처에 머무름
  • 도메인 로직은 가독성 유지
  • 핸들러는 얇음
  • 테스트 작성 용이

이 기사는 프로덕션 앱 아키텍처 주제 시리즈의 일부입니다. 통합 패턴의 정방성, 아웃박스, 사가, 오케스트레이션 가이드와 함께 제공됩니다.

흔한 실수

실수 1: 타임아웃 없음

서킷 브레이커는 호출이 반환되지 않는 한 느린 호출을 마법처럼 멈추지 않습니다.

아웃바운드 작업이 영원히 걸릴 수 있다면, 브레이커가 실패를 충분히 빠르게 보지 못할 수 있습니다.

항상 타임아웃을 사용하십시오.

실수 2: 모든 것에 대한 단일 전역 브레이커

모든 종속성에 대해 하나의 브레이커를 사용하지 마십시오.

실패하는 이메일 제공업체가 결제 제공업체의 회로를 열어서는 안 됩니다. 느린 검색 엔드포인트가 사용자 프로필 호출을 차단해서는 안 됩니다.

실패 모드가 다른 별도의 종속성 작업에 대해 별도의 브레이커를 사용하십시오.

실수 3: 호출자 에러를 종속성 실패로 카운트하기

서비스가 잘못된 입력을 보내고 400 Bad Request를 받으면, 이는 일반적으로 다운스트림 장애가 아닙니다.

브레이커를 자신의 버그에 대해 훈련하지 마십시오.

실수 4: 비정방성 쓰기 재시도

재시도는 무료가 아닙니다. 중복 쓰기, 결제, 메시지 또는 사이드 이펙트를 생성할 수 있습니다.

재시도하기 전에 쓰기를 정방성으로 만드십시오.

실수 5: 폴백 뒤에 장애 숨기기

폴백은 우아하게 저하되어야 하며, 현실을 왜곡해서는 안 됩니다.

종속성이 다운되면, 메트릭과 로그에서 그 사실이 명백해야 합니다.

실수 6: 프로덕션 데이터 없이 튜닝하기

예제에서 복사한 임계값은 시작점에 불과합니다.

다음에 기반하여 튜닝하십시오:

  • 요청 볼륨
  • 정상적인 에러율
  • 종속성 지연 시간
  • 사용자 영향
  • 복구 시간
  • 폴백 품질

실수 7: 용량 관리를 대신하여 서킷 브레이커 사용

서킷 브레이커는 다음을 대체하지 않습니다:

  • 부하 제거(load shedding)
  • 속도 제한
  • 큐 한도
  • 오토스케일링
  • 데이터베이스 튜닝
  • 연결 풀 한도
  • 업스트림 할당량

이는 복원력 전략의 일부일 뿐입니다.

실용적인 기본값

내부 HTTP 종속성을 호출하는 일반적인 Go 서비스에 대해, 합리적인 시작점은 다음과 같을 수 있습니다:

HTTP 클라이언트 타임아웃: 2~5초
요청별 컨텍스트 타임아웃: 호출자 SLA 기반
브레이커 실패 규칙: 5회 연속 실패 또는 20개 요청 후 50% 실패율
오픈 타임아웃: 10~30초
반열림 요청: 1~5
재시도 횟수: 1~3 시도
재시도 백오프: 지터와 함께 지수 백오프

이것들은 만능 값이 아닙니다. 안전-ish한 시작점입니다.

사용자Facing API의 경우 전체 지연 시간 예산을 엄격하게 유지하십시오. 백그라운드 작업의 경우 더 긴 대기를 허용할 수 있습니다. 결제 제공업체의 경우 재시도 및 정방성에 대해 훨씬 더 신중해야 합니다.

서킷 브레이커 체크리스트

서킷 브레이커를 추가하기 전에 다음 질문에 답하십시오:

  • 어떤 종속성을 보호하는가?
  • 어떤 작업을 보호하는가?
  • 어떤 에러가 종속성 실패로 카운트되는가?
  • 어떤 에러를 브레이커가 무시해야 하는가?
  • 각 호출에 적용되는 타임아웃은 무엇인가?
  • 재시도가 허용되는가?
  • 쓰기가 정방성인가?
  • 서킷이 열렸을 때 어떻게 되는가?
  • 폴백이 있는가?
  • 폴백이 메트릭에서 보이는가?
  • 서킷이 계속 열리면 누구에게 알람이 가는가?
  • 배포 후 브레이커를 어떻게 튜닝할 것인가?

이것들에 답할 수 없다면, 브레이커를 추가하는 것이 복원력보다 더 많은 혼란을 초래할 수 있습니다.

Go에서 서킷 브레이커 테스트하기

라이브러리의 내부 상태 기계가 아닌 동작을 테스트하십시오.

유용한 테스트는 다음과 같습니다:

  • 종속성이 성공하고 응답이 반환됨
  • 종속성이 반복적으로 실패하고 회로가 열림
  • 열린 회로가 임시 에러 반환
  • 클라이언트 측 유효성 검사 에러가 브레이커를 트리거하지 않음
  • 컨텍스트 타임아웃이 준수됨
  • 예상될 때 폴백 응답 반환
  • 상태 변경 시 메트릭 발생

통합 스타일 테스트를 위해 가짜 HTTP 서버를 사용하십시오:

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()

유닛 테스트의 경우, 의존성을 인터페이스 뒤로 숨기고 가짜 구현을 주입하십시오.

테스트를 결정론적으로 유지하십시오. 긴 실제 지속 시간을 위해 수면하지 마십시오. 테스트에서 짧은 브레이커 타임아웃을 구성하십시오. 가짜 시간 및 분리된 버블과 함께 동적 Go 코드 테스트에 대해 더 자세히 보려면 testing/synctest를 사용한 동적 Go 코드 테스트를 참조하십시오.

자체 서킷 브레이커를 구축해야 하는가?

작은 서킷 브레이커를 구축하는 것은 좋은 학습 연습입니다. 상태 기계를 이해하는 데 도움이 됩니다.

프로덕션 코드의 경우, 요구사항이 매우 구체적이지 않은 한 유지 관리되는 라이브러리를 선호하십시오.

프로덕션 브레이커는 다음을 처리해야 합니다:

  • 동시성
  • 상태 전환
  • 카운터
  • 반열림 프로브
  • 콜백
  • 사용자 정의 실패 분류
  • Race-free 동작
  • 예측 가능한 에러 처리

그것은 불가능하지 않지만, 미묘하게 잘못되기 쉽습니다.

심플한 라이브러리가 보통 더 나은 선택입니다.

결론

서킷 브레이커 패턴은 마법 같은 신뢰성 가루가 아닙니다.

Go에서는 작은 명시적 복원력 스택의 일부일 때 가장 잘 작동합니다:

컨텍스트 타임아웃
+ 지터와 함께 백오프 재시도
+ 서킷 브레이커
+ 폴백
+ 메트릭

이 패턴은 특히 느려지거나 부분적으로 사용 불가능해질 수 있는 원격 서비스 주변에서 종속성 경계에서 가장 유용합니다.

연쇄 장애를 방지하기 위해 사용하십시오. 종속성이 명확히 비정상 상태일 때 빠르게 실패하기 위해 사용하십시오. 과부하 상태인 시스템이 복구할 공간을 주기 위해 사용하십시오.

그러나 타임아웃, 정방성, 관찰 가능성, 또는 깔끔한 아키텍처를 무시하는 변명으로 사용하지 마십시오.

좋은 서킷 브레이커는 실패를 더 명확하고 저렴하게 만듭니다. 나쁜 것은 단순히 실패를 더 신비롭게 만들 뿐입니다.

참조

구독하기

시스템, 인프라, AI 엔지니어링에 관한 새 글을 받아보세요.