Tech

내부통신에 서킷브레이커(Circuit Breaker) 적용하기

2024. 08. 29

 

 

 

안녕하세요! 공통 도메인 파트 백엔드 개발자 지찬규입니다!

 

회사가 성장할수록 신규 서비스는 늘어나고, 비대해진 서비스는 MSA로 분리하게 되죠. 그리고 서비스는 분리된 구조에서 데이터를 송신하기 위해 내부통신(Internal api call)을 기반으로 메세지를 주고 받습니다.

 

이 때, 내부통신으로 서비스 간 메세지를 주고 받으며 별다른 조치를 취하지 않았다면 A서비스 장애가 다른 서비스 장애로 전파되는 상황이 발생합니다. 서비스 규모와 API 특성에 따라 장애 전파는 매우 크리티컬하게 다가올 수 있어, 오늘은 관련한 케이스를 나눠보려고 합니다. 그림을 통해 예시를 한번 살펴볼까요?

 

 

 

 

 

 

 

User Service는 분리된 Order Service에서  내 주문 정보 를 가져온다고 가정합시다. 이 상황에서 Order Service에 장애가 발생하면 User Service로 장애가 전파되죠. Order Service에서 메세지를 받지 못 했다는 이유 하나만으로 User Service가 올바른 응답을 반환하지 못 해 사용자는 서비스 장애를 경험합니다.

 

서비스 장애는 곧 제품 신뢰도를 깍는 행위이기에 위와 같은 상황을 미리 대비해야 합니다. 이를 위한 디자인 패턴이 바로 서킷브레이커(Circuit Breaker) 패턴입니다. Chat-GPT 는 서킷브레이커 패턴을 아래와 같이 정의합니다.

 

 

Chat-GPT 가 말한 서킷브레이커 패턴

 

 

위에서 말한 상태전이를 수식도로 보면 다음과 같습니다.

 

서킷브레이커 패턴의 상태전이 수식도 (출처: https://martinfowler.com/bliki/CircuitBreaker.html)

 

 

서킷브레이커 패턴이 가진 핵심은 시스템 안정성 향상입니다. 분리된 서비스 간 통신에서 발생 가능한 장애 상황을 관리하고, 다른 서비스 장애가 시스템 전체로 확산되지 못하도록 막습니다. 서킷브레이커 패턴을 적용할 때 모습을 그리면 다음과 같죠.

 

 

서킷브레이커 CLOSED (닫힘)

 

서킷브레이커 OPEN (열림)

 

 

서킷브레이커는 회로차단기를 의미하는데, 말그대로 메세지 통신 진입을 차단합니다. Order Service가 정상 동작할 때는 회로를 연결하여 통신을 주고 받죠. 반면 Order Service가 장애 상황일 때 User ServiceOrder Service로 요청을 보내지 않습니다. 서킷브레이커가 OPEN  상태가 되면 장애가 발생한 서버를 더이상 호출하지 않죠. 장애가 발생한 서버를 무의미하게 호출해봤자 응답지연을 야기하고, 리소스 낭비일 뿐입니다.

 

이걸로 충분할까요? 아닙니다. 단순히 장애가 발생한 Order Service를 호출하지 않는 걸로 그친다면 User Service는 500에러가 발생하겠죠. 서킷브레이커 패턴이 그리는 최종 모습은 fallback 을 적용하여 Client가 장애를 경험하지 않게 만드는 겁니다.

 

💡fallback(폴백) 메커니즘은 
서비스 호출이 실패하거나 차단될 때 대체 동작을 제공하는 방식을 뜻합니다. 
연결된 특정 서비스에 보낸 요청이 실패하더라도 부분적으로나마 기능을 유지하도록 돕습니다. 
시스템 가용성과 사용자 경험 향상을 목적으로 가집니다.

 

 

 

서킷브레이커 OPEN(열림), fallback 동작

 

 

이 그림이 서킷브레이커 패턴에 fallback까지 적용된 모습입니다. Order Service 장애로 서킷브레이커가 OPEN 되면 예외 처리하는 로직을 추가합니다. 빈 데이터나 더미 데이터 등 서비스 정책에 따라 적절한 fallback 로직을 구현하면 됩니다.

 

서킷브레이커 패턴을 도입하면 장애 관리와 더불어 서비스 품질이 높아집니다. 화해 내 벡엔드 표준화를 담당하는 저희 파트는 작년에 서킷브레이커 패턴을 도입하기로 결정했습니다. 그 과정에서 저희가 경험하고 고민했던 내용을 공유하고 서킷브레이커 동작 사례를 소개해드리고자 합니다.

 

 

 

 

1. 화해 서비스에 맞는 서킷브레이커를 적용해보자


 

서킷브레이커를 도입할 때 여러 가지로 고려할 게 많았습니다. 그 중에서도 고민이 많았던 항목을 하나씩 이야기해보겠습니다.

 

 

1-1. 바퀴를 재발명(Reinventing the wheel)할 것인가?

 

서킷브레이커 패턴을 도입할 때 제일 먼저 한 일은 오픈소스 탐색입니다. 검증된 오픈소스가 하는지 찾아봤죠. 구글링하면 가장 자료가 많은 게 Hystrix, resilience4j 입니다. 안타깝게도 화해는 python을 기반으로 백엔드 애플리케이션을 개발하다보니 자바 베이스 오픈소스는 사용할 수 없습니다. python 오픈소스는 star가 적어 선뜻 도입을 결정하기 힘들었습니다.

 

네… 어쩔 수 있나요? 바퀴를 재발명(Reinventing the wheel)하게 되었습니다.

 

바퀴를 재발명하는 행위 자체가 리소스 낭비라 바라볼 수 있지만, 화해에 어울리는 서킷브레이커 패턴을 잘 만들수 있다는 확신을 바탕으로 진행했습니다. 기본적인 개념과 용어는 마틴 파울러의 Circuit Breaker Patternresilience4j docs를 참고했습니다.

 

 

1-2. Count Based Sliding Window VS Time Based Sliding Window

 

서킷브레이커 패턴에서 장애감지를 위해 슬라이딩 윈도우(Sliding Window) 알고리즘을 사용합니다. 슬라이딩 윈도우 알고리즘를 통해 구해진 FailureRate를 서킷브레이커 장애감지 임계치(threshold)로 사용합니다.

 

슬라이딩 윈도우는 시간 또는 호출 횟수 기반으로 서비스 상태를 추적하며, 수치를 조절하여 보다 정교한 회로 차단 및 재시도 전략을 구현하는게 가능합니다.

 

 

 


화해 내부통신 특성상 실시간 트래픽을 관찰하여 상태를 판단하는 모습이 적합하다고 판단하여 Time-Based Sliding Window로 구현하기로 정했습니다. 추후에 필요하다면 Count-Based Sliding Window도 지원할 계획입니다.

 

 

Time Based Sliding Window.

 

 

💡failure_rate = int((self.failure_count / self.total_count) * 100)

 

저희팀이 서킷브레이커를 개발할때 중점적으로 생각했던건 빠른 장애감지와 대응이기 때문에 Time-Based Sliding Window의 단점 중 하나인 장애감지 딜레이를 해결하고자 오프셋 내에서 최소 호출수 + failure rate 임계치를 넘는 경우 서킷브레이커를 OPEN 상태로 변경하도록 했습니다.

 

이 방식은 장애감지를 빠르게 할 수 있는 대신에 실패 카운트가 스파이크(spike) 치는경우 서킷브레이커가 민감하게 작동할수 있는 가능성이 있습니다. 이 문제는 최소 호출수( minimum_number_of_calls)라는 조건과 아래에서 추가로 설명이 될 OPEN delay를 지수 백오프( Exponential Backoff)로 동작할 수 있게 하여 보완했습니다.

 

서킷브레이커의 민감한 작동은 일반적인 상황에서는 offset을 기다리고 특정 상황에서만 빠르게 서킷브레이커를 OPEN 할 수 있는 하이브리드 방식으로 개선 할 수 있을 것 같습니다. 아직까지 장애상황이 아닌경우에 서킷브레이커가 민감하게 작동한 경우는 없었습니다😃

 

 

1-3. 서킷브레이커의 상태 저장소

 

서킷브레이커의 장애감지를 위해서는 실패, 성공 카운트, 현재 상태 등을 관리할 수 있어야 합니다. 처음 서킷브레이커의 상태 저장을 고려할때는 단순하게 로컬 메모리에 저장하는걸 생각했습니다. 내부통신에서 사용하려는 목적이다보니 최대한 아키텍처를 간단하게 가져가고 싶었습니다.

 

프로세스 별 로컬 메모리, 인스턴스(EC2)별 로컬 메모리 두가지 방식을 생각했으나 큰 문제점이 있었습니다. 사용성은 간단하지만 두가지 방법 모두 프로세스, 인스턴스 별 상태가 다르게 저장될 수 있습니다. 즉, 서킷브레이커 상태가 뒤죽박죽이 될 수 있고 일관되지 않은 동작이 발생할 수 있다는 말입니다. 장애를 해결하려 했지만 오히려 더 상황이 복잡하게 될 수 있는 문제라 더 나은 방법이 필요했습니다.

 

일관된 서킷브레이커 상태를 관리하기 위해서는 별도의 데이터베이스가 필요하다고 판단했습니다. Redis, NoSQL을 후보로 고민하게 되었습니다.

 

  • Redis
    • 비교적 간단하게 사용할 수 있는 데이터베이스입니다.
    • Redis 한 대로 중앙집중 형태로 서킷브레이커 상태를 관리하면 모든 서비스가 일관된 장애감지를 할 수 있습니다.
    • 단일 Redis 서버는 장애 지점이 될 수 있지만, 장애가 자주 발생하지 않기도 하고 HA(High Availability)를 통해 장애를 대응할 수 있습니다.

 

  • NoSQL
    • 단순히 서킷브레이커의 상태만 저장하는게 아니라 request, response 로깅으로 좀 더 다양한 정보를 저장하여 장애분석에 도움이 되는걸 기대했습니다.
    • 물론 Redis도 다양한 정보를 저장할 수 있지만, 데이터가 대량으로 쌓이고 관리되는 형태로 보았을때 Redis보다는 NoSQL이 안정적으로 유지보수를 할 수 있을거라 생각했습니다.

 

고민 끝에 Redis를 서킷브레이커 상태 저장소로 정했습니다. 내부통신에서 서킷브레이커를 사용하는 목적에 비해 NoSQL에서 사용하려는 방식은 오버스펙이라고 판단했습니다.

 

다만 Redis를 사용하면 Race Condition을 해결해야만 합니다. Failure, Success Count 증가시 애플리케이션 로직에서 단순히 GET, SET을 하게 되면 Race Condition이 발생합니다. 서로다른 프로세스가 동시에 GET으로 데이터를 가져온뒤 +1하고 SET을하면 +2가 되는게 아니라 +1만 되는 상황을 맞이할 수 있습니다.

 

💡레이스 컨디션(Race Condition)은 두 개 이상의 프로세스 또는 스레드가 공유 자원에 
접근하여 변경할 때 접근 순서에 따라 예기치 못한 결과가 발생하는 상황을 의미합니다. 
주로 병렬 또는 동시 실행 환경에서 발생하며, 프로그램의 정확성과 일관성을 해칠 수 있습니다.

 

다행히 Reids는 Atomic하게 동작하는 INCR 명령어가 존재합니다. 즉, 동시에 여러 클라이언트가 동시에 호출하더라도 순차적으로 실행되어 적절하게 카운트 증가를 처리할 수 있습니다.

 

💡"Atomic하게 데이터를 처리한다"는 의미는 
데이터 작업이 "원자성(atomicity)"을 가진다는 것을 뜻합니다. 
원자성은 데이터베이스 트랜잭션의 ACID 속성 중 하나로 작업이 불가분의 단위로 수행하는 걸 
보장합니다. 즉, 원자적인 작업은 모두 성공하거나 모두 실패해야 합니다. 
중간 상태가 허용되지 않으며, 원자성을 통해 데이터 일관성과 무결성을 보장합니다.

 

 

1-4. Half-Open → Closed로 상태가 바뀌는 조건

 

서킷브레이커가 OPEN 되고 일정시간이 흐른 뒤 장애가 해결되었는지 검증하는 HALF OPEN  단계가 존재합니다.  HALF OPEN에서  CLOSED로 상태를 변경하는 조건을 어떻게 지정할지 고심했습니다.

 

  1. HALF OPEN offset 시간동안 랜덤으로 API를 호출하여 Failure Rate 구합니다.
    1. 최소 호출수 + Failure Rate 임계치보다 낮으면 CLOSED
    2. 최소 호출수 + Failure Rate 임계치보다 높으면 다시 OPEN.
  2. HALF OPEN offset 시간동안 한번이라도 성공하면 CLOSED , 최소 호출수 + Failure Rate 임계치보다 높으면 다시 OPEN

 

첫 번째 방법은 랜덤으로 API를 호출하는 동안 서비스가 다시 정상적으로 돌아왔음에도 바로 CLOSED상태로 변경할 수 없습니다. 즉, 그 시간동안 유저가 잘못된 정보를 보거나 장애(fallback이 없는경우)를 겪습니다. 저희는 HALF OPEN 상태에서 한 번이라도 내부 통신이 성공하면 장애 복구로 판단하여 빠르게 CLOSED 상태로 바꾸는 게 저희 서비스에 더 유리하다고 생각하여 두번째 방식을 채택했습니다.

 

 

 

 

2. 서킷브레이커 구현체


 

버드뷰는 내부통신을 위한 별도 공통 모듈을 사용합니다. 서킷브레이커 설정은 내부 클라이언트에 선언하는데, per_host  ,per_method  두 가지 방식이 존재합니다.

  • per_host: host 기준으로 서킷브레이커 적용
  • per_method: 호출하는 API 엔드포인트(endpoint) 기준으로 서킷브레이커 적용

per_host, per_method 둘 다 설정하는 경우 per_host로 동작합니다.

 

<code>

class OrderServiceProxy:
    def __init__(self):
        self._api_client = InternalApiClient(
            ...
            # per_host로 서킷브레이커를 적용할때
            circuit_breaker_config_per_host=CircuitBreakerConfig(
                name=RegistryKey.ORDER_SERVICE_INTERNAL.value,
                threshold_failure_rate=80,
                half_open_threshold_failure_rate=30,
                minimum_number_of_calls=900,
                failure_check_time_offset=300,
                exceptions=(Exception,),
                exception_status_code=[501, 502, ..., 511],
                delay_config=DelayConfig(
                    type=DelayType.EXPONENTIAL_BACKOFF, max_delay=180, base=5
                ),
            ),
        )


    def get_my_order_infos(self) -> InternalApiResponse:
        response: InternalApiResponse = self._api_client.get(
            path="/my-order-infos",
            ...
            # per_method로 서킷브레이커를 적용할때
            circuit_breaker_config_per_method=CircuitBreakerConfig(
                name=RegistryKey.ORDER_SERVICE_INTERNAL.value,
                threshold_failure_rate=80,
                half_open_threshold_failure_rate=30,
                minimum_number_of_calls=300,
                failure_check_time_offset=300,
                exceptions=(Exception,),
                exception_status_code=[501, 502, ..., 511],
                delay_config=DelayConfig(
                    type=DelayType.EXPONENTIAL_BACKOFF, max_delay=180, base=5
                ),
            ),
        )

 

위 예시는 다음과 같은 조건을 가집니다.

  • 실패율 임계치 80%
  • 최소 호출수 900회
  • HALF OPEN상태의 실패율 임계치 30%
  • 실패 체크를 위한 시간 기반 슬라이딩 윈도우 사이즈는 300초
  • 실패로 핸들링할 Exception 종류 (default: global Exception)
  • 실패로 핸들링할 HTTP 응답 상태 코드 (default: 501 ~ 511)
  • 서킷브레이커 OPEN 시간은 base값이 5인 지수 백오프로 동작하고 최대 180초

 

💡최소 호출수 (minimum_number_of_calls): 
실패율을 판단하기에 필요한 최소 호출수를 의미합니다. 
정해진 시간안에 최소 호출수를 만족하지 못하면 서킷브레이커 상태를 판단하지 않습니다. 
서비스 특성과 트래픽에 따라 오작동할 수 있는 경우를 막기 위해서 필요한 속성입니다.

 

💡HALF-OPEN 실패율 임계치 (half_open_threshold_failure_rate):
OPEN 상태의 failure rate 임계치와 HALF-OPEN 상태에서의 failure rate 임계치를 
다르게 가져가고 싶은 경우를 위해 정의하는 속성입니다. 
저희는 HALF-OPEN에서 좀 더 민감하게 실패율을 판단하기 위해 OPEN 상태의 failure rate 보다
더 낮게 정의했습니다.

 

 

기본적인 서킷브레이커 내 상태전이는 글 초입부에 설명드린 상태전이 수식도를 기반으로 작성되었습니다.  CLOSED 상태를 핸들링하는 코드부터 순서대로 살펴보겠습니다!

 

 

def handle_closed_state(self, registry: Registry, *args, **kwargs) -> Response:
    try:
        response: Response = self.func(*args, **kwargs)
        self.raise_for_status(response.status_code)
        self.increase_success_count(registry)
        return response

    except self.exceptions_to_catch as e:
        self.increase_failure_count(registry)
        raise CircuitBreakerException(name=self.name, registry=registry) from e

    finally:
        self.handle_circuit_breaker_trigger(registry)

CLOSED 상태를 핸들링 하는 메서드

 

CLOSED 상태일 때 내부 서비스를 호출합니다. 내부통신이 성공하면 성공 카운트를, 실패하면 실패카운트를 증가시킵니다. finally문으로 호출되는 handle_circuit_breaker_trigger  메서드는 지정된 조건에 따라 서킷브레이커 상태를 OPEN 으로 변경하는 역할을 수행합니다.

 

 

def handle_circuit_breaker_trigger(self, registry: Registry) -> None:
    threshold_failure_rate = (
        self.threshold_failure_rate
        if registry.is_closed_state
        else self.half_open_threshold_failure_rate
    )
    # 최소 호출수 이상 + threshold_failure_rate 넘긴 경우에만 OPEN 상태로 변경 가능
    if self.is_over_minimum_number_of_calls(
        registry
    ) and self.can_circuit_breaker_open(registry, threshold_failure_rate):
        self.set_open_state(registry, threshold_failure_rate)

서킷브레이커 OPEN 트리거 메서드

 

최소 호출수를 만족하고 실패율 임계치를 넘기면 서킷브레이커는 OPEN 상태로 변경됩니다.

 

 

def handle_open_state(self, registry: Registry, *args, **kwargs) -> Response:
    if registry.is_open_state:
        current_timestamp = datetime.utcnow().timestamp()
        next_call_timestamp = registry.last_attempt_timestamp + self.delay

        # OPEN delay 핸들링 (OPEN 상태에서는 Remote Call을 하지 않는다)
        if next_call_timestamp >= current_timestamp:
            raise CircuitBreakerOpen(
                after_time=f"{next_call_timestamp - current_timestamp:.2f}",
                name=self.name,
                registry=registry,
            )

    # HALF OPEN 으로 상태 변경후 HALF OPEN 핸들링 시도
    self.set_half_open_state(registry)
    return self.handle_half_open_state(registry)

OPEN 상태를 핸들링 하는 메서드

 

서킷브레이커가 OPEN  되면 더이상 내부 통신을 허용하지 않고 CircuitBreakerOpen  exception 을 발생시킵니다. 일정 시간이 흐른 뒤 HALF OPEN 으로 변경합니다.

 

 

def handle_half_open_state(self, registry: Registry, *args, **kwargs) -> Response:
    try:
        response: Response = self.func(*args, **kwargs)
        self.raise_for_status(response.status_code)
        # 한번이라도 성공하면 CLOSED 상태로 전환
        self.set_closed_state(registry)
        return response

    except self.exceptions_to_catch as e:
        self.increase_failure_count(registry)
        self.handle_circuit_breaker_trigger(registry)
        raise CircuitBreakerException(name=self.name, registry=registry) from e

HALF OPEN 상태를 핸들링 하는 메서드

 

마지막으로 HALF OPEN 는 내부 서비스를 호출하고 한 번이라도 성공하면CLOSED상태로 전환합니다. 내부 서비스 호출이 계속 실패하여 임계치를 넘어가면 다시 OPEN 상태로 변경됩니다.

서킷브레이커 상태전이 로직도 확인했으니, 이제 장애 시점에 동작할 fallback을 살펴볼까요?

 

 

# try-except로 fallback 구현
def get_my_order_infos(self) -> CouponListDto:
    try:
        response: InternalApiResponse = self._api_client.get(
            "/my-order-infos",
            params={"user_id": user_id}
        )
    # OPEN 예외처리와 호출 예외처리를 별개로 두고 싶으면 아래처럼 구분합니다
    except CircuitBreakerException:
        return CouponListDto()
    except CircuitBreakerOpen:
        return CouponListDto()
    return CouponListDto(response)


  
# fallback method로 구현
def get_my_order_infos(self) -> CouponListDto:
    def _fallback() -> CouponListDto:
        return CouponListDto(response)


    # fallback 응답이 request의 응답으로 오도록 정의해둬서 타입이 두가지 형태가 됩니다.
    # fallback 함수에서 InternalApiResponse로 응답을 맞추는것도 약간 어색합니다.
    # 이 부분은 개선해야할 점이라고 생각합니다.
    response: InternalApiResponse | CouponListDto = self._api_client.get(
        "/my-order-infos",
        params={"user_id": user_id},
        circuit_breaker_fallback=_fallback
    )
    return (
        CouponListDto(response)
        if isinstance(response, InternalApiResponse)
        else response
    )

서킷브레이커 fallback 예시

 

 

서킷브레이커 fallback은 두 가지 방식으로 구현할 수 있습니다.

  1. Exception을 catch하여 예외처리
  2. fallback 함수를 구현한뒤 circuit_breaker_fallback 파라미터로 전달

 

서킷브레이커 모듈을 개발하면서 의도한 방식은 두번째 방식입니다. 첫번째 방식은 일반적인 예외처리 형태라서 좀 더 fallback을 명시적으로 사용하기 위해 두번째 방식을 사용하길 기대했습니다.

 

다만, 두번째 방식을 사용할 경우 ApiClient에서 반환하는 타입이 정상적인 응답(InternalApiResponse), fallback 응답 두 가지 타입을 가집니다. 사용자는 응답의 타입을 보고 정상적인 응답인지 fallback인지 구분해야 하기 때문에 사용성이 떨어져 추후 개선하려고 합니다.

 

서킷브레이커 패턴을 구현했으니 여기서 끝일까요? 아니죠. 모름지기 개발자라면 검증은 기본입니다. 문제 없이 동작하던 내부통신에 “서킷브레이커를 적용할 때 병목 지점은 없는지?”, “내부통신 트래픽 만큼 Redis에 요청이 몰렸을 때 이슈는 없는지?” 등 검증하고 넘어갈 게 존재하죠.

 

 

 

 

3. 부하테스트를 해보자


 

Jmeter로 부하테스트를 진행하고 리소스 지표는 Prometheus & Grafana로 확인했습니다. 검증 단계는 로컬, 개발환경 순으로 시나리오를 정하고 진행했습니다.

 

 

3-1. Redis 부하테스트

 

 

테스트를 위한 Docker 리소스를 인스턴스와 유사하게 세팅합니다. 한편 redis-benchmark로 RPS 및 시스템 상태를 살펴봤습니다.

 

 

redis-bnechmark 명령

 

👨‍💻redis-benchmark -p 6379 -d 10000 -r 10000 -n 10000 -c 50 -q -t incr, get, set

 

  • redis-benchmark 옵션에 대한 설명
    옵션 설명 기본값
    -h Redis 서버의 호스트 이름을 지정합니다. 127.0.0.1
    -p Redis 서버의 포트 번호를 지정합니다. 6379
    -s Redis 서버의 Unix 도메인 소켓 경로를 지정합니다. 없음
    -a Redis 서버의 인증 비밀번호를 지정합니다. 없음
    -c 동시에 연결할 클라이언트의 수를 지정합니다. 50
    -n 각 클라이언트가 실행할 요청의 수를 지정합니다. 100000
    -d SET/GET 값의 바이트 크기를 지정합니다. 2
    -k 읽기/쓰기 키의 존재 비율을 지정합니다. 1
    -r 랜덤 키를 사용하도록 설정합니다. 없음
    -P 요청의 파이프라인 깊이를 지정합니다. 1
    --csv 결과를 CSV 형식으로 출력합니다. 없음
    -q 요약 정보를 출력하지 않습니다. 없음
    --loop 벤치마크 테스트를 무한 반복합니다. 없음
    -t 특정 테스트를 지정합니다.

    쉼표로 구분된 명령어 리스트로 지정합니다.

    모든

    테스트

    -I Idle 모드에서 인스턴스를 테스트합니다. 없음
    -e 테스트가 중단될 때까지의 실행 시간을 설정합니다 (초 단위). 없음
    --precision 타이밍 출력의 소수점 자릿수를 설정합니다. 2

 

 

INCR, GET, SET 명령어 함께 사용

 

 

총 7번의 벤치마크를 실행했습니다. 첫번째에서 세번째 벤치마크에서 50개의 클라이언트가 10,000번 요청을 실행했을때 CPU 사용율은 6% 정도로 나왔고, 네번째 벤치마크에서 100,000번 요청을 실행했을때 33%의 사용율이 나왔습니다. 다섯번째 벤치마크부터 100개의 클라이언트로 요청했을때도 유사한 결과가 나왔습니다.

 

결론적으로 10,000번의 요청에서 대략 160,000 ~ 200,000 RPS와 CPU 사용율 6%의 성능이면 내부통신용 서킷브레이커에서 사용하기 충분하다고 결론을 내렸습니다.

 

다만 벤치마크는 벤치마크일 뿐 100% 정확하다고 볼 수 없습니다. 동료에게 전해 듣기로 Redis 벤치마크는 성능이 너무 좋게 나오는 경향이 있어 보인다고 하더라고요. 대략적으로 참고할 만한 자료라 판단하고 별도 테스트로 추가적인 검증을 진행했습니다.

 

 

 

3-2. 서킷브레이커 적용 전후 API 호출 부하테스트

 

c5.xlarge 맞춰 도커 리소스 세팅 (EC2)

 

유저 정보를 가져오는 내부통신 API 호출에 서킷브레이커 적용 전후 TPS와 Redis CPU 사용량을 비교했습니다.

 

 

 

Redis CPU 사용률 (평균 150TPS로 레디스 호출해도 4% 아래)

 

 

위 지표를 보면 서킷브레이커 적용 전후 TPS는 크게 차이 없습니다. 로컬 테스트 결과를 바탕으로 판단할 때 운영 환경도 이슈 없어보이네요. Redis CPU 사용률도 크게 이슈가 없는 걸로 보아 서킷브레이커로 인한 내부통신 병목이 없을 거라 판단했습니다.

 

💡로컬에서 동시에 띄울수 있는 쓰레드 제한으로 TPS가 낮습니다.

 

 

 

3-3. 서킷브레이커 적용 전후 API 호출 부하테스트

 

서킷브레이커 상태에 따른 응답시간을 확인해봤습니다. 참고로 저희는 위에서 설명한대 대로 offset내에서 최소 호출수 + failure rate 임계치를 넘어가는 상황이 되면 OPEN 상태로 변경하기 때문에 슬라이딩 윈도우 별로 offset내에서 상태가 변경되는 시점의 편차가 있을 수 있습니다.

 

 

TPS (호출되는 API서버를 껐다, 켰다 하면서 서킷브레이커 동작을 확인)

 

 

Response Time (서킷브레이커가 OPEN 일때는 빠른 응답을 함)

 

 

API서버 CPU, Memory 사용량

 

 

 

4. 실제 서킷브레이커 발동 사례를 분석해보자

 

내부통신에 서킷브레이커를 적용하고 몇 개월 동안은 잊고 지냈습니다. 서킷브레이커가 발동하지 않을수록 화해가 안정적인 상태라는 걸 의미하므로 “서킷브레이커 OPEN 알림을 보는 순간이 없었으면 좋겠다”라는 생각을 하였습니다. 그렇게 몇 개월이 흐른 어느 날, 슬랙 채널에 서킷브레이커 알림이 울렸습니다.

 

 

서킷브레이커 OPEN , HALF OPEN 상태 알림

 

 

울리지 말아야 할 알림이 울리고 말았습니다! 서킷브레이커가 OPEN되었습니다.

 

 

몇 번 HALF OPEN 발생 후 정상적으로 CLOSED 상태로 처리된 서킷브레이커

 

 

데브옵스팀에서 먼저 확인한 뒤 제게 멘션을 주셨습니다. 처음 의도했던대로 서킷브레이커가 제대로 동작했네요. 장애 발생 직후 OPEN되었으며, 장애가 해소된 뒤에 CLOSED까지 잘 이루어졌습니다. 이후 서킷브레이커 발동 구간을 바탕으로 장애 발생 원인을 되짚어보았습니다.

 

화해 검색 지면은 머신러닝을 기반으로 고객에게 제품을 추천하는데요. 그 과정 중 검색 API 서버ML 서빙 API 서버 내부통신 구간이 존재하는데, 이곳에서 서킷브레이커가 발동되었습니다. 장애 발생 원인은 다음과 같습니다.

 

🚧
1. ML 서빙 API 서버에서 더이상 사용하지 않는 RDS를 제거하여 RDS를 중단 
2. ML 서빙 API 서버 헬스체크에서 해당 RDS 헬스체크 로직을 제거하지 않음 
3. 헬스체크가 실패하여 ML 서빙 API 서버 ECS Task가 죽음
4. 검색 API 서버 ↔ ML 서빙 API 서버 구간에 서킷브레이커 OPEN

 

인프라 이슈나 신규 기능으로 인한 장애는 아니었고, 사용하지 않는 RDS를 제거하는 과정에 놓친 휴먼에러였습니다. fallback이 정의되어 사용자가 장애를 인지할 만큼 확산되지 않았으며, 연관 부서에서 즉각 대응을 해주셔서 7분만에 장애 상황이 해소되었습니다.

 

장애 상황 동안 서킷브레이커가 의도대로 동작했는지 지표로 확인해보죠.

 

 

 

전 날과 비교한 레이턴시

 

 

중단되었던 ml mysql 지표

 

 

두 번째 지표는 ML 서빙 API 서버에서 mysql이 중단된 시점 지표입니다. 09시57분 ~ 10시07분까지 쓰루풋(Throughput)이 없는 걸 확인할 수 있습니다.

 

첫 번째 지표는 검색 API 서버로 호출된 api를 전 날과 비교한 레이턴시(Latency)입니다. 09시59분 ~ 10시07분까지 평균적인 레이턴시 1000ms가 웃도는걸 볼 수 있습니다. mysql 중단 시점과 동일하고 이 시점이 장애 발생 시점입니다.

 

서킷브레이커가 발동된 시점을 기준으로 검색 서버에서 Stack Trace를 확인해보겠습니다.

 

 

 

서킷브레이커가 OPEN되서 ML 서빙 API 서버를 호출하지 않음

 

 

서킷브레이커가 HALF OPEN일 때 ML 서빙 API 서버를 호출

 

서킷브레이커가 OPEN된 상태에서 ML 서빙 API 서버를 호출하지 않는 모습을 확인할 수 있습니다.

 

 

 

장애상황일때 fallback이 적용되어 실패율 0%

 

fallback까지 잘 적용되어 API 응답 실패율은 0%입니다.

 

 

 

4. 마치며


내부통신용 서킷브레이커 개발 DLI를 맡아 고민도 많았지만, 보람을 느꼈던 작업입니다. 함께 기능 리뷰하고 부하테스트를 도와준 팀원들에게 감사인사를 전합니다.

 

공통 도메인이 담당하는 과제는 서비스 개발처럼 직접적으로 사용자에게 드러나지 않습니다. 그러다보니 아주 가~끔은 ‘내가 하는 일이 정말 서비스에 기여가 하는 작업인가?’라는 물음에 빠질 때도 있었는데요. 서킷브레이커가 작동되는 걸 보니 충분히 중요한 일이며, 서비스에 기여한다고 느껴 뿌듯했습니다.

 

서킷브레이커를 처음 개발할 때는 막연한 두려움이 마음 한켠에 두었습니다. 어느 날 문득 그런 생각이 들더라고요. 막연한 두려움을 가졌던 이유는 근거와 더불어 검증이 충분하지 않기 때문이라 느꼈습니다. 제품 개발 자체도 중요하지만 서비스 특성과 지표, 데이터를 근거로 증명하는 걸 소홀히 여기면 안 됩니다. 검증이야말로 신뢰성 높은 제품을 만드는 원동력이거든요.

 

긴 글 읽어주셔서 감사드립니다!

 

 

 


이 글이 마음에 드셨다면 다른 콘텐츠도 확인해 보세요!

캐시 스탬피드를 대응하는 성능 향상 전략, PER 알고리즘 구현

화해 검색 개선의 첫걸음

 

  • Redis
  • 서킷브레이커
  • 백엔드팀

연관 아티클