프레임워크 수준 서킷 브레이커로 복원력 있는 시스템 구축하기
Wenhao Wang
Dev Intern · Leapcell

소개
현대 분산 시스템의 복잡한 세계에서 단일 실패 지점은 빠르게 광범위한 장애로 확대될 수 있습니다. 서비스는 끊임없이 통신하며, 한 컴포넌트의 사용 불가능 또는 느린 응답은 상위 서비스에 불균형적으로 영향을 미쳐 연쇄 실패라고 알려진 도미노 효과를 초래할 수 있습니다. 재고 서비스가 응답하지 않는 전자상거래 플랫폼을 상상해 보세요. 주문 처리 서비스가 재고에 대한 실패한 요청을 계속해서 다시 시도하면 자체 리소스가 고갈되어 느려지거나 사용할 수 없게 될 수 있습니다. 이는 다시 사용자 대면 스토어프런트에 영향을 미쳐 시스템 전체가 붕괴될 수 있습니다. 이러한 시나리오를 방지하는 것은 시스템 안정성을 유지하고 긍정적인 사용자 경험을 보장하는 데 가장 중요합니다. 이 글에서는 백엔드 프레임워크 내에 서킷 브레이커 패턴을 직접 구현하여 이러한 위험을 사전에 완화하는 방법을 살펴보고, 오류를 효과적으로 격리하고 확산을 방지합니다.
핵심 개념 이해
구현 세부 사항을 살펴보기 전에 관련된 핵심 용어에 대한 공통된 이해를 확립해 보겠습니다.
- 분산 시스템(Distributed System): 구성 요소가 서로 메시지를 주고받으며 통신하고 작업을 조정하는 여러 네트워크 컴퓨터에 있는 시스템입니다.
- 연쇄 실패(Cascading Failure): 시스템의 실패가 연속적인 단계를 거쳐 확산되어 그 효과가 전파되고 잠재적으로 전체 상호 연결된 시스템을 다운시키는 것입니다.
- 복원력(Resilience): 시스템이 완전히 실패하는 대신 장애로부터 복구하고, 부분적으로라도 계속 작동할 수 있는 능력입니다.
- 서킷 브레이커 패턴(Circuit Breaker Pattern): 실패할 가능성이 높은 작업을 반복적으로 실행하려는 시도를 애플리케이션이 방지하도록 설계된 아키텍처 패턴입니다. 실패할 수 있는 함수 호출을 래핑하고 실패를 모니터링합니다. 실패 횟수가 특정 임계값에 도달하면 서킷 브레이커가 작동하고, 래핑된 함수에 대한 후속 모든 호출은 시도하지 않고 즉시 오류를 반환합니다. 이를 통해 실패한 서비스가 복구할 시간을 주고 호출 서비스가 잘못된 호출에 리소스를 낭비하는 것을 방지합니다.
서킷 브레이커 패턴은 세 가지 상태로 작동합니다.
- 닫힘(Closed): 이 상태에서는 서킷 브레이커가 요청을 보호된 작업으로 전달합니다. 실패가 발생하면 서킷 브레이커가 기록합니다. 특정 시간 창 내에서 실패 횟수가 미리 정의된 임계값을 초과하면 서킷 브레이커는 열림(Open) 상태로 전환됩니다.
- 열림(Open): 이 상태에서는 서킷 브레이커가 보호된 작업을 호출하지 않고 모든 요청을 즉시 실패 처리합니다. 구성된 시간 제한 후 반 열림(Half-Open) 상태로 전환됩니다.
- 반 열림(Half-Open): 이 상태에서는 서킷 브레이커가 제한된 수의 테스트 요청을 보호된 작업으로 전달합니다. 이러한 테스트 요청이 성공하면 서킷 브레이커는 닫힘(Closed) 상태로 재설정됩니다. 실패하면 구성된 시간 제한 동안 즉시 열림(Open) 상태로 돌아갑니다.
프레임워크 수준 서킷 브레이커 구현
프레임워크 수준에서 서킷 브레이커를 구현하면 상당한 이점을 얻을 수 있습니다. 장애 내결함성 논리가 중앙 집중화되고, 개별 서비스의 상용구 코드가 줄어들며, 전체 시스템에 걸쳐 패턴의 일관된 적용이 보장됩니다. Go 언어로 작성되었고 Hystrix 라이브러리를 사용하는 가상의 마이크로 서비스 아키텍처를 사용하겠습니다 (원칙은 Java의 Resilience4j 또는 Python의 Tenacity와 같은 다른 언어 및 프레임워크에도 광범위하게 적용됩니다).
Order Service가 Payment Service를 호출해야 하는 시나리오를 생각해 봅시다. Payment Service의 실패로부터 Order Service를 보호하고 싶습니다.
먼저 Payment Service 클라이언트를 정의해 보겠습니다.
// payment_client.go package main import ( "errors" "fmt" time "time" ) // PaymentServiceClient는 외부 결제 서비스 호출을 시뮬레이션합니다. type PaymentServiceClient interface { ProcessPayment(orderID string, amount float64) error } type mockPaymentServiceClient struct { failRequests bool failRate int // 실패할 요청의 백분율 latency time.Duration callCount int } func NewMockPaymentServiceClient(failRequests bool, failRate int, latency time.Duration) *mockPaymentServiceClient { return &mockPaymentServiceClient{ failRequests: failRequests, failRate: failRate, latency: latency, } } func (m *mockPaymentServiceClient) ProcessPayment(orderID string, amount float64) error { m.callCount++ time.Sleep(m.latency) if m.failRequests && m.callCount%100 < m.failRate { fmt.Printf("PaymentServiceClient: 주문 %s에 대해 실패 시뮬레이션 중\n", orderID) return errors.New("결제 서비스 사용 불가 또는 시간 초과") } if m.callCount%10 == 0 { // 간헐적인 성공 시뮬레이션 (반 열림 상태 테스트용) fmt.Printf("PaymentServiceClient: 주문 %s에 대한 결제 성공적으로 처리됨\n", orderID) } else { fmt.Printf("PaymentServiceClient: 주문 %s에 대한 결제 성공적으로 처리됨\n", orderID) } return nil }
이제 Hystrix를 프레임워크 수준, 아마도 맞춤형 HTTP 클라이언트 또는 서비스 래퍼 내에 통합해 보겠습니다.
// main.go package main import ( "fmt" "log" time "time" "github.com/afex/hystrix-go/hystrix" ) // PaymentServiceCircuitBreakerClient는 실제 결제 클라이언트를 Hystrix와 래핑합니다. type PaymentServiceCircuitBreakerClient struct { paymentClient PaymentServiceClient commandName string } func NewPaymentServiceCircuitBreakerClient(client PaymentServiceClient, commandName string) *PaymentServiceCircuitBreakerClient { // 이 특정 명령에 대한 Hystrix 구성 hystrix.ConfigureCommand(commandName, hystrix.CommandConfig{ Timeout: 1000, // 명령 실행 시간 초과 (ms) MaxConcurrentRequests: 10, // 허용되는 최대 동시 요청 수 RequestVolumeThreshold: 5, // 회전 통계 창 내에서 회로를 작동시키기 위한 최소 요청 수 ErrorPercentThreshold: 50, // 회로를 작동시키기 위한 실패율 (백분율) SleepWindow: 5000, // 회로가 열린 후 Hystrix가 단일 요청을 통과시키는 시간 (ms) }) return &PaymentServiceCircuitBreakerClient{ paymentClient: client, commandName: commandName, } } func (c *PaymentServiceCircuitBreakerClient) ProcessPayment(orderID string, amount float64) error { var err error err = hystrix.Do(c.commandName, func() error { // 실제 결제 서비스 호출 return c.paymentClient.ProcessPayment(orderID, amount) }, func(e error) error { // 폴백 함수. 명령이 실패하거나 서킷이 열린 경우 실행됩니다. log.Printf("폴백이 주문 %s에 대해 오류로 인해 트리거되었습니다: %v", orderID, e) // 여기서 오류를 로깅하거나, 결제를 재시도하도록 큐에 넣거나, 기본 응답을 반환할 수 있습니다. return fmt.Errorf("주문 %s에 대한 결제 처리 폴백이 트리거되었습니다: %w", orderID, e) }) return err } func main() { fmt.Println("결제 서비스 서킷 브레이커 데모 시작") // 결제 서비스 실패 및 지연 시뮬레이션 // 처음에는 실패율을 높게 설정합니다. mockClient := NewMockPaymentServiceClient(true, 70, 50*time.Second) // 클라이언트를 서킷 브레이커로 래핑 cbcClient := NewPaymentServiceCircuitBreakerClient(mockClient, "payment_service_process_payment") fmt.Println("\n--- 1단계: 높은 실패율 ---") // 서킷을 작동시키기 위해 많은 요청 시뮬레이션 for i := 0; i < 20; i++ { orderID := fmt.Sprintf("order-%d", i) err := cbcClient.ProcessPayment(orderID, 100.0) if err != nil { fmt.Printf("주문 %s에 대한 결제 처리 오류: %v\n", orderID, err) } else { fmt.Printf("주문 %s에 대한 결제 성공적으로 처리됨\n", orderID) } time.Sleep(100 * time.Second) } fmt.Println("\n--- 서킷 브레이커 상태 ---") // 시간이 지나면 서킷이 열려야 합니다. // Hystrix 대시보드 또는 메트릭에서는 실제 시스템에서 이를 보여줄 것입니다. // 이 데모에서는 폴백 메시지를 관찰할 것입니다. time.Sleep(2 * time.Second) fmt.Println("\n--- 2단계: 서킷 열림 - 요청이 즉시 거부됨 ---") for i := 20; i < 30; i++ { orderID := fmt.Sprintf("order-%d", i) err := cbcClient.ProcessPayment(orderID, 100.0) if err != nil { fmt.Printf("주문 %s에 대한 결제 처리 오류: %v\n", orderID, err) } else { fmt.Printf("주문 %s에 대한 결제 성공적으로 처리됨\n", orderID) } time.Sleep(50 * time.Second) } fmt.Println("\n--- 3단계: SleepWindow가 반 열림 상태를 허용할 때까지 대기 ---") fmt.Println("결제 서비스 복구 시뮬레이션 중. 실패율 감소.") // 결제 서비스가 복구되었다고 가정 mockClient.failRequests = false // 실패 없음 mockClient.failRate = 0 time.Sleep(6 * time.Second) // Hystrix의 SleepWindow(5초)를 넘도록 기다림 fmt.Println("\n--- 4단계: 반 열림 상태 - 테스트 요청 전송, 서킷이 닫혀야 함 ---") for i := 30; i < 40; i++ { orderID := fmt.Sprintf("order-%d", i) err := cbcClient.ProcessPayment(orderID, 100.0) if err != nil { fmt.Printf("주문 %s에 대한 결제 처리 오류: %v\n", orderID, err) } else { fmt.Printf("주문 %s에 대한 결제 성공적으로 처리됨\n", orderID) } time.Sleep(100 * time.Second) } fmt.Println("\n데모 종료.") }
이 예시에서:
PaymentServiceClient인터페이스와 네트워크 호출 및 실패를 시뮬레이션하기 위한mockPaymentServiceClient을 정의했습니다.PaymentServiceCircuitBreakerClient는 프레임워크 수준 래퍼 역할을 합니다. 실제PaymentServiceClient인스턴스와commandName을 받습니다.hystrix.ConfigureCommand는 특정 명령 이름에 대한 서킷 브레이커의 임계값을 설정합니다. 이 구성은 일반적으로 애플리케이션 시작 또는 서비스 초기화 중에 한 번 수행됩니다.ProcessPayment메서드는hystrix.Do를 사용하여 실제 결제 처리 로직을 실행합니다. 기본 명령이 실패하거나 서킷이 열려 있을 때 호출되는fallback함수도 제공합니다. 폴백은 호출 서비스가 차단되거나 즉시 실패하는 것을 방지합니다.
출력은 명확하게 보여줄 것입니다:
- 서킷을 열게 하는 초기 실패.
- 서킷이 열려 있을 때 폴백 오류로 요청이 즉시 거부됨.
SleepWindow이후, 몇 개의 테스트 요청이 통과할 수 있으며(반 열림), 성공하면 서킷이 닫힘.
응용 시나리오:
- 외부 API 호출: 안정적이지 않은 타사 API로부터 서비스를 보호합니다.
- 데이터베이스 액세스: 느린 쿼리 또는 연결 문제 발생 시 데이터베이스 과부하를 방지합니다.
- 서비스 간 통신: 하위 마이크로 서비스의 실패로부터 상위 서비스를 보호합니다.
- 캐싱 계층: 캐시 서비스가 사용할 수 없게 되면, 서킷 브레이커는 복구될 때까지 직접적인 데이터베이스 히트를 방지하고, 적절한 경우 오래된 데이터를 사용하거나 대체 방법을 사용할 수 있습니다.
결론
프레임워크 수준에서 서킷 브레이커 패턴을 구현하는 것은 복원력 있는 백엔드 시스템을 구축하기 위한 강력한 전략입니다. 이는 오류 처리를 캡슐화하고, 내결함성에 대한 일관된 접근 방식을 제공하며, 가장 중요하게는 사소한 문제가 치명적인 연쇄 실패로 확대되는 것을 방지합니다. 서킷 브레이커는 실패를 격리하고 즉각적인 피드백 또는 대체 메커니즘을 제공함으로써 애플리케이션이 충돌하는 대신 우아하게 성능을 저하할 수 있도록 하여, 악조건 하에서 안정성과 신뢰성을 크게 향상시킵니다. 이러한 패턴을 채택하여 단순히 작동하는 것이 아니라 진정으로 견딜 수 있는 시스템을 설계하십시오.

