Channel Gateway에 CircuitBreaker 적용

들어가기에 앞서

Circuit Breaker에 대한 소개 블로그를 읽지 않았다면 먼저 읽어보시길 권장합니다.

Channel Gateway에 CircuitBreaker 적용하기

Channel Gateway 서버에서는 LINE의 다양한 서버들의 기능을 CP(Contents Provider)들에게 제공하는 역할을 맡고 있습니다. 그러다보니 Channel Gateway 서버들은 연결된 서버들에 영향을 많이 받게 됩니다. 그리고, 그러한 영향들은 쉽게 전체 Channel Gateway 서버들에게 전파됩니다.

이 문제를 해결하기 위하여 고민하던 중에 CircuitBreaker에 관한 내용을 들었습니다. 특정 서버에서 장애가 발생할 경우 CircuitBreaker가 이를 감지하고 그 서버로 요청하는 호출을 차단하면 문제를 충분히 해결할 수 있다고 생각했습니다. 그래서 Channel Gateway에 CircuitBreaker를 적용하기로 했습니다.

Channel Gateway를 위한 CircuitBreaker를 직접 구현할 수도 있었지만, Armeria에는 CircuitBreaker가 훌륭하게 만들어져 있었습니다. Armeria의 CircuitBreaker는 여러 가지 옵션들을 원하는 대로 설정하여 CircuitBreakerBuilder를 통해 구현된 CircuitBreaker 객체를 얻을 수 있습니다. 이 객체를 통하여 Channel Gateway에 맞게 커스터마이즈할 수 있게 구현되어 있어서 어렵지 않게 적용할 수 있었습니다.

CircuitBreaker의 애너테이션 사용

talk-channel-gateway 소스코드에서 CircuitBreaker는 @CircuitBreakable 애너테이션을 사용하여 적용할 수 있습니다.

@CircuitBreakable(CircuitBreakerGroup.HBASE_CLIENT_USER_SETTINGS)
public ChannelSettings findBy(String mid) {
    ...
}  

위와 같이 적용한 경우, findBy() 메서드가 호출될 때마다 성공/실패를 감시하고, 그 결과에 따라 CircuitBreaker가 열리거나 닫히게 됩니다. HBASE_CLIENT_USER_SETTINGS는 CircuitBreaker로 묶일 그룹을 지정하는 것으로서, 메서드들의 실패율을 계산할 때도 그룹 전체의 호출 회수 대비 실패 회수로 계산하고, CircuitBreaker가 열리거나 닫힐 때도 함께 열리거나 닫히게 됩니다. CircuitBreaker의 설정은 CircuitBreakerGroup이라는 enum 객체에서 다음과 같이 설정할 수 있습니다.

public enum CircuitBreakerGroup implements ExceptionFilter {

    SAMPLE_DEFAULT {
    },
    SAMPLE_API {
        @Override
        protected ExceptionFilter exceptionFilter() {
            return cause -> !(cause instanceof AuthenticationException
                              || cause instanceof ApiPermissionException
                              || cause instanceof ImproperRequestException);
        }
    },
    HBASE_CLIENT_CHANNEL_MATRIX {
    },
    HBASE_CLIENT_USER_SETTINGS {
    };
    
    protected ExceptionFilter exceptionFilter() {
        return cause -> true;
    }
    
    public CircuitBreaker circuitBreaker(CircuitBreakerListener listener) {
        return new CircuitBreakerBuilder(name()).exceptionFilter(exceptionFilter())
                                                .listener(listener)
                                                .build();
    }
    
    @Override
    public boolean shouldDealWith(Throwable throwable) throws Exception {
        return exceptionFilter().shouldDealWith(throwable);
    }
}   

Channel Gateway에서는 ExceptionFilter를 커스터마이즈하여 사용하였습니다. 그 외의 옵션들은 Armeria의 기본 옵션을 그대로 사용하였습니다. 만약, 다른 옵션들을 변경하여 사용하고 싶다면 circuitBreaker() 메서드를 수정하여 사용할 수 있을 것입니다.

Armeria에서는 기본적으로 어떠한 예외라도 발생할 경우 실패로 간주하도록 되어 있습니다. 하지만, Channel Gateway에서는 권한이 없는 경우 등을 예외로 처리하여 반환해주기 때문에 이를 구분할 필요가 있었습니다. 그래서, ExceptionFilter를 커스터마이즈하여 사용하였습니다. 그리고, CircuitBreaker의 상태가 변할 때마다 로그를 쌓을 수 있도록 Channel Gateway를 위한 이벤트 리스너도 추가하였습니다.

애너테이션에 적용하는 그룹은 enum 객체로 지정할 수 있으며, enum 객체 구현 부분에서 그룹별로 다른 설정을 할 수 있도록 하였습니다.

CircuitBreaker에서 proceed() 구현

Aspect 객체의 proceed() 코드는 다음과 같습니다.

public class CircuitBreakerAspect implements Ordered {
    
        private final Map<CircuitBreakerGroup, CircuitBreaker> circuitBreakers = 
                new EnumMap<>(CircuitBreakerGroup.class);
    
        @PostConstruct
        public void initialize() {
    
            final CircuitBreakerListener listener = 
            new CircuitBreakerListenerImpl(circuitBreakerLogger);
            for (CircuitBreakerGroup group : CircuitBreakerGroup.values()) {
                circuitBreakers.put(group, group.circuitBreaker(listener));
            }
        }
    
        public Object proceed(final ProceedingJoinPoint pjp, 
               final CircuitBreakable circuitBreakable) throws Throwable {
    
            final CircuitBreakerGroup group = circuitBreakable.value();
            final CircuitBreaker circuitBreaker = circuitBreakers.get(group);
    
            if (circuitBreaker.canRequest()) {
                final Object result;
    
                try {
                    result = pjp.proceed();
                } catch (Throwable e) {
                    if (group.shouldDealWith(e)) {
                        circuitBreaker.onFailure(e);
                    } else {
                        circuitBreaker.onSuccess();
                    }
                    throw e;
                }
    
                circuitBreaker.onSuccess();
                return result;
            } else {
                throw CircuitBreakerException.circuitBroken();
            }
        }
    } 

코드는 비교적 간단합니다. CircuitBreaker.canRequest()를 통해 CircuitBreaker가 열려 있다면 예외를 발생시킵니다. 그렇지 않다면 메서드를 정상적으로 호출합니다. 호출한 결과 예외가 발생했고, 그 예외가 실패로 처리해야 하는 경우라면 CircuitBreaker에게 실패했다고 알려줍니다. 그렇지 않다면 CircuitBreaker에게 성공했다고 알려주기만 하면 됩니다. 참고로 실제 적용된 코드에는 CircuitBreaker가 잘 적용되었는지 확인하기 위한 IMON Logger1와 관련된 코드가 들어있습니다. 하지만, 여기에서는 CircuitBreaker 부분에 집중할 수 있도록 생략하였습니다.

1: IMON Logger: IMON은 LINE 사내의 다양한 서비스들을 모니터링하기 위한 시스템으로, IMON Logger는 IMON으로 대상 서비스의 통계지표 및 로그들을 취합하여 전송하는 기능을 하고 있다.

CircuitBreaker의 설정 변경 메서드

CircuitBreaker 객체를 만들어주는 CircuitBreakerBuilder에는 CircuitBreaker의 설정을 변경할 수 있는 여러 가지 메서드를 제공합니다. 가능하면 기본 설정 그대로 사용하는 것을 추천하지만 CircuitBreaker에 대해서 좀 더 이해하기 쉽도록 설정들에 대해서 설명드릴까 합니다.

메서드 파라미터 기본값 설명
failureRateThreshold double 0.8 CircuitBreaker를 열지 말지 판단할 때 사용하기 위한 실패율입니다. counterSlidingWindow 시간 동안의 실패율이 이 값보다 높을 경우 CircuitBreaker가 열리게 됩니다. 기본값 그대로 사용할 경우 80% 이상 실패하게 되면 CircuitBreaker가 열리게 됩니다. 다시 말해 성공하는 비율이 20% 미만이면 CircuitBreaker가 열리게 됩니다.
minimumRequestThreshold long 10 CircuitBreaker를 열지 말지 판단할 최소의 호출 회수입니다. counterSlidingWindow 시간 동안의 호출 회수가 이 값 미만일 때는 CircuitBreaker를 열지 말지 판단하지 않습니다.
circuitOpenWindow Duration 10초 CircuitBreaker가 Open 상태로 바뀐 후 Half-Open 상태로 바뀌기까지의 시간입니다. CircuitBreaker가 열린 후에는 circuitOpenWindow 시간만큼 흐른 후에 Half-Open 상태로 바뀌어서 호출을 테스트하게 됩니다.
circuitOpenWindowMillis long 10000
trialRequestInterval Duration 3초 Half-Open 상태에서 요청한 호출에 Closed응답이 없을 경우, 호출을 재시도하기 위해 기다리는 시간입니다. trialRequestInterval 시간 안에 응답이 올 경우에는 그 결과에 따라서 Closed나 Open 상태로 바뀌게 됩니다. 만약, Open 상태로 바뀌었다면 다시 circuitOpenWindow 시간만큼 기다렸다가 호출을 테스트하게 됩니다. 하지만, 요청한 호출이 trialRequestInterval 시간 동안 어느 응답도 오지 않는다면 다시 호출을 시도하게 됩니다.
trialRequestIntervalMillis long 3000
counterSlidingWindow Duration 20초 CircuitBreaker를 열지 말지 판단할 때 최근 counterSlidngWindow 시간만큼의 기록을 가지고 판단합니다. 기본값 그대로 사용하게 되면 최근 20초 동안의 호출 결과들만으로 판단하게 됩니다.
counterSlidingWindowMillis long 20000
counterUpdateInterval Duration 1초 CircuitBreaker에서는 호출의 결과를 SlidingWindowCounter를 통해서 보관하고 있는데 이때, 보관하는 단위의 시간을 의미합니다. 기본값 그대로 사용하게 되면 1초 단위로 기록을 가지고 있습니다. 예를 들면, 최근 20초 전 = 성공 20회, 실패 0회 / 최근 19초 전 = 성공 25회, 실패 1회 / … / 최근 1초 전 = 성공 21회, 실패 0회 등의 기록을 가지고 있습니다. 이 1초 단위의 기록을 가지고 counterSlidingWindow 시간만큼을 계산하여 실패율을 계산하게 됩니다.
counterUpdateIntervalMillis long 1000
exceptionFilter ExceptionFilter 모든 예외를 실패로 간주 예외가 발생했을 때, 그 예외를 실패로 간주할지 여부를 반환하는 객체입니다. 기본값을 그대로 사용하게 되면 모든 예외를 실패로 간주합니다.
listener CircuitBreakerListener CircuitBreaker의 상태가 바뀌거나, counterUpdateInterval 시간이 지나거나, CircuitBreaker가 열려서 호출이 거부될 때에 대한 이벤트를 수신할 수 있는 리스너입니다.

마무리하며

예전에는 Channel Gateway의 어떤 부분에 장애가 발생한다면 손쓰기 전에 너무 큰 피해가 발생하였습니다. 일부에서 시작된 장애가 Thread Full 현상을 발생시키고, 결국 전체 서비스에 영향을 미쳤기 때문입니다. 하지만, 이제는 CircuitBreaker가 그 부분을 차단함으로서 좀 더 여유롭게 대처할 수 있을 것입니다.

마지막으로 위 설정들을 이용하여 CircuitBreaker의 동작 방식을 설명하는 것으로 마무리 지을까 합니다.

  • 처음 CircuitBreaker의 상태는 Closed입니다.
  • Closed : 호출을 수행한 후 exceptionFilter에 따라 다음과 같이 동작합니다.
    • 결과가 성공했다면 성공했다는 기록을 남기고 Closed 상태로 갑니다.
    • 결과가 실패했다면 counterSlidingWindow 시간 동안의 호출 결과를 확인해서
      • minimumRequestThreshold 개수보다 많고, 실패율이 failureRateThreshold 이상이라면 Open 상태로 갑니다.
      • 그렇지 않다면 Closed 상태로 유지됩니다.
  • Open : circuitOpenWindow 시간이 지나면 Half-Open 상태로 바뀌게 됩니다.
  • Half-Open : 처음 들어오는 호출을 수행한 후 exceptionFilter에 따라 다음과 같이 동작합니다.
    • 결과가 성공했다면 Closed 상태로 바뀝니다.
    • 결과가 실패했다면 Open 상태로 바뀝니다.
    • trialRequestInterval 시간 안에 응답이 없다면 Half-Open 상태에서 그 다음 처음으로 들어오는 호출 결과에 따른 분기 처리를 합니다.

저자 소개

신종훈: 저는 귀찮은 일을 싫어합니다. 귀찮은 일을 줄이기 위해서 계속 고민하지요. 그래서, 프로그래밍을 좋아하는데 왠지 일이 더 늘어난 것 같은 느낌은 저만의 느낌이겠지요?

Related Post