서킷 브레이커 패턴: 분산 시스템의 연쇄 장애를 막는 방패

Description:
분산 시스템의 장애 전파를 막고 서비스의 안정성을 유지하는 핵심 패턴인 서킷 브레이커를 마스터합니다.
1. 개념 소개

현대 소프트웨어 아키텍처는 마이크로서비스와 클라우드 환경으로 빠르게 전환되면서, 여러 서비스가 서로 의존하며 복잡하게 얽혀 있는 분산 시스템이 보편화되었습니다. 이러한 환경에서 가장 큰 도전 과제 중 하나는 바로 '안정성'과 '회복성'입니다. 특정 서비스의 작은 장애가 시스템 전체로 확산되어 대규모 서비스 중단을 초래하는 '연쇄 장애(Cascading Failure)'는 개발자와 사용자 모두에게 악몽과 같습니다. 이러한 연쇄 장애를 효과적으로 방지하고 시스템의 견고함을 유지하기 위해 탄생한 디자인 패턴이 바로 서킷 브레이커(Circuit Breaker) 패턴입니다.
정의
서킷 브레이커 패턴은 전기 회로의 차단기에서 영감을 얻은 디자인 패턴입니다. 전기 회로 차단기가 과부하나 단락 발생 시 자동으로 전원을 차단하여 시스템 손상을 막듯이, 서킷 브레이커는 특정 서비스 호출의 실패율이 임계치를 넘으면 해당 서비스로의 모든 호출을 일시적으로 차단합니다. 이는 실패한 서비스에 더 이상의 부하를 주지 않고 복구할 시간을 제공하며, 동시에 이 서비스를 호출하는 상위 서비스들이 실패한 서비스 때문에 함께 다운되는 것을 방지합니다.
탄생 배경
모놀리식(Monolithic) 아키텍처에서는 애플리케이션 내부에서 직접 호출이 이루어지므로, 한 모듈의 장애가 전체 애플리케이션에 영향을 미칠 수는 있어도, 외부 의존성으로 인한 연쇄 장애는 상대적으로 적었습니다. 그러나 마이크로서비스 아키텍처가 확산되면서, 수많은 서비스가 네트워크를 통해 서로 통신하게 되었습니다. 이때 한 서비스가 느려지거나 응답하지 않으면, 이를 호출하는 다른 서비스들도 대기 상태에 빠지고, 결국 스레드 풀이 고갈되거나 리소스가 부족해져 줄줄이 장애가 발생하는 상황이 빈번해졌습니다.
이러한 문제에 직면한 Netflix는 자체적으로 Hystrix라는 라이브러리를 개발하여 서킷 브레이커 패턴을 널리 알렸습니다. Hystrix는 서비스 호출을 격리하고, 실패율을 모니터링하며, 임계치를 넘으면 자동으로 호출을 차단하는 기능을 제공하여 분산 시스템의 회복성을 크게 향상시켰습니다. 비록 Hystrix는 현재 유지보수가 중단되었지만, 그 핵심 개념과 철학은 Resilience4j (Java), Polly (.NET), Tenacity (Python) 등 다양한 언어와 프레임워크에서 계승되어 활발히 사용되고 있습니다.
왜 중요한가?
서킷 브레이커 패턴은 분산 시스템을 구축하는 개발자에게 다음과 같은 이유로 매우 중요합니다.
- 안정성(Stability) 증대: 가장 핵심적인 역할은 연쇄 장애를 방지하는 것입니다. 특정 서비스의 장애가 다른 서비스로 전파되는 것을 막아 시스템 전체의 안정성을 유지합니다.
- 회복성(Resilience) 향상: 실패한 서비스에 더 이상 요청을 보내지 않음으로써, 해당 서비스가 불필요한 부하 없이 안정적으로 복구될 수 있는 시간을 벌어줍니다.
- 사용자 경험(User Experience) 유지: 전체 서비스가 마비되는 대신, 일부 기능만 일시적으로 제한되거나 대체 기능을 제공함으로써 사용자에게 최소한의 서비스라도 지속적으로 제공할 수 있게 합니다.
- 자원 효율성: 실패할 것이 명백한 요청을 계속해서 보내는 것은 네트워크 대역폭, CPU, 메모리 등 귀중한 시스템 자원을 낭비하는 행위입니다. 서킷 브레이커는 이러한 낭비를 줄여줍니다.
- 신속한 피드백: 장애 발생 시 즉시 감지하고, 차단함으로써 개발자에게 문제 상황을 빠르게 인지시키고 대응할 수 있는 기회를 제공합니다.
결론적으로, 서킷 브레이커 패턴은 분산 시스템의 복잡성 속에서 시스템의 견고함과 지속적인 서비스 제공 능력을 확보하기 위한 필수적인 방어 메커니즘이라 할 수 있습니다.
2. 핵심 원리 설명 (비유와 다이어그램 활용)
서킷 브레이커 패턴은 간단한 상태 머신(State Machine)으로 동작합니다. 대부분의 서킷 브레이커는 세 가지 주요 상태를 가집니다: 닫힘(Closed), 열림(Open), 반쯤 열림(Half-Open).
상태 머신 비유: 고장 난 수도꼭지
서킷 브레이커의 동작 원리를 이해하기 위해 고장 난 수도꼭지 비유를 들어보겠습니다.
-
닫힘 (Closed) 상태 - "수도꼭지 정상 작동":
- 평소에는 수도꼭지가 잘 작동하여 물이 잘 나옵니다. 모든 요청은 정상적으로 수도관(서비스)으로 흘러갑니다.
- 하지만 만약 수도꼭지를 틀 때마다 물이 안 나오고 삐걱거리는 횟수가 특정 횟수(예: 5번)를 넘어가면, '이 수도꼭지는 고장 났구나!'라고 판단하고 다음 상태로 넘어갑니다.
-
열림 (Open) 상태 - "수도꼭지를 잠그다":
- 수도꼭지가 고장 났다고 판단하면, 더 이상 수도꼭지를 틀지 않고 완전히 잠가버립니다. 이 상태에서는 어떠한 요청도 수도관(서비스)으로 전달되지 않고, 즉시 '물 안 나옴'이라는 응답(에러 또는 대체 응답)을 반환합니다.
- 수도꼭지를 잠근 채로 일정 시간(예: 1분) 기다립니다. 이 시간은 수도꼭지가 저절로 고쳐지기를 기다리는 시간입니다.
-
반쯤 열림 (Half-Open) 상태 - "수도꼭지를 살짝 열어보다":
- 일정 시간이 지나면, '혹시 수도꼭지가 고쳐졌을까?' 싶어서 수도꼭지를 살짝만 열어봅니다. 이 상태에서는 제한된 수의 요청(예: 1개)만 수도관(서비스)으로 전달됩니다.
- 만약 이 시험 요청에서 물이 잘 나오면, '수도꼭지가 고쳐졌다!'라고 판단하고 다시 닫힘(Closed) 상태로 돌아가 완전히 열어줍니다.
- 하지만 시험 요청에서도 물이 나오지 않으면, '아직 고장 났네!'라고 판단하고 다시 열림(Open) 상태로 돌아가 수도꼭지를 다시 잠급니다.
이러한 상태 전환 과정을 통해 서킷 브레이커는 서비스의 안정성을 관리합니다.
다이어그램: 서킷 브레이커 상태 전환
graph TD
A[CLOSED] -->|Failure Rate > Threshold| B(OPEN)
B -->|Timeout Elapsed| C(HALF-OPEN)
C -->|Success| A
C -->|Failure| B
-
CLOSED (닫힘):
- 정상 작동 상태. 모든 요청이 대상 서비스로 전달됩니다.
- 내부적으로 성공 및 실패 횟수를 기록합니다.
- 일정 기간 동안의 실패율(예: 1분 동안 10회 요청 중 5회 이상 실패)이 미리 정의된 임계치(예: 50%)를 초과하면, OPEN 상태로 전환됩니다.
- 성공적인 호출이 지속되면 실패 카운터는 초기화됩니다.
-
OPEN (열림):
- 차단 상태. 대상 서비스로의 모든 요청을 즉시 차단하고, 미리 정의된 대체 로직(fallback)을 실행하거나 오류를 반환합니다. 이는 실패한 서비스에 추가적인 부하를 주지 않고 복구할 시간을 제공합니다.
- 이 상태로 진입한 후 일정 시간(예: 30초) 동안 대기합니다. 이 시간을 **재시도 대기 시간(reset timeout)**이라고 합니다.
- 재시도 대기 시간이 경과하면, HALF-OPEN 상태로 전환됩니다.
-
HALF-OPEN (반쯤 열림):
- 시험 상태. 제한된 수의 요청(예: 1개 또는 소수)만 대상 서비스로 전달하여 서비스가 복구되었는지 확인합니다.
- 이 시험 요청이 성공하면, 서비스가 복구되었다고 판단하고 서킷 브레이커는 다시 CLOSED 상태로 전환됩니다.
- 만약 시험 요청이 실패하면, 서비스가 아직 복구되지 않았다고 판단하고 서킷 브레이커는 다시 OPEN 상태로 돌아갑니다.
이러한 메커니즘을 통해 서킷 브레이커는 서비스 장애 시 즉각적인 대응과 함께, 서비스 복구 후에는 자동으로 정상 상태로 돌아오는 유연성을 제공합니다.
3. 코드 예제 2개 (Python)
여기서는 Python으로 서킷 브레이커의 기본적인 동작 원리를 구현한 예제를 보여드리겠습니다.
예제 1: 기본적인 Circuit Breaker 클래스 구현
이 예제는 CircuitBreaker 클래스를 정의하여 세 가지 상태와 상태 전환 로직을 구현합니다.
import time
import random
class CircuitBreaker:
"""
간단한 Circuit Breaker 구현 클래스
"""
# 서킷 브레이커 상태 정의
CLOSED = "CLOSED"
OPEN = "OPEN"
HALF_OPEN = "HALF_OPEN"
def __init__(self, failure_threshold=3, reset_timeout=5, half_open_test_calls=1):
"""
초기화
:param failure_threshold: CLOSED 상태에서 OPEN으로 전환될 실패 횟수 임계치
:param reset_timeout: OPEN 상태에서 HALF_OPEN으로 전환될 대기 시간 (초)
:param half_open_test_calls: HALF_OPEN 상태에서 시도할 테스트 호출 횟수
"""
self.state = self.CLOSED
self.failure_threshold = failure_threshold
self.reset_timeout = reset_timeout
self.half_open_test_calls = half_open_test_calls
self.failure_count = 0
self.last_failure_time = None
self.current_half_open_calls = 0 # HALF_OPEN 상태에서 시도한 호출 횟수
self.success_count_in_half_open = 0 # HALF_OPEN 상태에서 성공한 호출 횟수
print(f"Circuit Breaker 초기화: {self.state}")
def call(self, service_function, fallback_function=None):
"""
서비스 호출을 시도하고 서킷 브레이커 로직을 적용합니다.
:param service_function: 호출할 실제 서비스 함수
:param fallback_function: 서비스 실패 시 호출할 대체 함수 (선택 사항)
:return: 서비스 함수의 결과 또는 대체 함수의 결과
"""
current_time = time.time()
if self.state == self.OPEN:
# OPEN 상태: 재시도 대기 시간이 지났는지 확인
if self.last_failure_time and (current_time - self.last_failure_time > self.reset_timeout):
self._change_state(self.HALF_OPEN)
return self.call(service_function, fallback_function) # HALF_OPEN 상태로 다시 호출
else:
# 아직 대기 시간 중이면 즉시 실패 처리
print(f"[{self.state}] 서비스 호출 차단. 대기 시간 중...")
if fallback_function:
return fallback_function()
raise Exception("서비스가 OPEN 상태이며 재시도 대기 시간 중입니다.")
elif self.state == self.HALF_OPEN:
# HALF_OPEN 상태: 제한된 테스트 호출 시도
if self.current_half_open_calls < self.half_open_test_calls:
self.current_half_open_calls += 1
try:
result = service_function()
self.success_count_in_half_open += 1
print(f"[{self.state}] 테스트 호출 성공 ({self.success_count_in_half_open}/{self.half_open_test_calls})")
if self.success_count_in_half_open >= self.half_open_test_calls:
self._change_state(self.CLOSED)
return result
except Exception as e:
print(f"[{self.state}] 테스트 호출 실패: {e}")
self._change_state(self.OPEN) # 테스트 호출 실패 시 다시 OPEN
if fallback_function:
return fallback_function()
raise e
else:
# HALF_OPEN 테스트 호출 횟수를 초과하면 OPEN으로 전환 (실패로 간주)
print(f"[{self.state}] HALF_OPEN 테스트 호출 횟수 초과. 다시 OPEN으로 전환.")
self._change_state(self.OPEN)
return self.call(service_function, fallback_function) # OPEN 상태로 다시 호출
elif self.state == self.CLOSED:
# CLOSED 상태: 정상적으로 서비스 호출 시도
try:
result = service_function()
self.failure_count = 0 # 성공 시 실패 카운트 초기화
print(f"[{self.state}] 서비스 호출 성공.")
return result
except Exception as e:
self.failure_count += 1
self.last_failure_time = current_time
print(f"[{self.state}] 서비스 호출 실패 ({self.failure_count}/{self.failure_threshold}): {e}")
if self.failure_count >= self.failure_threshold:
self._change_state(self.OPEN)
if fallback_function:
return fallback_function()
raise e
def _change_state(self, new_state):
"""
서킷 브레이커 상태 변경 및 관련 카운터 초기화
"""
print(f"--- Circuit Breaker 상태 변경: {self.state} -> {new_state} ---")
self.state = new_state
if new_state == self.CLOSED:
self.failure_count = 0
self.last_failure_time = None
self.current_half_open_calls = 0
self.success_count_in_half_open = 0
elif new_state == self.OPEN:
self.last_failure_time = time.time()
self.current_half_open_calls = 0
self.success_count_in_half_open = 0
elif new_state == self.HALF_OPEN:
self.current_half_open_calls = 0
self.success_count_in_half_open = 0
# --- 서비스 시뮬레이션 ---
def unreliable_service():
"""
50% 확률로 성공 또는 실패하는 서비스
"""
if random.random() < 0.5: # 50% 확률로 실패
raise Exception("서비스 내부 오류 발생!")
return "서비스 데이터"
def fallback_data():
"""
서비스 실패 시 제공할 대체 데이터
"""
return "캐시된 데이터 또는 기본값"
# --- 테스트 시나리오 ---
if __name__ == "__main__":
cb = CircuitBreaker(failure_threshold=3, reset_timeout=5, half_open_test_calls=1)
print("\n--- 1. CLOSED 상태에서 서비스 호출 (성공/실패 반복) ---")
for i in range(7):
try:
result = cb.call(unreliable_service, fallback_data)
print(f"결과: {result}")
except Exception as e:
print(f"예외 발생: {e}")
time.sleep(0.5) # 0.5초 대기
print("\n--- 2. OPEN 상태 진입 후 대기 ---")
# OPEN 상태에서는 바로 fallback_data가 반환되거나 예외 발생
for i in range(3):
try:
result = cb.call(unreliable_service, fallback_data)
print(f"결과: {result}")
except Exception as e:
print(f"예외 발생: {e}")
time.sleep(0.5)
print("\n--- 3. reset_timeout 경과 후 HALF_OPEN 상태 진입 ---")
time.sleep(cb.reset_timeout + 1) # reset_timeout보다 길게 대기
try:
result = cb.call(unreliable_service, fallback_data)
print(f"결과: {result}")
except Exception as e:
print(f"예외 발생: {e}")
print("\n--- 4. HALF_OPEN 상태에서 서비스 복구 시도 (이번엔 성공하도록 조작) ---")
def reliable_service():
return "서비스 복구 완료!"
try:
result = cb.call(reliable_service, fallback_data) # HALF_OPEN -> CLOSED로 전환 기대
print(f"결과: {result}")
except Exception as e:
print(f"예외 발생: {e}")
print("\n--- 5. CLOSED 상태로 돌아온 후 정상 작동 ---")
for i in range(3):
try:
result = cb.call(unreliable_service, fallback_data) # 다시 50% 확률로 성공/실패
print(f"결과: {result}")
except Exception as e:
print(f"예외 발생: {e}")
time.sleep(0.5)
예제 2: Decorator를 활용한 Circuit Breaker
데코레이터를 사용하면 기존 함수 코드를 수정하지 않고도 서킷 브레이커 기능을 쉽게 적용할 수 있습니다. 이는 실제 라이브러리들이 제공하는 방식과 유사합니다.
import time
import random
from functools import wraps
class CircuitBreakerDecorator:
"""
Circuit Breaker 데코레이터 구현을 위한 클래스
"""
CLOSED = "CLOSED"
OPEN = "OPEN"
HALF_OPEN = "HALF_OPEN"
def __init__(self, failure_threshold=3, reset_timeout=5, half_open_test_calls=1):
self.state = self.CLOSED
self.failure_threshold = failure_threshold
self.reset_timeout = reset_timeout
self.half_open_test_calls = half_open_test_calls
self.failure_count = 0
self.last_failure_time = None
self.current_half_open_calls = 0
self.success_count_in_half_open = 0
self.name = "UnknownService" # 데코레이터 적용 시 서비스 이름을 알 수 있도록
def __call__(self, func):
"""
데코레이터로 사용될 때 호출되는 메서드
"""
self.name = func.__name__ # 데코레이터가 적용된 함수의 이름을 가져옴
@wraps(func)
def wrapper(*args, **kwargs):
current_time = time.time()
if self.state == self.OPEN:
if self.last_failure_time and (current_time - self.last_failure_time > self.reset_timeout):
self._change_state(self.HALF_OPEN)
# HALF-OPEN 상태로 전환 후 바로 재시도
print(f"[{self.name}][{self.OPEN} -> {self.HALF_OPEN}] 재시도 시간 경과, 테스트 호출 시작.")
return wrapper(*args, **kwargs)
else:
print(f"[{self.name}][{self.OPEN}] 서비스 호출 차단. 대기 시간 중...")
# Fallback 로직이 있다면 여기서 호출할 수 있음.
# 여기서는 간단히 예외 발생으로 처리
raise Exception(f"[{self.name}] 서비스가 OPEN 상태이며 재시도 대기 시간 중입니다.")
elif self.state == self.HALF_OPEN:
if self.current_half_open_calls < self.half_open_test_calls:
self.current_half_open_calls += 1
try:
result = func(*args, **kwargs)
self.success_count_in_half_open += 1
print(f"[{self.name}][{self.HALF_OPEN}] 테스트 호출 성공 ({self.success_count_in_half_open}/{self.half_open_test_calls})")
if self.success_count_in_half_open >= self.half_open_test_calls:
self._change_state(self.CLOSED)
return result
except Exception as e:
print(f"[{self.name}][{self.HALF_OPEN}] 테스트 호출 실패: {e}")
self._change_state(self.OPEN)
raise e
else:
# HALF_OPEN 상태에서 지정된 테스트 호출 횟수 이상 실패하면 다시 OPEN으로
print(f"[{self.name}][{self.HALF_OPEN}] 테스트 호출 횟수 초과. 다시 OPEN으로 전환.")
self._change_state(self.OPEN)
raise Exception(f"[{self.name}] HALF_OPEN 테스트 호출 실패로 OPEN 전환.")
elif self.state == self.CLOSED:
try:
result = func(*args, **kwargs)
self.failure_count = 0 # 성공 시 실패 카운트 초기화
print(f"[{self.name}][{self.CLOSED}] 서비스 호출 성공.")
return result
except Exception as e:
self.failure_count += 1
self.last_failure_time = current_time
print(f"[{self.name}][{self.CLOSED}] 서비스 호출 실패 ({self.failure_count}/{self.failure_threshold}): {e}")
if self.failure_count >= self.failure_threshold:
self._change_state(self.OPEN)
raise e
return wrapper
def _change_state(self, new_state):
print(f"--- [{self.name}] Circuit Breaker 상태 변경: {self.state} -> {new_state} ---")
self.state = new_state
if new_state == self.CLOSED:
self.failure_count = 0
self.last_failure_time = None
self.current_half_open_calls = 0
self.success_count_in_half_open = 0
elif new_state == self.OPEN:
self.last_failure_time = time.time()
self.current_half_open_calls = 0
self.success_count_in_half_open = 0
elif new_state == self.HALF_OPEN:
self.current_half_open_calls = 0
self.success_count_in_half_open = 0
# 데코레이터 인스턴스 생성
external_service_breaker = CircuitBreakerDecorator(failure_threshold=3, reset_timeout=5)
@external_service_breaker
def call_external_api():
"""외부 API 호출을 시뮬레이션하는 함수"""
if random.random() < 0.6: # 60% 확률로 실패
raise Exception("외부 API 호출 실패!")
return "외부 API 응답 데이터"
def get_cached_data():
"""대체 로직: 캐시된 데이터를 반환"""
return "캐시된 응답"
if __name__ == "__main__":
print("\n--- 데코레이터 적용 예제 ---")
for i in range(10):
try:
result = call_external_api()
print(f"외부 API 호출 결과: {result}")
except Exception as e:
print(f"외부 API 호출 예외: {e}")
# 여기서 fallback 로직을 구현할 수 있습니다.
# 예: print(f"대체 데이터 사용: {get_cached_data()}")
time.sleep(0.5)
print("\n--- 5초 대기 후 HALF_OPEN 상태 진입 ---")
time.sleep(external_service_breaker.reset_timeout + 1)
for i in range(3):
try:
result = call_external_api()
print(f"외부 API 호출 결과: {result}")
except Exception as e:
print(f"외부 API 호출 예외: {e}")
time.sleep(0.5)
print("\n--- 서비스 복구 후 (임의로 성공률 높여서) ---")
# 이때는 random.random() < 0.6이 아닌, 더 높은 성공률이 나오도록 환경을 조작해야
# CLOSED 상태로 돌아가는 것을 관찰할 수 있습니다.
# 실제 환경에서는 서비스 자체가 복구되어 성공률이 높아질 것입니다.
# 여기서는 데모를 위해 임의로 성공하도록 조작할 수는 없지만,
# HALF_OPEN 상태에서 성공하면 CLOSED로 돌아감을 기대합니다.
# 만약 계속 실패한다면 다시 OPEN 상태로 전환됩니다.
이 데코레이터 예제에서는 fallback 함수를 직접 데코레이터에 전달하는 대신, 호출하는 쪽에서 try-except 블록을 사용하여 예외 발생 시 fallback 로직을 처리하도록 했습니다. 이는 더 유연한 접근 방식이 될 수 있습니다.
4. 실무 적용 사례
서킷 브레이커 패턴은 분산 시스템의 거의 모든 상호작용 지점에서 적용될 수 있습니다.
- 마이크로서비스 간 통신: 가장 흔한 적용 사례입니다. 서비스 A가 서비스 B의 API를 호출할 때, 서비스 B가 과부하로 느려지거나 응답하지 않으면 서비스 A에 서킷 브레이커를 적용하여
