티스토리 뷰

🪓 서킷 브레이커(Circuit Breaker)란?

 서킷 브레이커는 실패할 가능성(likely to fail)이 있는 작업이 반복적으로 실행되는 것을 방지하기 위한 디자인 패턴의 일종입니다.

 

 서킷 브레이커는 원격 서비스에 대한 요청을 모니터링하여 오류 수를 측정하고, 오류 수가 임계치(threshold)를 넘어가게 되면 원격 서비스로의 요청을 차단하여 장애가 발생한 부분을 격리, 빠르게 오류를 반환함으로써 불필요하게 자원이 낭비되는 것을 방지합니다.


🤲 서킷 브레이커는 왜 필요한가?

 사용자의 요청이 서비스 A를 거쳐서 서비스 B로 전달됩니다.

 

 

 그런데 예기치 않은 문제로 인해 서비스 B가 서비스 A에게 응답을 주지 못하고 있습니다. 이 경우 서비스 B의 장애가 서비스 A로 전이되어 서비스 A는 사용자의 요청을 적절히 처리할 수 없게 됩니다.

 

실제 마이크로 서비스 구축 환경에서는 이보다 많은 서비스들이 서로 연결되어 있을 수 있으며 일부 서비스의 장애가 전체 시스템의 장애로 이어질 수 있는 위험이 있습니다. 또한 처리되지 못하고 있는 요청은 메모리, 스레드 등의 중요한 시스템 리소스를 잡아두게 되고, 결국에는 리소스 부족과 함께 예상치 못한 오류를 야기할 수 있습니다.

 

 따라서 문제가 발생한 서비스는 빠르게 격리하고, 살아있는 서비스들은 적절하게 요청을 처리할 수 있게끔 하는 무언가가 필요합니다. 여기서 그 역할을 수행할 수 있는 것이 바로 서킷 브레이커입니다.


💡 서킷 브레이커는 어떻게 동작하는가?

 서킷 브레이커는 위와 같이 세 가지 상태를 가진 상태 머신으로 구현할 수 있으며 이를 기반으로 동작합니다.

 

  • 닫힘(Closed): 요청이 정상적으로 실행되는 상태입니다. 만약 실패 회수가 임계값을 초과하게 되면 '열림' 상태로 전환됩니다.
  • 열림(Open): 요청이 즉시 실패하고 예외를 반환합니다. '열림' 상태가 되면 타이머를 실행하고, 타이머가 만료되는 시점에 '반 열림' 상태로 전환합니다.
  • 반 열림(Half-Open): 제한된 횟수의 요청만이 실행됩니다. 만약 이 요청들이 성공한다면, 문제가 되는 부분이 해결되었다고 가정하고 서킷 브레이커의 상태를 '닫힘'으로 전환합니다. 그러나 반대로 하나라도 실패하면, 문제가 해결되지 않은 것이므로 '열림' 상태로 전환하여 문제가 발생한 서비스가 복구될 여지를 줍니다.

🥇 서킷 브레이커의 이점

  1. 장애 격리(Isolation of Failures): 서킷 브레이커를 사용하면 오류가 발생한 부분을 격리시킬 수 있으므로 전체 시스템의 안정성을 유지할 수 있습니다.
  2. 회복 능력(Resilience): 서킷 브레이커 패턴을 사용하면 시스템이 오류로부터 회복하는 데 도움이 됩니다. 열림 상태로 전환되었다가 반 열림 상태를 거쳐 닫힘 상태로 전환함으로써 시스템의 회복을 모니터링하고 가능한 빨리 정상 상태로 돌아갈 수 있습니다.
  3. 과부하 방지(Overload Protection): 서킷 브레이커 패턴은 오류가 발생하면 시스템에 대한 요청을 일시 중단하므로 과부하 상태를 방지하고, 시스템에 더 큰 피해를 방지합니다.
  4. 오류 로깅 및 모니터링(Error Logging and Monitoring): 서킷 브레이커 패턴은 오류가 발생하거나 열림/반 열림/닫힘 상태로 전환될 때 이를 기록하고 모니터링할 수 있으므로 시스템의 문제를 식별하고 해결하는 데 도움이 됩니다.

💻 구현해 보기

전체 코드

 

GitHub - piatoss3612/practical-go: repository for practical go programming

repository for practical go programming. Contribute to piatoss3612/practical-go development by creating an account on GitHub.

github.com

 상태 머신의 세 가지 상태에 기반하여 간단한 서킷 브레이커를 구현해 보았습니다. 게시글에 전체 코드를 담기 어려우므로 깃허브를 참고해주시길 바랍니다.

 

주요 타입

type State int // 서킷 브레이커의 상태를 나타내는 타입

const (
	StateClosed State = iota
	StateOpen
	StateHalfOpen
)

// 서킷 브레이커의 상태를 판단하기 위한 Counter (성공/실패 횟수) 구조체
type Counter struct {
	TotalSuccesses       uint32
	TotalFailures        uint32
	ConsecutiveSuccesses uint32
	ConsecutiveFailures  uint32
}

type TripFunc func(c Counter) bool // 서킷 브레이커가 open 상태로 전환되기 위한 조건을 판단하는 함수의 타입

type StateChangeHook func(from, to State) // 서킷 브레이커의 상태가 변경될 때 호출되는 함수의 타입

// 서킷 브레이커 구조체
type CircuitBreaker struct {
	halfOpenMaxSuccesses uint32          // half open 상태에서 closed 상태로 전환되기 위한 최소 성공 횟수
	clearInterval        time.Duration   // 서킷 브레이커의 counter를 초기화하는 주기
	openTimeout          time.Duration   // open 상태에서 half open 상태로 전환되기 위한 시간
	trip                 TripFunc        // 서킷 브레이커가 open 상태로 전환되기 위한 조건을 판단하는 함수
	onStateChange        StateChangeHook // 서킷 브레이커의 상태가 변경될 때 호출되는 함수

	state   State        // 서킷 브레이커의 상태
	counter Counter      // 서킷 브레이커의 상태를 판단하기 위한 counter (성공/실패 횟수)
	mu      sync.RWMutex // 서킷 브레이커의 상태를 변경하기 위한 mutex
}

 서킷 브레이커의 주요 타입은 다음과 같습니다. 

 

  • State: 서킷 브레이커의 상태를 나타냅니다. 
  • Counter: 성공/실패 횟수를 추적하여 서킷 브레이커의 상태를 파악하기 위해 사용합니다.
  • TripFunc: 서킷 브레이커가 '열림' 상태로 전환되기 위한 조건을 판단하기 위해 사용합니다.
  • StateChangeHook: 서킷 브레이커의 상태가 변경될 때 로깅 및 모니터링을 위해 사용합니다.
  • CircuitBreaker: 서킷 브레이커를 나타내는 구조체입니다.

 

초기화

 서킷 브레이커의 초기화는 functional options 패턴을 사용하여 다음과 같이 이루어집니다.

// 서킷 브레이커 생성
cb := circuitbreaker.New(
	circuitbreaker.WithClearInterval(10*time.Second), // 서킷 브레이커의 counter를 초기화하는 주기를 10초로 설정
	circuitbreaker.WithOpenTimeout(5*time.Second),    // open 상태에서 half open 상태로 전환되기 위한 시간을 5초로 설정
	circuitbreaker.WithStateChangeHook(
		func(from, to circuitbreaker.State) {
			slog.Info("state change", slog.String("from", from.String()), slog.String("to", to.String()))
		},
	), // 서킷 브레이커의 상태가 변경될 때 호출되는 함수를 설정
	circuitbreaker.WithTripFunc(func(c circuitbreaker.Counter) bool {
		return c.TotalFailures > 3
	}), // 서킷 브레이커가 open 상태로 전환되기 위한 조건을 판단하는 함수를 설정
)
  • '반 열림' 상태일 때 '닫힘' 상태가 되기 위해 필요한 요청 성공 횟수는 옵션 함수를 따로 전달하지 않았지만, 기본값으로 5회로 지정됩니다.
  • 서킷 브레이커가 '닫힘' 상태일 때 10초마다 카운터를 초기화합니다.
  • 서킷 브레이커가 '열림' 상태로 전환되면 5초 뒤에 '반 열림' 상태로 전환합니다.
  • 서킷 브레이커의 상태가 변경되면 "state change"라는 메시지와 함께 구조화된 로그를 출력합니다.
  • 서킷 브레이커가 '닫힘' 상태일 때 4회 이상 요청이 실패한다면 '열림' 상태로 전환합니다.

 

테스트

 다음과 같은 간단한 서비스 구조를 통해 서킷 브레이커를 테스트해 보겠습니다.

 

service

 service는 8080번 포트의 '/' 경로로 GET 요청을 보내면 'Hello, world!'를 응답하는 간단한 서비스입니다.

package main

import "net/http"

func main() {
	// '/' 경로로 요청이 들어오면 "Hello, world!"를 응답으로 보내는 핸들러를 생성한다.
	h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello, world!"))
	})

	// 8080 포트와 h를 핸들러로 갖는 http.Server를 생성한다.
	srv := http.Server{
		Addr:    ":8080",
		Handler: h,
	}

	// http.Server를 실행한다.
	if err := srv.ListenAndServe(); err != nil {
		panic(err)
	}
}

proxy

 proxy는 4000번 포트의 '/' 경로로 클라이언트의 요청을 받아 service로 프락싱하는 서비스입니다.

package main

import (
	"05-circuit-breaker/circuitbreaker"
	"context"
	"io"
	"log/slog"
	"net/http"
	"time"
)

// proxyServer는 서킷 브레이커를 사용하는 프록시 서버를 나타내는 구조체
type proxyServer struct {
	cb           *circuitbreaker.CircuitBreaker // 서킷 브레이커
	*http.Server                                // 프록시 서버
}

// setup은 프록시 서버를 설정하는 메서드
func (s *proxyServer) setup() {
	s.Server.Handler = s.handler()
}

// handler는 프록시 서버의 핸들러를 반환하는 메서드
func (s *proxyServer) handler() http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// 서킷 브레이커로 wrapping될 함수
		fn := func(ctx context.Context) (interface{}, error) {
			client := http.DefaultClient // http.Client 생성

			// http.Client를 사용하여 http://localhost:8080 경로로 GET 요청을 보낸다.
			req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:8080", nil)
			if err != nil {
				return nil, err
			}

			return client.Do(req) // http.Response를 반환한다.
		}

		// 서킷 브레이커로 wrapping된 함수를 실행한다.
		resp, err := s.cb.Execute(r.Context(), fn)
		if err != nil {
			slog.Error(err.Error())
			w.WriteHeader(http.StatusInternalServerError)
			return
		}

		httpResp := resp.(*http.Response) // 타입 assertion을 사용하여 http.Response를 얻는다.
		defer httpResp.Body.Close()

		w.WriteHeader(httpResp.StatusCode) // http.Response의 status code를 응답한다.
		_, _ = io.Copy(w, httpResp.Body)   // http.Response의 body를 응답한다.
	})
}

func main() {
	// 서킷 브레이커 생성
	cb := circuitbreaker.New(
		circuitbreaker.WithClearInterval(10*time.Second), // 서킷 브레이커의 counter를 초기화하는 주기를 10초로 설정
		circuitbreaker.WithOpenTimeout(5*time.Second),    // open 상태에서 half open 상태로 전환되기 위한 시간을 5초로 설정
		circuitbreaker.WithStateChangeHook(
			func(from, to circuitbreaker.State) {
				slog.Info("state change", slog.String("from", from.String()), slog.String("to", to.String()))
			},
		), // 서킷 브레이커의 상태가 변경될 때 호출되는 함수를 설정
		circuitbreaker.WithTripFunc(func(c circuitbreaker.Counter) bool {
			return c.TotalFailures > 3
		}), // 서킷 브레이커가 open 상태로 전환되기 위한 조건을 판단하는 함수를 설정
	)

	// 프록시 서버 생성 및 실행
	srv := &proxyServer{
		cb: cb,
		Server: &http.Server{
			Addr: ":4000",
		},
	}

	srv.setup()

	if err := srv.ListenAndServe(); err != nil {
		panic(err)
	}
}

'닫힘' 상태에서 service가 정상적으로 동작하는 경우

 service가 살아있다면 클라이언트의 요청이 proxy를 통해 정상적으로 처리되므로 다음과 같이 서킷 브레이커의 카운터가 초기화되었다는 로그를 제외하면 어떤 로그도 출력되지 않습니다.

$ go run ./proxy/
2023/10/07 20:31:27 INFO Successfully reset circuit breaker counter
2023/10/07 20:31:37 INFO Successfully reset circuit breaker counter

'닫힘' 상태에서 service가 정상적으로 동작하지 않는 경우

 service를 종료하고 다시 요청을 보내다보면 네 번째로 요청을 실패했을 때 서킷 브레이커의 상태가 '닫힘'에서 '열림'으로 전환됩니다. 이후에 보내는 요청들은 서킷 브레이커 딴에서 차단됩니다. 서킷 브레이커의 상태가 '열림'으로 전환되고 5초가 지나면 타임아웃에 의해 서킷 브레이커의 상태가 '반 열림' 상태로 전환됩니다.

2023/10/07 20:33:20 ERROR Get "http://localhost:8080": dial tcp 127.0.0.1:8080: connect: connection refused
2023/10/07 20:33:21 ERROR Get "http://localhost:8080": dial tcp 127.0.0.1:8080: connect: connection refused
2023/10/07 20:33:22 ERROR Get "http://localhost:8080": dial tcp 127.0.0.1:8080: connect: connection refused
2023/10/07 20:33:22 INFO state change from=closed to=open
2023/10/07 20:33:22 ERROR Get "http://localhost:8080": dial tcp 127.0.0.1:8080: connect: connection refused
2023/10/07 20:33:25 ERROR circuit breaker is in open state
2023/10/07 20:33:25 ERROR circuit breaker is in open state
2023/10/07 20:33:27 INFO state change from=open to=half-open

'반 열림' 상태에서 요청이 연속해서 성공하면

 서킷 브레이커의 상태가 '반 열림'이고 service가 다시 정상적으로 동작하는 상태에서 연속해서 다섯 번 요청이 성공하면 서킷 브레이커의 상태가 '닫힘'으로 전환됩니다. 그리고 다시 카운터를 초기화하는 인터벌이 실행됩니다. 

2023/10/07 20:33:27 INFO state change from=open to=half-open
2023/10/07 20:33:55 INFO state change from=half-open to=closed
2023/10/07 20:34:05 INFO Successfully reset circuit breaker counter

'반 열림' 상태에서 요청이 실패하면

2023/10/07 20:40:17 INFO state change from=open to=half-open
2023/10/07 20:40:23 INFO state change from=half-open to=open
2023/10/07 20:40:23 ERROR Get "http://localhost:8080": dial tcp 127.0.0.1:8080: connect: connection refused

 '반 열림' 상태에서 요청이 실패하면 다시 '열림' 상태로 전환되고 타임머가 적용됩니다.


🙏 마치며

 분산 시스템, 마이크로 서비스 등에서 활용되는 서킷 브레이커를 직접 구현해 보고 테스트를 통해 실사용해 보았습니다. 서킷 브레이커를 사용하면 문제가 발생한 서비스로의 요청을 차단함으로써 정상적으로 동작하는 서비스들의 안정성이 유지된다는 점을 직접 체감해볼 수 있는 유익한 시간이었습니다.

 

 제가 작성한 코드는 기본적인 상태만 표현하는 간단한 구현이므로 여러가지 고려 사항들을 따져서 견고하게 작성된 패키지들의 링크를 참고자료에 추가해 놓았으니 함께 보시면 좋을 것 같습니다. 


📖 참고자료

 

회로 차단기 패턴 - Azure Architecture Center

원격 서비스 또는 리소스에 연결할 때 해결하는 데 걸리는 시간이 유동적인 오류를 처리합니다.

learn.microsoft.com

 

GitHub - mercari/go-circuitbreaker: A context aware circuit breaker library in Go.

A context aware circuit breaker library in Go. Contribute to mercari/go-circuitbreaker development by creating an account on GitHub.

github.com

 

GitHub - sony/gobreaker: Circuit Breaker implemented in Go

Circuit Breaker implemented in Go. Contribute to sony/gobreaker development by creating an account on GitHub.

github.com

글에서 수정이 필요한 부분이나 설명이 부족한 부분이 있다면 댓글로 남겨주세요!
최근에 올라온 글
최근에 달린 댓글
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Total
Today
Yesterday
글 보관함