개발자 의식의 흐름대로 적용해보는 서킷브레이커

Jan.11.2024 이제현

Backend

서비스가 확장되고 IT기업의 규모가 커짐에 따라서, 같은 회사 혹은 조직에서 동일한 기능을 개발하고 운영하게 되는 경우가 많습니다.

사실 한가지 IT 서비스를 구성하기 위해서는 여러가지 기능의 조합으로 전체 기능을 구성하게 되므로, 실제로 서로 다른 조직에서 동일한 기본적인 기능을 구현하는 경우는 매우 흔한 상황입니다.

조직이 성장하여 처음 서비스를 분화할 때는 공통적인 기능에 대한 정의도 되어 있질 않고, 이를 조율할 수단이나 절차도 없습니다. 그러다보니 당장 필요한 기능을 추가하기 위해서 개별 서비스에서 각자 개발하게 됩니다. 하지만 조직이 충분히 성장했을때는, 겹치는 기능을 개발하고 운영하는데 비용이 이중으로 들게 됩니다. 각각의 기능에 구현과 운영을 위한 개발 및 기획 인력, 인프라 비용 등이 개별로 들어가게 되니까요.

이를 방지하는 방법은 간단합니다. 공통 기능을 추려내어 공통적인 기능을 담당하는 플랫폼을 만들고 해당 플랫폼을 개발, 운영하는 조직을 만들면 됩니다. 전사적으로 동일한 기능의 개발과 운영에 들어가는 인력을 줄일수 있고, 인프라 비용도 감소할 수 있습니다. 조직과 서비스의 규모가 클수록 공통플랫폼 사용으로 인한 비용절감 효과는 증가하게 됩니다. 따라서 공통플랫폼으로의 전환은 비용의 효율화 입장에서 불가피한 선택일 수 있습니다.

그렇다면 불가피한 공통플랫폼 사용 시 주의할 점은 없을까요?

소프트웨어는 사람이 만듭니다. 코드 생성의 자동화라는 오래된 주제로 컴퓨터 과학, 컴퓨터 공학에서 수많은 키워드가 나왔었지만, 아직까지 완전히 사람의 손을 벗어날 수 없었습니다. 사람의 손에서 나온 코드는 당연하게도 버그를 가지고 있을 가능성이 있습니다. 사실 기계가 만들어 낸 코드도 버그가 있을 가능성이 있습니다. 결국, 모든 코드는 버그의 가능성을 가지고 있습니다.

IT 서비스에서 버그는 다양한 모습으로 표출됩니다. 단순한 기능 이상일 수도 있고, 중대한 기능 이상일 수도 있습니다. 또 어떤 버그는 서비스의 장애로 이어집니다. 공통플랫폼도 마찬가지로 소프트웨어입니다. 당연히 버그의 가능성이 있으며, 그 중 일부는 장애로 이어질 가능성이 있습니다. 따라서 공통플랫폼을 사용하는 서비스는 자신의 서비스 장애도 주의를 기울여야 하지만, 사용하는 공통플랫폼의 장애시 자신의 서비스에 미칠 영향도도 고려해야 합니다. 보통 연동된 시스템에서의 장애는 장애전파로 이어 집니다.

그렇다면 어떻게 해야 공통플랫폼의 장애에 효율적으로 대응할 수 있을까요? 배민상회에서는 어떻게 하는지 알아보겠습니다.

반갑다 동기야!… 가 아니고 동기? 비동기?

배민상회팀은 배민상회 서비스와 대용량특가 서비스를 개발 및 운영하고 있습니다. 배민상회는 요식업 사장님들의 장사를 돕기 위한 식품 및 비품을 판매하는 쇼핑몰입니다. 대용량특가는 즉석밥, 생수 등 쟁여놓기 좋은 상품을 대용량으로 저렴한 가격에 택배로 구매할 수 있는 서비스입니다.

배민상회가 속해 있는 우아한형제들에는 배민상회와 대용량특가 말고도 다양한 서비스가 있습니다. 앞서 말한 이유로 우아한형제들에도 전사적인 공통플랫폼들이 존재합니다.

배민상회도 자체 개발하던 검색, 광고, 쿠폰 등 일부 기능을 전사의 공통플랫폼으로 이관하는 작업을 통해서 코드의 효율화는 물론 비용의 효율화를 진행 중에 있습니다.
공통플랫폼과 연동은 그 기능의 요구사항에 따라서 동기 방식 혹은 비동기 방식을 통해서 연동이 됩니다.

보통 공통플랫폼에 이관된 기능은 결과를 가져오기 위해서 동기적으로 연동이 됩니다. 가령 상품의 현재까지의 리뷰 정보와 별점 정보를 가져온다거나, 특정 상품 카테고리의 상품목록을 가져오는 동작 등 지금 당장 보여줘야 하는 기능은 동기적인 연동이 필요합니다.

동기적인 연동은 지금 당장의 실시간 정보를 가져올 수 있다는 장점이 있지만, 플랫폼을 사용하는 서비스와 플랫폼간에 강력한 의존관계가 생성되고 쉽게 장애 전파가 된다는 단점이 있습니다. 만약 서비스와 플랫폼간에 비동기적으로 연동이 된다면, 직접적인 장애 전파는 막을 수 있습니다.

다만, 앞서 말했듯이 실시간의 정보가 필요한 기능이거나, 비동기 처리를 위한 코드의 복잡도 상승, 플랫폼의 개발 우선순위에서 밀리는 등의 이유로 비동기로의 전환이 어려운 경우도 있습니다. 그렇다면 동기 상황에서 장애전파를 방지하려면 어떤 방법이 있을까요?

바로 서킷브레이커(Circuit Breaker)를 도입하는 것입니다.

서킷브레이커?

서킷브레이커는 집에 있는 두꺼비집 혹은 분전반에서 확인 할 수 있습니다. 누전차단기, 회로차단기가 바로 이것입니다.

회로차단기
회로차단기(출처: https://ko.wikipedia.org/wiki/회로_차단기)

소프트웨어에서 말하는 서킷브레이커는 서로 다른 시스템 간의 연동 시 장애전파 차단을 목적으로 합니다. 연동 시 이상을 감지하고 이상이 발생하면 연동을 차단하고, 이후 이상이 회복되면 자동으로 다시 연동하기 위한 기술입니다.

서킷브레이커는 상태에 따라서 서로 다른 동작을 합니다. 서킷브레이커는 3가지의 보통 상태(OPEN, CLOSED, HALF_OPEN)와 2가지의 특별한 상태(DISABLED, FORCED_OPEN)를 갖습니다.

보통 상태로는 정상적으로 호출되고 응답을 주는 CLOSED 상태, 문제 발생이 감지된 OPEN과 HALF_OPEN 상태가 있습니다. 특별한 상태로는 항상 호출을 허용하는 DISABLED 상태와 항상 호출을 거부하는 FORCED_OPEN 상태가 있습니다.

서킷브레이커는 슬라이딩 윈도(sliding window)를 사용하여 상태의 변화여부를 결정합니다. 슬라이딩 윈도는 횟수 방식(COUNT_BASED)과 시간 방식(TIME_BASED)으로 나뉩니다.

방식에 따라 슬라이딩 윈도 안에서 정해진 확률보다 높은 확률로 호출에 실패하게 되면 상태를 OPEN으로 변경합니다. OPEN 상태에서는 연동된 시스템 호출을 시도하지 않으며, 바로 호출 실패 Exception을 발생시키거나 정해진 fallback 동작을 수행합니다.

OPEN 이후 설정한 시간이 지나면 HALF_OPEN 상태로 변경되며, 호출이 정상화되었는지 다시한번 실패 확률로 확인합니다. 정상회되었다고 판단되면, CLOSED 상태로 변경되며, 아직 정상화되지 못했다고 판단되면 다시 OPEN 상태로 되돌아 갑니다.

서킷브레이커 상태전이도
서킷브레이커 상태 전이도(출처: https://resilience4j.readme.io/docs/circuitbreaker)

배민상회는 서킷브레이커를 구현하기 위해서 Resilience4j를 이용했습니다.

Resilience4j는 Netflix Hystrix로부터 영감을 받은 함수형 프로그래밍(functional programming)으로 설계된, 경량의 내결함성(fault tolerance) 라이브러리입니다.

함수형 프로그래밍으로 설계가 되어서 functional interface, lambda, method reference 등을 활용하여 구현할 수 있습니다.

원래 Spring Cloud에서는 Spring Cloud Hystrix를 서킷브레이커로 사용하였으나, 현재는 maintenance 모드로 전환되었고 대체 모듈로 Resilience4j가 선택되었습니다.

Resilience4j의 주요 기능은 서킷브레이커 외에도 Rate Limiter, Retry, Bulkhead, TimeLimiter, Cache 등을 수행할 수 있습니다. 본 글에서는 서킷브레이커만 이야기하겠습니다.

개발자 의식의 흐름대로 적용해보는 서킷브레이커

서킷브레이커를 사용하기 위해서는 의존성을 추가해야 합니다. 서킷브레이커를 구현하고자 하는 시스템의 구현에 따라서 서로 다른 의존성을 제공하며, Getting Started 페이지에서 확인할 수 있습니다.

의존성을 추가하면 annotation을 통해서 서킷브레이커를 사용할 위치를 지정할 수 있습니다. 만약, 요청에 실패할 경우 수행할 fallback method가 있다면, fallback 설정도 추가할 수 있습니다. fallback method는 요청 실패에 대한 Exception별로 구분해서 작성할 수 있습니다.

아래 예시에서 2번째 fallback method는 CallNotPermittedException에 대한 fallback method인데, 해당 Exception은 서킷브레이커가 OPEN 상태일 때, 발생되는 Exception입니다.

아래 코드는 서킷브레이커를 적용한 코드의 예시입니다.

본 코드는 실제로 배민상회에서 사용되는 코드는 아니며, 실제 코드와 유사하게 본글에서 설명하기 위해 재작성되었습니다.

public class PlatformExecutor {

    @CircuitBreaker(name = "toPlatform", fallbackMethod = "fallback")
    public PlatformResultDto execute(Condition condition) {

                    ...
    }

    private PlatformResultDto fallback(Condition condition, Exception exception) {
        log.warn("fallback method가 실행 됩니다. condition:{}", condition, exception);
        return doFallback(condition);
    }

    private PlatformResultDto fallback(Condition condition, CallNotPermittedException exception) {
        log.warn("[CircuitBreaker : OPEN] fallback method가 실행 됩니다. condition:{}", condition, exception);
        return doFallback(condition);
    }
}

서킷브레이커 예시 코드(출처: 직접 작성)

fallback method에 대한 오해가 있어서 말씀을 드리면, fallback method는 서킷브레이커의 상태와 무관하게, annotation을 표시한 method 즉, 서킷브레이커가 걸린 method가 실패하면 수행됩니다.

CLOSED 상태에서도 method가 실패한다면 fallback method를 수행합니다. 따라서 fallback method의 수행여부로 서킷브레이커의 현재 상태를 알 수 없습니다.

또 서킷브레이커를 걸은 method에서 Exception이 발생하게되어서 fallback method가 수행이 되면, 발생한 Exception은 상위 method로 전파되지 않습니다. 기존에 서킷브레이커가 없던 상태에서 Exception을 받아서 처리하던 부분이 있다면, 이 부분을 고려해서 수정이 필요합니다.

fallback method는 서킷브레이커를 설정한 method에서 Exception이 발생할 경우 실행됩니다. 연동할 플랫폼의 응답에 따라서 적절한 fallback method가 준비가 되어있다면, 적절한 Exception을 발생시켜 주어야 합니다. 이는 단순히 연동할 플랫폼과 연동하는 서비스간에 Rest API의 status code로 Exception을 발생시킬 수 도 있습니다만, 상황에 따른 적절한 비즈니스 로직에 비추어 설정해야 할 경우도 있습니다.

예를 들면, 공통 플랫폼의 응답이 빈 응답이라면, 이것을 어떻게 처리해야 할까요?

진짜 빈 응답일 수 도 있고 공통플랫폼의 내부 장애로 빈 응답이 올 수 도 있습니다. 연동하는 서비스는 장애 여부를 판단할 근거가 있을 수도 있고 없을 수도 있습니다. 상황에 따른 종합적인 판단이 필요합니다. 이 판단은 연동하는 서비스와 연동할 플랫폼의 로직에 비추어 가장 적절한 최선의 처리가 필요합니다.

자, 이제 이 정도 구현하면 서킷브레이커는 동작합니다. 그런데, 테스트하면서 느낄 것입니다.

“서킷브레이커의 상태변화가 로그로 찍히면 좋겠는데?”

맞습니다. 이 상태에서는 상태 변화에 대해 특별히 로그를 찍지 않는데, 서킷브레이커는 서킷브레이커가 발생한 이벤트에 따라서, 리스너를 설정하여 동작을 지정해 줄 수 있습니다.

보통 software에서 이벤트의 동작을 하는 부분을 이벤트 리스너라고 하는데, Resillience4j에서는 이를 consumer라고 합니다. 서킷브레이커 consumer에서 상태변화 이벤트에 대해서 로그를 찍는 동작을 지정해 주면, 서킷브레이커의 상태 변화에 로그를 찍을 수 있습니다.

consumer의 구현은 RegistryEventConsumer interface를 사용하여 구현할 수 있습니다.
상태변화에 대한 동작을 지정하기 위해서는 onStateTransition() method에 동작을 지정하면 됩니다.

public class CircuitBreakerRegistryEventConsumer implements RegistryEventConsumer<CircuitBreaker> {

    @Override
    public void onEntryAddedEvent(EntryAddedEvent<CircuitBreaker> entryAddedEvent) {
        entryAddedEvent.getAddedEntry().getEventPublisher()
            .onFailureRateExceeded(event -> log.warn("{} failure rate {}%", event.getCircuitBreakerName(), event.getFailureRate())
            )
            .onError(event -> log.error("{} ERROR!!", event.getCircuitBreakerName())
            )
            .onStateTransition(
                event -> log.info("{} state {} -> {}",
                    event.getCircuitBreakerName(), event.getStateTransition().getFromState(), event.getStateTransition().getToState())
            );
    }
}

consumer 코드(출처: 직접 작성)

서킷브레이커 로그
개발 환경에서 재현해 본 서킷브레이커 로그(출처: 배민상회 개발환경 로그 직접 캡처)

이제 서킷브레이커 구현도 되었고, 상태변화에 대해서 로그로 확인도 가능해졌습니다.

상태변화 로그를 보면서 확인을 할 수 있으니, 서킷브레이커의 설정이 구현된 시스템에 적절한지 알수가 있고, 설정값을 변화시켜서 나의 시스템에 맞게 설정하고 싶어집니다.

서킷브레이커의 주요 설정값은 아래와 같습니다.

  • failureRateThreshold: 호출 실패율에 대한 임곗값, sliding window 상에서 임곗값보다 실패율이 높아지면 상태가 OPEN 됩니다. 기본값은 50입니다.
  • slidingWindowSize: sliding window의 크기를 설정합니다. 기본값은 100입니다.
  • slidingWindowType: siliding window의 형태를 COUNT_BASED 로 설정할지, TIME_BASED로 설정할지 결정합니다. 타입에 따라서 slidingWindowSize의 숫자가 의미하는 것이 호출 횟수 혹은 시간이 됩니다. 기본값은 COUNT_BASED입니다.
  • waitDurationInOpenState: OPEN 상태에서 얼마나 기다린 후에, HALF_OPEN으로 전환할지를 설정합니다. 기본값은 60000 ms(60초)입니다.

이 외의 설정은 가이드 문서의 Create and configure a CircutiBreaker에서 확인할 수 있습니다.

아래 설정값은 application.properties 에 저장된 서킷브레이커의 설정값 예시입니다.

#resilience4j
resilience4j.circuitbreaker.configs.default.sliding-window-size=10
resilience4j.circuitbreaker.configs.default.failure-rate-threshold=50
resilience4j.circuitbreaker.configs.default.wait-duration-in-open-state=10s
resilience4j.circuitbreaker.configs.default.sliding-window-type=COUNT_BASED

서킷브레이커 설정코드(출처: 직접 작성한 코드)

이로써 원하는 동작을 설정할 수 있고, 로그를 통해서 동작을 확인할 수 있습니다.

이제 운영을 해봅시다. 그런데 운영을 할 때 터미널을 열어서 로그를 항상 확인하나요? 아닙니다. 편한 운영을 위해서는 대시보드가 필요합니다.

우아한형제들은 전사적으로 그라파나(Grafana)를 사용하고 대시보드를 구성합니다. Resillience4j는 프로메테우스에 서킷브레이커의 상태, 실패율 등 상태정보를 저장하고, 그라파나에서 이를 이용해서 대시보드를 구성할 수 있습니다. Resillience4j가 프로메테우스에 저장하는 정보정보는 Resillience4j 가이드 문서의 CircuitBreaker Metrics 에서 확인할 수 있습니다.

오늘도 배민상회와 대용량특가는 녹색불입니다.

서킷브레이커 대시보드
배민상회 그라파나 대시보드의 일부 캡쳐

장애? 멈춰!

이 코드가 언제가 될지 모르지만 배민상회가 없어지는 그날 까지 실행되지 않았으면 하는 마음으로 작성을 했습니다.

모든 시스템은 장애의 가능성을 가집니다. 장애전파 방지를 위한 조치는 개발자인 내가 연동할 시스템을 믿지 못하는 신뢰의 문제도 아니며, 지레 겁먹은 개발자의 over engineering도 아닙니다. 그저 기본적인 장애전파의 방지 수단일 뿐입니다.

또한 서킷브레이커 적용은 수단일 뿐이며, 만능이 아닙니다. 이중화를 이용하는 등 다른 방식을 통해서 시스템의 장애를 방지할 수도 있습니다. 다만 중요한 것은 장애방지 및 확산 예방을 위한 조치의 마련입니다.

의식의 흐름대로 슬슬 구현해도 서킷브레이커는 손쉽게 적용할 수 있습니다. 사실 연동 부분을 모두 예외 처리해 try-catch를 이용할 수도 있지만, 장애상태에서 빠르게 실패 처리를 하고, 공통플랫폼이 정상화될 경우 다시 자동으로 내 서비스도 정상화되는 등 운영상의 장점이 분명히 있습니다. try-catch 처리를 통한 처리보다는 수정 사항이 많겠지만, 수정하는 양 대비 이점이 많습니다.

배민상회와 대용량특가는 오늘도 성장하고 있습니다. 물론 모든 플랫폼 연동에 서킷브레이커를 적용하지 못하였지만, 언제나 사장님들에게 안정적인 쇼핑 경험을 드리기 위해 노력하는 배민상회, 대용량특가가 되겠습니다.

이제현

사장님들을 위한 쇼핑몰인 배민상회에서 백엔드 개발을 하고 있습니다.