ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Resilience4j] 외부 서비스 호출 로직에 서킷 브레이커 패턴 적용하기
    Design Pattern 2024. 2. 18. 17:32

     

    들어가며

     

      외부 서비스에 장애가 발생하더라도 연속적인 서비스를 제공하려면 어떻게 해야할까요? 트래픽 폭증 등의 이유로 외부 서비스가 당장 복구되기 어려울 때, 계속해서 외부 서비스에 데이터를 요청한다면 해당 서버에 부하를 가하게 되고 사용자에게도 지속적으로 장애 상황이 전파될 것입니다. 이런 경우, '서킷 브레이커' 패턴을 사용함으로써 외부 서비스 호출 실패가 전체 시스템에 영향을 미치는 것을 방지할 수 있습니다. 그럼 도대체 서킷 브레이커가 무엇인지, 서킷 브레이커가 어떻게 서비스 안정성과 고가용성을 위한 디자인 패턴으로서 기능하는지 살펴보겠습니다. 참고로 저는 재시도(retry)와 서킷 브레이커(circuit breaker)를 조합해서 사용했습니다.

     

    목차

    1. 서킷 브레이커 패턴이란?(feat. 고가용성)

    2. 고가용성 설계를 위한 또 다른 대안들

    3. 코틀린으로 작성한 재시도 + 서킷 브레이커 패턴 코드

    4. 테스트 코드로 확인하는 서킷 브레이커의 동작

    5. 마무리

     


     

    서킷 브레이커 패턴이란? (feat. 고가용성)

    서킷 브레이커는 서비스 안전장치다!

      서킷 브레이커는 서비스 장애 처리와 회복 전략을 위한 디자인 패턴 중 하나이며, 여러 분야에서 범용적으로 사용되는 용어입니다. 말 그대로 잠시 과열된 문제점을 식혀주는 'breaker' 역할을 하는데요, 일례로 주식시장에서의 서킷 브레이커는 주가의 급등락 시 주식 거래를 일시 정지시켜 시장에 미치는 충격을 완화합니다. 1987년 10월 미국 증권시장 주가 대폭락 사태 ‘블랙 먼데이’ 이후 뉴욕증권거래소를 시작으로 각국 증시에서 시장 안전장치의 일종으로 활용되고 있으며, 최근 우리나라에서는 2020년 3월 19일 코로나 패닉 때 유가폭락으로 인해 서킷 브레이커가 발동하여 20분간 매매가 중단된 적이 있습니다. 

     

      다시 우리의 IT분야로 돌아와서, 서킷 브레이커는 외부 서비스의 지속적인 장애 발생 시 외부 서비스와의 연결을 차단합니다. 이후 장애가 지속되지 않는다면 다시 연결을 복구함으로써 어플리케이션 전체의 안정성과 가용성을 높입니다. 이를 통해 어플리케이션이 과도한 부하나 장애 상황에서도 계속적으로 외부 api에게 요청하는 것을 방지하고, 사용자에게 연속적인 서비스를 제공하게 됩니다. 즉, 서킷브레이커 패턴을 적용함으로써 고가용성 서비스를 제공할 수 있게 되는 것이죠.

     

     

    서킷 브레이커의 동작과 상태 전이: CLOSED, OPEN, HALF-OPEN

      서킷 브레이커의 동작은 아래와 같습니다. CLOSED는 메소드 성공으로 닫힌 상태, OPEN은 메소드 실패로 열린 상태, HALF-OPEN은 CLOSED가 될 수도 있고 OPEN이 될 수도 있는 상태입니다. 조금 더 자세히 설명하면 서비스가 정상적으로 동작하는 CLOSED 상태에서 실패 임계치에 도달하면 OPEN 상태로 전환되고, OPEN 상태에서 일정 시간이 흐르면 HALF-OPEN 상태가 됩니다. HALF-OPEN 상태에서 실패 임계치 이상이면 다시 OPEN, 실패 임계치 미만으로 유지되면 CLOSED 상태로 전환됩니다. 이를 도식으로 나타내면 아래와 같습니다.

     

    서킷 브레이커의 상태별 동작

     

    서킷 브레이커 라이브러리 선택: Resilience4j

      서킷 브레이커를 제공하는 Java 라이브러리에는 Java6 기반의 Netflix Hystrix와 Java8 기반의 Resilience4j가 있습니다. 하지만 Netflix Hystrix는 공식 깃헙 레포지토리 "Hystrix is no longer in active development, and is currently in maintenance mode." 라며 더이상 새롭게 개발을 진행하지 않는 유지보수 상태임을 명시하고 있으며, Spring Cloud Greenwich.RC1 버전 릴리즈 당시의 Spring Boot 공식 문서에서도 Hystrix를 대체하여 Resilience4j를 사용할 것을 권장하고 있습니다. 따라서 저 역시 Resilience4j를 사용했습니다. 

     

    Spring Cloud Greenwich.RC1 릴리즈 노트에서 발췌

     

    서킷 브레이커 패턴 적용의 의의: 고가용성 설계

       한편, 앞서 서킷 브레이커를 적용하면 고가용성 서비스 제공이 가능하다고 했으니 가용성이 무엇인지부터 짚고 넘어가겠습니다. 가용성이란 '시스템이 사용자에게 서비스를 제공할 수 있는 능력'을 말하며, 시스템의 신뢰성을 나타내는 중요 지표 중 하나입니다. 일반적으로 가용성은 백분율 단위로 측정하는데, 99% 가용성은 시스템이 1년 중 99%의 시간 동안 정상 작동할 수 있음을 의미합니다. 대략적으로 생각했을 때 99%라는 수치는 굉장한 고가용성 서비스라는 느낌을 주지만, 1%의 다운타임 발생 가능성을 계산해보면 1년에 약 876시간이나 다운될 수 있습니다(365일 x 24시간 x 0.01). 고가용성의 기준은 비즈니스 요구 사항 및 특정 서비스의 필요에 따라 달라지며, AWS 공식 문서에서는 가용성별로 적합한 어플리케이션 종류를 아래와 같이 제시하고 있습니다.  

     

    가용성별 적합한 어플리케이션 종류 (출처: https://docs.aws.amazon.com/wellarchitected/latest/reliability-pillar/availability.html)

      가용성을 측정할 수 있는 지표에는 '작동 시간', '요청 수', '회복성' 등이 있습니다. 작동 시간을 기준으로 가용성을 계산할 때는 '사용 가능 시간/총 시간', 요청 수를 기준으로 계산할 때는 '성공 응답 수/유효한 요청 수', 회복성을 기준으로 계산할 때는 '평균 고장 간격/(평균 고장 간격 + 평균 복구 시간)'의 산식을 사용합니다(출처). 참고로 평균 고장 간격(MTBF, Mean Time Between Failure)은 평균 복구 시간(MTTR, Mean Time To Repair)과 평균 고장 시간(MTTF, Mean Time To Failure)의 합계입니다. 결과적으로 고가용성 시스템은 아래와 같은 특성을 갖습니다.  

    💫 고가용성 시스템의 특성
     1️⃣ 최소한의 다운타임과 함께 오랜 시간 동안 작동해야 함
     2️⃣ 시스템 부하가 증가할 때, 이에 즉각 대응하여 자원 확장이 가능해야 함
     3️⃣ 오류 발생 시 이를 감지하고 처리하며 가능한 빠르게 정상 상태로 복구할 수 있어야 함

     

     

    고가용성 설계를 위한 또 다른 대안들

      Resiliece4j는 자바 어플리케이션을 위한 "fault tolerance" 라이브러리이며, Circuit Breaker 외에도 서비스 내결함성을 향상시키기 위한 패턴으로 Retry, Bulkhead, RateLimiter, TimeLimiter, Cache 모듈을 제공하고 있습니다. 저는 기존에 외부 서비스 호출 시 Spring Boot의 Retry 패턴을 통해 서비스 복구를 시도하고 있었는데, Resilience4j 각 모듈의 장단점을 파악 후 Circuit Breaker와 Retry를 조합해서 일시적인 장애로 판단되면 Retry, 빠른 복구가 어렵다고 판단되면 Circuit Breaker가 동작하도록 개선했습니다. 아래와 같이 내결함성 패턴 종류별로 장단점과 유즈케이스에 대해서 정리했으며, 추후 Bulkhead를 통해 부하를 분산하거나 Rate Limiter를 통해 트래픽의 양도 적절히 제한해보려고 합니다. 

     

    • Circuit Breaker - 장점: 서비스의 과부하를 방지하고, 실패한 서비스에 대한 빠른 실패 처리를 통해 시스템의 안정성 유지 가능
    - 단점: 서비스가 재정상화되기 전까지 일시적으로 서비스가 차단될  있으며, 초기 설정  관리 필요
    - 유즈 케이스: 서비스의 과부하를 방지하고 신속한 실패 처리가 필요한 경우
     Bulkhead - 장점: 부하 분산을 통해 서비스의 부분적인 장애가 다른 부분에 영향을 미치지 않도록 함
    - 단점: 자원의 낭비가 발생할  있으며, 너무 많은 분리가 서비스 전체의 효율성을 감소시킬  있음
    - 유즈 케이스: 서비스의 부분적인 장애가 전체 서비스에 영향을 미치지 않도록 하고 싶은 경우
     Rate Limiter - 장점: 과도한 요청에 대한 서비스 부하를 제한하여 서비스의 안정성 유지 가능
    - 단점: 허용 가능한 요청 속도 설정에 따라 사용자 경험에 영향을   있음
    - 유즈 케이스: 과도한 요청으로 인한 서비스 부하를 제한하고 싶은 경우
     Retry - 장점: 네트워크 연결 문제나 일시적인 서버 문제 발생 시 요청을 성공할  있도록 여러  시도할  있음
    - 단점: 지속적인 장애 상황에서는 효과적이지 않을  있으며, 재시도로 인한 부하 발생 가능
    - 유즈 케이스: 지속적인 장애에는 적합하지 않으며, 네트워크 연결이 불안정하거나 일시적인 서비스 다운 
     Time Limiter - 장점: 실행 시간을 제한하여 서비스의 응답 시간을 예측 가능하게 함
    - 단점:  실행 시간이 필요한 작업에 대해 제한이 가해질  있으며, 일부 요청이 너무 빨리 실패할  있음
    - 유즈 케이스: 서비스의 응답 시간을 예측 가능하게 제한하고 싶은 경우
     Cache - 장점: 반복적인 요청에 대한 응답 시간 및 서비스 부하 감소
    - 단점: 적절하지 않은 캐싱 전략은 데이터 일관성, 메모리 부족 등의 문제를 야기할  있음
    - 유즈 케이스: 반복적으로 요청되는 데이터에 대한 응답 시간을 줄이고 서비스 부하를 감소시키고 싶은 경우

     

     

    코틀린으로 작성한 재시도 + 서킷 브레이커 패턴 코드

    의존성 추가

      build.gradle 파일에 아래와 같이 의존성을 추가합니다. aop는 Resilence4j가 AOP 기능과 어노테이션 기반 설정을 사용할 수 있도록 만들기 위해서 필요하고, actuator는 end point를 통해 Resilience4j의 동작을 손쉽게 파악하기 위해서 필요합니다. 또, 서킷 브레이커 의존성과 관련하여 spring-boot3:1.7.0을 추가했습니다. 저는 스프링부트 3를 사용하므로 spring-boot'3'과 Resilience4j의 버전 '2.2.0'을 지정했습니다.

     

      Resilience4j 2부터는 Java 17 버전을 사용하므로 Java 17 이하의 버전을 사용하신다면 Resilience4j는 버전 1을 사용하면 됩니다(출처). 마지막으로 Resilience4j 모듈 관련해서 circuitbreaker, retry 두 가지 의존성을 추가했습니다. 모든 모듈을 제공하는 'resilience4j-all'도 있는데 저는 서킷 브레이커와 재시도만 사용할 것이기 때문에 두 가지 모듈을 각각 명시해줬습니다.

    // build.gradle.kts
    
    dependencies {
    	implementation("org.springframework.boot:spring-boot-starter-aop")
            implementation("org.springframework.boot:spring-boot-starter-actuator")
        
        // circuit breaker
    	implementation("io.github.resilience4j:resilience4j-spring-boot3:2.2.0")
    	implementation("io.github.resilience4j:resilience4j-circuitbreaker:2.2.0")
    	implementation("io.github.resilience4j:resilience4j-retry:2.2.0")
    }

     

      참고로 actuator를 통해 제공되는 resilience4j의 각 endpoint는 아래와 같습니다. 서킷 브레이커의 이벤트 타입에는 STATE_TRANSITION, FAILURE_RATE_EXCEEDED, SUCCESS, ERROR, RESET, IGNORED_ERROR,   NOT_PERMITTED  등이 있습니다. 

    • /actuator/circuitbreakers: 서킷 브레이커의 전체 목록 조회
    • /actuator/circuitbreakers/{name}: 특정 서킷 브레이커의 상태 변경(상태 조회 x)
    • /actuator/circuitbreakerevents: 모든 서킷 브레이커에서 발생 중인 이벤트 목록 조회
    • /actuator/circuitbreakerevents/{name}: 특정 서킷 브레이커에서 발생 중인 이벤트 목록 조회
    • /actuator/circuitbreakerevents/{name}/{eventType}:특정 서킷 브레이커에서 발생 중인 특정 이벤트 타입 정보 조회

     

    설정 파일 생성

      Resilience4j의 Circuit Breaker와 Retry 설정값은 아래와 같습니다. 쉬운 방법으로 application.yml을 통해 Circuit breaker와 Retry의 설정값을 지정해줘도 되지만 스프링의 의존성 주입 및 설정에 대해서 한번 더 공부하는 차원에서 CircuitBreakerConfiguration.kt와 RetryConfiguration.kt를 직접 작성해봤습니다. 특히 RetryConfiguration에서는 decorateSupplier 메소드를 정의했는데 그 이유는 아래에서 설명하겠습니다. 추후 설정 파일 대신에 application.yml을 사용하는 방법으로 변경하는 과정에 대해서 다시 한번 포스팅 하려고 합니다.

      [Circuit Breaker 설정값]

      ※COUNT_BASED 기준 설명입니다.

     sliding window type COUNT_BASED 또는 TIME_BASED(default: COUNT_BASED)
     minimum number of calls 무조건 CLOSED로 가정하고 호출하는 최소 횟수(default: 100)
     sliding window size

    서킷 브레이커의 상태를 판단하기 위한 전체 호출 횟수(default: 100) 
    * minimum number of calls 이후로 서킷 브레이커의 상태를 판단하기 위한 기준
     failure rate threshold

    백분율로 나타낸 실패 임계치. sliding window size에 대해서 record exceptions로 인해 발생하는 실패의 비율이 해당 비율을 넘으면 OPEN(default: 50)
     automatic transition from open to half open enable



    true이면 wait duration in open state이 지난 후 자동으로 OPEN → HALF-OPEN 상태 전이, false이면 wait duration in open state가 경과 후에도 호출이 이루어진 경우에만 HALF-OPEN 상태로 전이(default: false)
    * false 설정의 장점: 스레드가 모든 서킷 브레이커의 상태를 모니터링 하지 않아 리소스 절약 가능
     wait duration in open state

    automatic transitionfrom open to half open enabled값이 true일 때 OPEN → HALF-OPEN 상태 전이에 소요되는 시간(default: 60000[ms])
     max wait duration in half open state


    HALF-OPEN → OPEN 상태 전이 전 HALF-OPEN 상태로 유지될 수 있는 최대 대기 시간(default: 0[ms])
    *해당 값이 0이면 허용된 모든 호출 완료 시까지 HALF-OPEN 상태에서 무한정 대기
     permitted number of calls in half open state


    HALF-OPEN 상태에서 OPEN 또는 CLOSED 상태 전이 판단을 위해 호출하는 횟수(default: 10)
    * 해당 값만큼 실행 결과 실패 임계치에 도달하면 OPEN, 실패 임계치 미만 상태 유지하면 CLOSED 상태로 전이
     slow call duration threshold

    호출에 소요되는 시간이 해당 값보다 길면 실패로 기록하고 느린 호출로 판단(default: 60000[ms])
     slow call rate threshold sliding window size에 대한 slow call 비율이 해당 비율을 넘으면 OPEN(default: 100)
     record exceptions 실패로 판단하는 Exception(default: emtpy)
     ignore exceptions 실패나 성공으로 판단하지 않는 Exception(default: emtpy)
     record failure predicate

    예외가 실패로 기록되어야 하는 경우 true, 예외가 성공으로 간주되어야 하는 경우 false를 반환하는 사용자 정의 조건자(default: throwable  true)
     ignore exception predicate

    예외를 무시해야 하는 경우 true, 예외가 실패로 간주되어야 하는 경우 false를 반환하는 사용자 정의 조건자(default: throwable → false)
     event consumer buffer size actuator를 위한 이벤트 버퍼 사이즈

     

      아래와 같이 CircuitBreakerConfiguration.kt를 작성했습니다. circuitBreakerConfig 함수는 CircuitBreaker의 구성을 설정하는 데 사용됩니다. CircuitBreakerConfig.custom 함수를 호출하여 새로운 CircuitBreakerConfig Builder를 생성함으로써 CircuitBreaker의 동작을 정의합니다. 저는 슬라이딩 윈도우 사이즈 10, 실패 임계치 30%, 최소 호출 수 3회, OPEN →  HALF-OPEN 자동 전환 시간 1초, OPEN 또는 CLOSED를 판단하기 위한 호출 횟수, OPEN → HALF-OPEN으로의 자동 전이 여부는 참으로 설정했습니다. 

     

      다음으로 circuitBreakerRegistry 함수는 CircuitBreakerConfig를 사용하여 CircuitBreakerRegistry를 생성하는데, 이 레지스트리는 어플리케이션에서 사용될 CircuitBreaker의 인스턴스를 생성 및 검색하는 역할을 합니다. 마지막으로 circuitBreaker 함수는 CircuitBreakerRegistry를 사용하여 실제 CircuitBreaker를 생성하고, 이를 위해 레지스트리에서 가져온 구성을 사용합니다. 서킷 브레이커의 이름을 myCircuitBreaker로 지정했기 때문에 서비스 로직에서 해당 빈을 사용하려면 이름을 'myCircuitBreaker'로 명시해줘야 합니다. 

    //CircuitBreakerConfiguration.kt
    
    package com.hh2.katj.config
    
    import io.github.resilience4j.circuitbreaker.CircuitBreaker
    import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig
    import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
    import org.springframework.context.annotation.Bean
    import org.springframework.context.annotation.Configuration
    
    @Configuration
    class CircuitBreakerConfiguration {
        @Bean
        fun circuitBreakerConfig(): CircuitBreakerConfig {
            return CircuitBreakerConfig.custom()
                .slidingWindowSize(10)
                .failureRateThreshold(30.0F)
                .minimumNumberOfCalls(3)
                .waitDurationInOpenState(java.time.Duration.ofSeconds(1))
                .permittedNumberOfCallsInHalfOpenState(3)
                .automaticTransitionFromOpenToHalfOpenEnabled(true)
                .build()
        }
    
        @Bean
        fun circuitBreakerRegistry(circuitBreakerConfig: CircuitBreakerConfig): CircuitBreakerRegistry {
            return CircuitBreakerRegistry.of(circuitBreakerConfig)
        }
        @Bean
        fun circuitBreaker(circuitBreakerRegistry: CircuitBreakerRegistry): CircuitBreaker {
            return CircuitBreaker.of("myCircuitBreaker", circuitBreakerConfig())
        }
    }

     

    [Retry 설정값]

     max attempts 최대 시도 횟수(초기 호출을 첫 번째 시도에 포함)(default: 3)
     wait duration 시도 사이의 대기 시간(default: 500[ms])
     interval function


    실패 후 대기 간격을 수정하는 사용자 정의 Interval 함수(default: num of attempts  wait duration)
    * 기본적으로 대기시간은 일정하게 유지됨
     interval Bi Function


    시도 횟수와 결과 또는 예외를 기반으로 실패 후 대기 간격을 수정하는 사용자 정의 Interval 함수(default: num of attempts  either<throwable, result> → wait duration )
    * Interval 함수와 함께 사용하면 IllegalStateException 발생
     retry on result predicate

    특정 응답이 재시도해야 하는 경우 true, 그렇지 않으면 false를 반환하는 사용자 정의 조건자(default: result  flase)
     retry exception predicate

    특정 예외가 재시도해야 하는 경우 true, 그렇지 않으면 false를 반환하는 사용자 정의 조건자(default: throwable  true)
     retry exceptions

    실패로 기록해서 재시도하기 위한 Throwable 클래스 목록(default: empty)
    * Checked Exception을 사용하는 경우 CheckedSupplier를 사용해야 함
     ignore exceptions 무시하고 재시도하지 않아야 하는 Throwable 클래스 목록(default: empty)
     fail after max attempts

    재시도 횟수가 max attempts에 도달했지만 결과가 여전히 retry on result predicate를 전달하지 않은 경우 max retries exceeded exception발생을 활성화 할지 여부(default: false)

     

      RetryConfiguration.kt은 아래와 같이 작성했는데 상태코드 500인 경우에 네트워크의 일시적인 장애로 간주하고 retry를 한 번 더 할 수 있도록 설정했습니다. 위에서 Retry 설정값에 대해 설명했듯이 max attempts는 초기 호출을 첫 번째 시도에 포함하므로 max attempts값이 2라면 실패 시 retry는 두 번이 아닌 한 번 하게 됩니다.

     

      decorateSupplier 함수는 Resilience4j의 Retry.decorateSupplier 함수를 호출하는 래퍼 함수입니다. 이 함수는 첫 번째 매개변수로 전달된 Retry 객체를 가지고 두 번째 매개변수 Supplier 함수를 래핑함으로써 Retry가 적용된 Supplier 함수를 반환합니다. 즉, @Retry의 구현입니다.

     

      참고로 Supplier는 파라미터를 받지 않으므로 decorateSupplier 함수 호출 시 supplier를 명시적으로 전달하지 않아도 됩니다. 또, Supplier는 함수형 인터페이스로, 반드시 하나의 추상 메서드만을 가지므로 람다 표현식을 사용할 수 있습니다. decorateSupplier 메소드의 쓰임과 구현 이유에 대해서는 아래 서비스 로직에서 더 자세히 설명하도록 하겠습니다. 

    //RetryConfiguration.kt
    
    package com.hh2.katj.config
    
    import com.hh2.katj.history.model.dto.KakaoAddressSearchResponse
    import io.github.resilience4j.retry.Retry
    import io.github.resilience4j.retry.RetryConfig
    import io.github.resilience4j.retry.RetryRegistry
    import org.springframework.context.annotation.Bean
    import org.springframework.context.annotation.Configuration
    import org.springframework.http.ResponseEntity
    import java.util.function.Supplier
    
    
    @Configuration
    class RetryConfiguration {
    
        @Bean
        fun retryConfig(): RetryConfig {
            return RetryConfig.custom<ResponseEntity<KakaoAddressSearchResponse>>()
                .maxAttempts(2)
                .waitDuration(java.time.Duration.ofMillis(100))
                .retryOnResult { response: ResponseEntity<KakaoAddressSearchResponse> -> response.statusCode.value() == 500 }
                .build()
        }
    
        @Bean
        fun retryRegistry(retryConfig: RetryConfig): RetryRegistry {
            return RetryRegistry.of(retryConfig)
        }
    
        @Bean
        fun retry(retryRegistry: RetryRegistry): Retry {
            return Retry.of("myRetry", retryConfig())
        }
    
        fun <T> decorateSupplier(retry: Retry, supplier: Supplier<T>): Supplier<T> {
            return Retry.decorateSupplier(retry, supplier)
        }
    
    }

     

    서비스 로직 작성

      아래 코드에서 람다 함수 '{ kakaoApiManager.requestAddressSearch(keyword) }'는 Supplier 인터페이스의 추상 메서드를 오버라이드한 것입니다. 이로써 Retry가 적용된 kakaoApiManager.requestAddressSearch(keyword)의 반환 결과가 addressSearchWithRetry 변수에 할당됩니다. 

     

      만약 kakaoApiManager.requestAddressSearch 함수에 @Retry 어노테이션을 사용했다면 더 간단히 Retry를 적용할 수 있었을 것입니다. 그럼에도 굳이 decorateSupplier 함수를 정의하고 kakaoApiManager.requestAddressSearch함수에 대한 Retry 적용을 람다함수 형태로 구현한 이유는 외부 서비스 호출 로직의 내결함성 패턴을 Retry와 Circuit Breaker를 조합하여 구성했음을 명시적으로 나타내고 싶었기 때문입니다. 

     

      kakaoApiManager.requestAddressSearch 함수 실행 결과 응답 값이 500인 경우에는 1회 더 재시도 하게 됩니다. 응답값이 없으면 서킷 브레이커에 실패를 기록하며, 실패 임계치에 도달하면 서킷 브레이커가 OPEN되어 fallback 메소드가 실행되고 naverAPI에 요청하게 되는 구조입니다. 참고로 Fallback 메소드는 Circuit Breaker 메소드와 동일한 클래스에 속해야 하며, CircuitBreaker와 같은 파라미터에 예외 파라미터를 추가로 가져야 합니다. 

    package com.hh2.katj.history.service
    
    import com.hh2.katj.config.RetryConfiguration
    import com.hh2.katj.history.component.KakaoApiManager
    import com.hh2.katj.history.component.LocationHistoryManager
    import com.hh2.katj.history.model.dto.ResponseLocationHistory
    import com.hh2.katj.user.model.entity.User
    import com.hh2.katj.util.exception.ExceptionMessage
    import com.hh2.katj.util.exception.failWithMessage
    import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
    import io.github.resilience4j.retry.annotation.Retry
    import org.springframework.beans.factory.annotation.Autowired
    import org.springframework.http.ResponseEntity
    import org.springframework.stereotype.Service
    import java.util.concurrent.TimeUnit
    import kotlin.random.Random
    
    @Service
    class LocationHistoryService(
        private val locationHistoryManager: LocationHistoryManager,
        private val kakaoApiManager: KakaoApiManager,
        private val myCircuitBreaker: io.github.resilience4j.circuitbreaker.CircuitBreaker,
        @Autowired private val myRetry: io.github.resilience4j.retry.Retry
    ) {
        @CircuitBreaker(name="myCircuitBreaker")
        fun saveLocationHistory(user: User,
                                keyword: String): ResponseEntity<ResponseLocationHistory> {
    
            val addressSearchWithRetry = RetryConfiguration().decorateSupplier(myRetry) {
                kakaoApiManager.requestAddressSearch(keyword)
            }
            val response = addressSearchWithRetry.get()
            val responseBody = response?.body
            try {
                checkNotNull(responseBody) { "api 호출 오류" }
                check(responseBody.documents.isNotEmpty()) {
                failWithMessage(ExceptionMessage.NO_SEARCH_RESULT.name)
                }
            } catch(e: IllegalStateException) {
                myCircuitBreaker.onError(0, TimeUnit.SECONDS, RuntimeException())
                // OPEN 상태에서만 fallback 메서드를 직접 호출
                if (myCircuitBreaker.state == io.github.resilience4j.circuitbreaker.CircuitBreaker.State.OPEN) {
                    return fallbackSaveLocationHistory(user, keyword, faultPercentage, RuntimeException("CircuitBreaker is OPEN"))
                }
            }
    
            myCircuitBreaker.onSuccess(0, TimeUnit.SECONDS)
            val roadAddress = responseBody!!.documents[0].roadAddress
    
            return ResponseEntity.ok(locationHistoryManager.addLocationHistory(user, keyword, roadAddress, faultPercentage).toResponseDto())
        }
    
        fun fallbackSaveLocationHistory(user: User,
                                        keyword: String,
                                        t: Throwable): ResponseEntity<ResponseLocationHistory> {
    
            println("fallback function is acting! -> " + t.message)
            val response = naverApiManager.requestAddressSearch(keyword)?.body
            checkNotNull(response) { "api 호출 오류" }
            check(response.documents.isNotEmpty()) {
                failWithMessage(ExceptionMessage.NO_SEARCH_RESULT.name)
            }
    
            val roadAddress = response.documents[0].roadAddress
            return ResponseEntity.ok(locationHistoryManager.addLocationHistory(user, keyword, roadAddress, faultPercentage).toResponseDto())
        }
    }

     

     

    테스트 코드로 확인하는 서킷 브레이커의 동작

      기존 서비스 로직대로라면 서킷 브레이커에 실패가 기록되기 위해서는 서버 장애로 인해 응답이 실패해야합니다. 이는 구현하기 어려우므로 테스트를 용이하게 하기 위해 faultPercentage값을 100으로 받으면 실패하도록 서비스 로직을 수정했습니다. 아래와 같이 /location 엔드포인트에 faultPercentage값을 100으로 설정해서 POST하면 컨트롤러는 userId, keyword, faultPercentage를 파라미터로 받아 서비스 메소드를 호출합니다.

     

      하지만 이렇게 하면 테스트 환경과 실제 운영 환경 사이의 차이를 만들어내고 실제 서비스의 안전성과 신뢰성을 저해할 수 있게 됩니다. 따라서 추후 Mock 서버를 만들어 다시 테스트를 진행해보려고 합니다. 

    서비스 실패 테스트를 위해 body에 faultPercentage값 100으로 요청

     

    package com.hh2.katj.history.service
    
    import com.hh2.katj.config.RetryConfiguration
    import com.hh2.katj.history.component.KakaoApiManager
    import com.hh2.katj.history.component.LocationHistoryManager
    import com.hh2.katj.history.model.dto.ResponseLocationHistory
    import com.hh2.katj.user.model.entity.User
    import com.hh2.katj.util.exception.ExceptionMessage
    import com.hh2.katj.util.exception.failWithMessage
    import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
    import io.github.resilience4j.retry.annotation.Retry
    import org.springframework.beans.factory.annotation.Autowired
    import org.springframework.http.ResponseEntity
    import org.springframework.stereotype.Service
    import java.util.concurrent.TimeUnit
    import kotlin.random.Random
    
    @Service
    class LocationHistoryService(
        private val locationHistoryManager: LocationHistoryManager,
        private val kakaoApiManager: KakaoApiManager,
        @Autowired private val myCircuitBreaker: io.github.resilience4j.circuitbreaker.CircuitBreaker,
        @Autowired private val myRetry: io.github.resilience4j.retry.Retry
    ) {
        @Retry(name="saveLocationHistory")
        @CircuitBreaker(name="saveLocationHistory")
        fun saveLocationHistory(user: User,
                                keyword: String,
                                faultPercentage: Int): ResponseEntity<ResponseLocationHistory> {
    
            val addressSearchWithRetry = RetryConfiguration().decorateSupplier(myRetry) {
                kakaoApiManager.requestAddressSearch(keyword)
            }
            val response = addressSearchWithRetry.get()
            val responseBody = response?.body
            val random = Random.nextInt(0, 100)
            if (faultPercentage > random) {
                myCircuitBreaker.onError(0, TimeUnit.SECONDS, RuntimeException())
                // OPEN 상태에서만 fallback 메서드를 직접 호출
                if (myCircuitBreaker.state == io.github.resilience4j.circuitbreaker.CircuitBreaker.State.OPEN) {
                    return fallbackSaveLocationHistory(user, keyword, faultPercentage, RuntimeException("CircuitBreaker is OPEN"))
                }
            }
    
            myCircuitBreaker.onSuccess(0, TimeUnit.SECONDS)
            val roadAddress = responseBody!!.documents[0].roadAddress
    
            return ResponseEntity.ok(locationHistoryManager.addLocationHistory(user, keyword, roadAddress, faultPercentage).toResponseDto())
        }
    
        fun fallbackSaveLocationHistory(user: User,
                                        keyword: String,
                                        faultPercentage: Int,
                                        t: Throwable): ResponseEntity<ResponseLocationHistory> {
    
            println("fallback function is acting! -> " + t.message)
            val response = naverApiManager.requestAddressSearch(keyword)?.body
            checkNotNull(response) { "api 호출 오류" }
            check(response.documents.isNotEmpty()) {
                failWithMessage(ExceptionMessage.NO_SEARCH_RESULT.name)
            }
    
            val roadAddress = response.documents[0].roadAddress
            return ResponseEntity.ok(locationHistoryManager.addLocationHistory(user, keyword, roadAddress, faultPercentage).toResponseDto())
        }
    }

     

      스프링부트는 main, test 구분없이 빈을 등록하므로 테스트를 위한 빈의 이름을 메인 함수에 주입했던 빈의 이름과 다르게 명시해서 주입했습니다. sliding window size는 10, failure rate threshold는 30%, minimum number of calls는 3, automatic transition from open to half open enabled는 OPEN → HALF-OPEN 자동 전이되도록 true, wait duration in open state는 1초, permitted number of calls in half open state는 3회로 설정했습니다.

     

      HALF-OPEN → CLOSED 상태로 전이되기 위해서는 permmitted number of calls in half open state 횟수만큼 호출하는 동안 실패가 없어야 합니다. minimum number of calls는 무조건 CLOSED로 간주하고 호출하는 횟수로, minimum number of calls 이후부터 실패율을 계산하기 시작합니다. 만약 2번 실패하면 실패율은 '실패 call 횟수/전체 call 횟수' 이기 때문에 2/3이 되어 약 66%로 계산됩니다.

    @TestConfiguration
    class TestConfig {
        @Bean
        fun testCircuitBreakerConfig(): CircuitBreakerConfig {
            return CircuitBreakerConfig.custom()
                .slidingWindowSize(10) // CLOSED 상태에서의 실패율 기록 단위
                .failureRateThreshold(30.0F) // 실패율 임계치
                .minimumNumberOfCalls(3) // 실패율 계산을 위한 최소 call 횟수
                .automaticTransitionFromOpenToHalfOpenEnabled(true) // OPEN -> HALF-OPEN 자동 전환 여부
                .waitDurationInOpenState(java.time.Duration.ofSeconds(1)) // OPEN -> HALF-OPEN 전환 시간
                .permittedNumberOfCallsInHalfOpenState(3) // HALF-OPEN 상태에서 최대로 허용되는 call 횟수
                .build()
        }
    
        @Bean
        fun testCircuitBreakerRegistry(circuitBreakerConfig: CircuitBreakerConfig): CircuitBreakerRegistry {
            return CircuitBreakerRegistry.of(circuitBreakerConfig)
        }
    
        @Primary
        @Bean
        fun testCircuitBreaker(circuitBreakerRegistry: CircuitBreakerRegistry): CircuitBreaker {
            return CircuitBreaker.of("testCircuitBreaker", testCircuitBreakerConfig())
        }
    }

     

      테스트 코드는 크게 CLOSED, OPEN, HALF-OPEN 세 가지 상태가 예상한대로 전이되는지 확인하기 위해 작성했습니다. CLOSED 상태에서는 한 번의 성공으로 CLOSED 상태를 유지하는지, 임계치 달성 직전 성공해서 CLOSED 상태를 유지하는지 확인했습니다. OPEN 상태에서는 임계치에 도달하면 서킷 브레이커가 OPEN되는지 확인했습니다.

     

      마지막으로 HALF-OPEN 상태에서는 실패 임계치에 도달해서 wait duration in open state가 경과 하면 OPEN에서 HALF-OPEN 상태로 전이되는지, HALF-OPEN 상태에서 임계치에 도달하면 다시 OPEN 상태로 전이되가 되는지, HALF-OPEN 상태에서 permmitted number of calls in half open state동안 임계치 미만 상태를 유지하면 CLOSED 상태로 전이되는지 확인했습니다.

    @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    @ContextConfiguration(classes = [TestConfig::class])
    class CircuitBreakerTest @Autowired constructor(
        private val locationHistoryRepository: LocationHistoryRepository,
        private val locationHistoryService: LocationHistoryService,
        private val userRepository: UserRepository,
        @Autowired(required = false) private val testCircuitBreaker: CircuitBreaker
    ): BaseTestEntity() {
        @BeforeEach
        fun setUp() {
            testCircuitBreaker.reset()
        }
    
        @AfterEach
        fun tearUp() {
            userRepository.deleteAllInBatch()
            locationHistoryRepository.deleteAllInBatch()
        }
    
        // CLOSED
        @Test
        fun `한 번의 호출로 성공`() {
            // given: user, keyword
            val user = initUser()
            val keyword = "서울 서대문구 모래내로 412"
    
            // when: 성공
            val saveUser = userRepository.save(user)
            locationHistoryService.saveLocationHistory(saveUser, keyword)
    
            // then: CLOSED
            Assertions.assertThat(testCircuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
        }
    
        @Test
        fun `임계점 초과 직전 성공`() {
            // given: user, keyword
            val user = initUser()
            val keyword = "서울 서대문구 모래내로 412"
    
            // when: 실패율 임계치 30% 미달 (window size:10, call 3회부터 실패율 계산)
            val saveUser = userRepository.save(user)
            locationHistoryService.saveLocationHistory(saveUser, keyword)
    
            // then: CLOSED
            Assertions.assertThat(testCircuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
        }
    
        // OPEN
        @Test
        fun `임계점 달성으로 인한 회로 OPEN`() {
            // given: user, keyword
            val user = initUser()
            val keyword = "서울 서대문구 모래내로 412"
    
            // when: 실패율 임계치 30% 달성 (window size:10, call 3회부터 실패율 계산)
            val saveUser = userRepository.save(user)
            for (i in 1..3) {
                locationHistoryService.saveLocationHistory(saveUser, keyword)
            }
    
            // then: OPEN & retry 수행
            Assertions.assertThat(testCircuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
        }
    
        // HALF-OPEN
        @Test
        fun `임계점을 달성하여 회로 OPEN 후 1초가 지나면 HALF-OPEN`() {
            // given: user, keyword
            val user = initUser()
            val keyword = "서울 서대문구 모래내로 412"
    
            // when: 실패율 임계치 30% 달성 후 1초 경과 (wait duration in open state: 1)
            val saveUser = userRepository.save(user)
            for (i in 1..3) {
                locationHistoryService.saveLocationHistory(saveUser, keyword)
            }
            Thread.sleep(1000)
    
            // then
            Assertions.assertThat(testCircuitBreaker.state).isEqualTo(CircuitBreaker.State.HALF_OPEN)
        }
    
        @Test
        fun `HALF-OPEN 상태에서 3번의 요청 실패 시 OPEN`() {
            // given: user, keyword
            val user = initUser()
            val keyword = "서울 서대문구 모래내로 412"
    
            // when: HALF-OPEN 상태에서 3번 실패 (실패율 임계치와 동일 적용)
            val saveUser = userRepository.save(user)
            for (i in 1..3) {
                locationHistoryService.saveLocationHistory(saveUser, keyword)
            }
            Thread.sleep(1000)
            for (i in 1..3) {
                locationHistoryService.saveLocationHistory(saveUser, keyword)
            }
    
            // then: OPEN
            Assertions.assertThat(testCircuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
        }
    
        @Test
        fun `HALF-OPEN 상태에서 3번의 요청 성공 시 CLOSED`() {
            // given: user, keyword
            val user = initUser()
            val keyword = "서울 서대문구 모래내로 412"
    
            // when: HALF-OPEN 상태에서 permitted number of calls in half open state 동안 모두 성공
            val saveUser = userRepository.save(user)
            for (i in 1..3) {
                locationHistoryService.saveLocationHistory(saveUser, keyword)
            }
            Thread.sleep(1000)
            for (i in 1..3) {
                locationHistoryService.saveLocationHistory(saveUser, keyword)
            }
    
            // then: CLOSED
            Assertions.assertThat(testCircuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
        }
    
        private fun initUser(): User {
            val roadAddress = RoadAddress(
                addressName = "서울 서대문구 모래내로 412",
                buildingName = "서울시립서대문도서관",
                mainBuildingNo = "412",
                region1depthName = "서울",
                region2depthName = "서대문구",
                region3depthName = "연희동",
                roadName = "모래내로",
                subBuildingNo = "",
                undergroundYn = "N",
                longitude = "127.038836506223",
                latitude = "37.556198526017",
                zoneNo = "03717",
            )
    
            return User(
                name = "안유진",
                phoneNumber = "01012345678",
                email = "email@naver.com",
                gender = Gender.FEMALE,
                status = UserStatus.ACTIVE,
                roadAddress = roadAddress,
            )
        }
    }

     

     

     

    마무리

      지금까지 고가용성 서비스 제공을 위해 기존의 재시도 로직에 서킷 브레이커 패턴을 적용한 과정을 정리해봤습니다. 두 가지의 내결함성 패턴을 조합함으로써 보다 체계적인 설계 방식에 대해 고민해봤습니다. 서킷 브레이커 외에도 서비스의 안정성과 가용성을 향상시킬 수 있는 패턴들을 조사해보고, 테스트 코드를 통해 서킷 브레이커가 생각했던대로 동작하는지까지 확인해 보았습니다. 아래와 같이 새롭게 알게 된 사실들과 개선해야할 점들을 정리하며 글을 마무리하겠습니다. 

     

    알게된 점

    1. 고가용성 시스템은 SLA(Service Level Agreement)에서 계약된 수준 이상의 가용성 보장, 트래픽 폭증 시 자원 확장 가능성, 오류 발생 시 회복 능력을 가진 시스템을 말하며, 고가용성은 어플리케이션 종류와 비즈니스 요구사항에 따라 정해지는 '상대적 개념'으로, 절대적인 수치가 아니다.
    2. 고가용성 시스템 설계 시 발생 가능한 장애 상황을 상정한 후 유즈 케이스에 맞게 '여러 가지 내결함성 패턴을 조합'해서 사용할 수 있다.
    3. 서킷 브레이커는 메소드가 정상 때 CLOSED 상태, 비정상일 때 OPEN 상태, OPEN 상태에서 일정 시간이 흐르면 다시 CLOSED와 OPEN을 판단하는 HALF-OPEN 상태로 순환하는 구조를 통해 신속히 실패를 처리함으로써 시스템의 내결함성을 향상시킨다. 
    4. WireMock 등의 API Mocking 라이브러리를 사용하면 모의 서버가 실제 서버의 네트워크 요청을 가로채서 모의 응답을 보내준다. 

    개선할 점

    1. 서버 응답 실패 테스트의 용이성을 위해 서비스 로직을 수정했는데 원상복구하고 서버 장애로 인한 응답 실패를 반환하는 Mock 서버 생성해서 다시 테스트해보기 ✅ (참고: 2024.04.05 - [WireMock] 외부 API 의존성 함수에 대한 테스트 코드 리팩토링 - 모의 API 서버 활용)
    2. circuit breaker와 retry에 대한 인스턴스를 생성하는 설정파일을 test, develop 각각 사용하는 대신에 application.yml에서 circuit breaker와 retry에 대해 test, develop 인스턴스별로 설정해서 사용해보기
    3. Bulkhead, Rate Limiter 적용해보기 
     
     

     

    참고:

    https://resilience4j.readme.io/docs/getting-started

     

    Introduction

    Resilience4j is a lightweight fault tolerance library designed for functional programming. Resilience4j provides higher-order functions (decorators) to enhance any functional interface, lambda expression or method reference with a Circuit Breaker, Rate Lim

    resilience4j.readme.io

    https://bottom-to-top.tistory.com/57

     

    Resilience4j CircuitBreaker 사용하기

    들어가며 Resilience4j는 넷플릭스의 히스트릭스에 영감을 받아 개발된 경량화 Fault Tolerance 라이브러리이다. 그 중 Circuit Breaker(이하 서킷브레이커)를 분석하고 적용하였다. 서킷브레이커의 상태 서

    bottom-to-top.tistory.com

    https://cheese10yun.github.io/resilience4j-basic/

     

    Resilience4j를 이용한 서킷 브레이커 기초 - Yun Blog | 기술 블로그

    Resilience4j를 이용한 서킷 브레이커 기초 - Yun Blog | 기술 블로그

    cheese10yun.github.io

    https://mangkyu.tistory.com/290

     

    [Spring] RestTemplate에 Resilence4J 서킷 브레이커 적용하는 방법과 예시

    이번에는 Java 진영의 서킷브레이커 라이브러리인 Resilence4J를 RestTemplate에 적용하는 방법에 대해 알아보도록 하겠습니다. 1. Resilence4J 라이브러리와 구성 요소 [ Resilience4J란? ] Resilience4J는 함수형

    mangkyu.tistory.com

    https://bkjeon1614.tistory.com/712

     

    서킷브레이커(=Circuitbreaker) Resilience4j 적용 (Java + Spring Boot) 2편

    해당 포스팅은 1편인 이전 포스팅인 https://bkjeon1614.tistory.com/711을 참고하여 사전작업 후 진행하는것이 좋다. (모니터링 하는 방법에 대해서만 설명이 나오기 때문) 서킷브레이커 테스트를 위한

    bkjeon1614.tistory.com

    https://repo1.maven.org/maven2/io/github/resilience4j/

     

    Central Repository: io/github/resilience4j

     

    repo1.maven.org

     

    https://velog.io/@akfls221/resilience4j-%EB%A1%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%84%9C%ED%82%B7%EB%B8%8C%EB%A0%88%EC%9D%B4%EC%BB%A4%ED%8C%A8%ED%84%B4CircuitBreaker

     

    resilience4j 로 알아보는 서킷브레이커패턴(CircuitBreaker )

    resilience4j 로 알아보는 서킷브레이커패턴(CircuitBreaker ) 마이크로 서비스로 분할되는 패턴환경 에서의 내결함성(Fault Tolerance)을 위한 서킷브레이커 패턴을 알아보자

    velog.io

    https://www.baeldung.com/spring-boot-resilience4j

    https://dzone.com/articles/a-new-era-of-spring-cloud

     

    A New Era Of Spring Cloud - DZone

    The main goal of this article is to guide you through building microservices architecture with new Spring Cloud components without Netflix projects.

    dzone.com

    https://d2.naver.com/helloworld/6070967

     

    댓글

Designed by Tistory.