티스토리 뷰

 

피할 수 없는 장애의 덫

 MSA 환경에서의 장애 양상

 우리는 장애를 피할 수 없습니다. 아무리 실력이 좋은 소프트웨어 개발자라 할지라도 완전무결한 시스템을 만들 수 없습니다. 물론 우리도 여러 방법으로 장애에 대응하고 있었습니다. 서버를 클러스터링하여 일부 서버에 문제가 발생하더라도 클라이언트는 정상 동작중인 다른 서버를 이용할 수 있습니다. 인프라를 분산 구축하는 것도 이러한 전통적인 장애 대응방식 중 하나입니다. 우리는 서버 클러스터링, 인프라 분산 구축과 같은 기존의 장애 대응 방식은 시스템의 완전한 실패를 대비하여 설계되었다는 것에 주목해야 합니다. 하드웨어 장비에 문제가 생겨 서버에 전원 공급이 안되거나 자연재해로 인해 데이터 센터의 데이터가 유실된 경우라면 이런 기존의 장애 대응 방식으로 우리는 큰 도움을 받을 수 있습니다.

 

기존 Monolithic 구조의 장애 대응 방식

 

 하지만, 여러 서버들이 그물망처럼 얽혀 서로 영향을 주고 받는 MSA에서 더 위험한(빈번한) 실패 시나리오는 완전히 뻗어버린 서버가 아닙니다. 오히려 버벅거리지만 동작은 하는 서버가 더 위험할 수 있습니다. 호출하는 쪽에서는 원격 서비스로부터 응답을 받아야 자신의 동작을 완료할 수 있기에 MSA에서의 호출은 동기식으로 이루어지기 마련입니다. 따라서, 응답에 오랜 시간이 걸리더라도 호출을 중단하지 않기 때문입니다.

 

MSA 환경에서의 장애 양상

 

 위의 그림처럼 서비스 A, B, C가 모여 MSA를 구성하고 있습니다. 서비스 C는 DB에서 데이터를 읽어오는 역할을 하고 있습니다. 지속적으로 DB에서 응답 지연이 발생하고 있는 상황에서 서비스 B가 지속적으로 C를 호출한다면 어떻게 될까요? 결국 C에서 DB에 붙는 커넥션 풀이 고갈되고, B는 C를 반복 호출하면서 스레드 풀을 모두 소진합니다. 서버 A 역시 B의 스레드 풀이 소진되면 장애가 발생할 가능성이 높습니다. 응답이 조금 지연될 뿐 서비스 A, B, C 모두 정상 동작하고 있기에 서버 측에서는 장애 직전까지 문제 상황을 감지하기가 쉽지 않습니다. 고객 입장에서는 서비스 페이지 로딩이 조금씩 느려지다가 어느 순간 에러 페이지가 뜨는 최악의 고객 경험을 하게 됩니다.

 

사소해보이는 지연이 서비스 전체를 멈출 수 있습니다

 

 Monolithic 구조에서는 모듈 간에 호출이 실패하는 경우에 대하여 크게 신경을 쓸 필요가 없었습니다. 시스템이 완전히 실패하지 않는 한, 시스템 내의 모듈 간에 서로의 메서드를 호출하는 것이 실패할 리가 없기 때문입니다. 하지만, 여러 독립적인 서비스들이 서로 API를 호출하여 통신하는 MSA 구조에서는 여러 가지 이유로 호출이 안 되는 경우가 생깁니다. 예를 들어, 네트워크 문제로 http 통신에 문제가 생겨 원격 서비스 호출이 실패할 수 있습니다.

 

클라이언트 회복성 패턴

 지금까지 MSA 환경에서 버벅거리는 서비스 하나가 전체 시스템에 어떻게 치명적인 영향을 끼칠 수 있는지 알아보았습니다. 이러한 장애 양상에 어떻게 대응해야 할까요? 우리는 이에 대응하기 위해 회복성 패턴이라는 안전장치를 두어 치명적인 시스템 실패를 피할 수 있습니다. 회복성 패턴은 우리 일상에서도 친숙한 아래의 개념들로 이루어져 있습니다.

 

  • Circuit Breaker(회로 차단기) 

두꺼비집

 응답이 지연되는 원격 서비스를 차단하여 반복 호출하지 못하도록 합니다. 가정집에 있는 두꺼비집을 서비스에 설치한다고 생각하시면 되겠습니다. 두꺼비집이 과전류 발생 시 전류를 차단하듯이 특정 원격 서비스에 대한 호출이 정해진 횟수 이상으로 실패한 경우 해당 자원에 대한 호출을 더 이상 반복하지 않습니다.

 

 

  • Fallback(폴백)

 서비스를 차단한 경우 예외를 발생시키는 대신 대비책을 제공하거나 미리 준비된 동작을 실행합니다. 기본값을 반환하거나, 장애가 복구된 후 다시 처리할 수 있도록 재시도 큐에 보관할 수도 있습니다. 예를 들어, 개인화 추천 서비스에 문제가 생겨 회로를 차단했다면 프로모션 상품이나 인기 상품을 대신 응답하도록 하여 매끄럽게 서비스를 운영할 수 있습니다.

 

 

  • Bulkhead(벌크헤드)

선박의 Bulkhead(격벽)

 응답지 지연되는 서비스에 자원을 모두 소진하지 않도록 스레드 풀을 격리합니다. 하나의 서버에서 가용한 스레드풀을 응답이 지연되는 원격 호출에 모두 소진하면 다른 문제없는 기능까지 마비될 수 있습니다. 벌크헤드 패턴은 선박 건조 시 선체 일부가 파손되거나 화재가 발생하여도 다른 부분에 영향이 없도록 격벽을 두는 것과 같습니다. 이러한 회복성 패턴의 아이디어는 원격 서버의 지연이 시스템 전체로 전파되지 않도록 빠르게 실패로 간주하고(빠른 실패), 지연되는 서비스에 소모되는 자원을 격리(실패의 격리)함에 있습니다. 회복성 패턴이 적용된 경우 어떤 식으로 장애가 조치되는지 살펴보겠습니다.

 

 

회복성 패턴 시나리오

 

 서비스 C를 호출하는 서비스 B에 회로 차단기가 적용되었습니다. 이런 상황에서 서비스 C에서 응답 지연 현상이 발생하면 어떻게 될까요? 마냥 응답을 기다리며 반복 호출을 하던 앞서의 상황과 다르게 이제 기설정한 timeout 기준을 넘기는 횟수가 일정 수준을 넘으면 서비스 C를 빠르게 장애로 판단(fail fast, 빠른 실패)하여 회로를 open(차단)합니다. 차단된 동안 서비스 B는 서비스 C로부터 필요한 데이터를 가져올 수 없기에 서비스 A로 fallback 동작을 수행(fail gracefully, 원만한 실패)합니다. 서비스 C는 응답지연이 지속되는 상황에서 더 이상 호출되지 않아 복구할 수 있는 여유가 생깁니다. 많은 경우 이렇게 추가 호출 없이 시간적인 여유를 주는 것만으로 문제가 자연스럽게 해결될 수도 있습니다. 서비스 B의 회로 차단기는 일정 시간이 지나면 서비스 C의 정상화 여부를 간헐적으로 확인합니다. 그리고 DB 이슈가 해결되어 서비스 C가 정상화되었다면 사람의 개입없이 자동(recover seamlessly, 원활한 회복)으로 회로를 close(차단 해제)하여 호출을 허용합니다.

 

Netflix Hystrix

 지금까지 클라이언트 회복성 패턴을 대하여 이야기했습니다만, 가장 중요한 문제가 있습니다. 이 완벽해 보이는 해결책의 가장 큰 문제는 구현이 매우 어렵다는 점입니다. 스레드를 직접 조작하는 개발은 기술이 아닌 예술의 영역이라는 말도 있습니다. 하지만, 언제나 그랬듯 우리는 남이 만들어둔걸 잘 가져다가 쓰면 됩니다😏 Netflix의 API 팀에서 개발하여 2011년부터 자사 프로덕트에 적용해온 Hystrix는 위에서 이야기한 회복성 패턴을 다년간 검증받은 falut tolerance 라이브러리입니다. Hystrix는 고슴도치의 한 종류인데, 가시로 스스로를 보호하듯 서비스를 장애로부터 보호하겠다는 의미로 이름을 붙인 듯합니다.

 

고슴도치의 한 종류인 hystrix
고슴도치를 본 딴 Hystrix 로고

 

  • Hystrix 시작하기

 Spring 생태계의 여러 library들처럼, Hystrix도 의존성 추가 후 어노테이션 기반으로 간단히 시작할 수 있습니다. 메이븐 dependency 추가 후, application 클래스에 @EnableCircuitBreaker 어노테이션을 추가하면 우리의 애플리케이션에 회로 차단기를 적용할 준비가 끝났습니다. 

<!-- netflix-hystrix maven dependency 추가 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
@SpringBootApplication
@EnableCircuitBreaker // 회로 차단기 사용
public class RestConsumerApplication {
    public static void main(String[] args) {
        SpringApplication.run(RestConsumerApplication.class, args);
    }
}

 

 원격 서비스에 의존하는 메서드에 @HystrixCommand 어노테이션을 추가하여 회로 차단기 패턴을 적용할 수 있습니다. 간단히@HystrixCommand 어노테이션만 추가하여, Hystrix는 해당 메서드를 wrapping 하는 proxy를 생성합니다. 그렇게 함으로써, 원격 호출을 처리하는 스레드 풀을 확보하여, 해당 메서드의 모든 호출을 관리할 수 있게 됩니다.

@HystrixCommand
public void callOtherServer() {
    // 다른 서버 호출
}

 또는 어노테이션을 추가하는 대신, HystrixCommand를 상속하여 run method를 오버라이드함으로써 회로 차단기 패턴을 구현할 수 있습니다.

public class CallOtherServer extends HystrixCommand<String> {
    @Override
    protected String run() throws Exception {
    	String result = callOtherServer();
        return result;
    }
}

 

  • 회로 차단기 구현

 회로 차단기는 4가지 parameter를 이용한 통계 값을 내어 차단 여부를 결정합니다. 구체적으로, 일정 시간(metrics.rollingStats.timeInMilliseconds) 동안 일정 개수(requestVolumeThreshold) 이상 호출을 했는데, 일정 비율(errorThresholdPercentage) 이상 에러가 발생하면, 일정 시간(sleepWindowInMilliseconds) 동안 회로 차단기를 open(차단)합니다.

@HystrixCommand(
    commandProperties={
        @HystrixProperty(name="metrics.rollingStats.timeInMilliseconds", value="10000"),
        @HystrixProperty(name="circuitBreaker.requestVolumeThreshold", value="20"),
        @HystrixProperty(name="circuitBreaker.errorThresholdPercentage", value="50"),
        @HystrixProperty(name="circuitBreaker.sleepWindowInMilliseconds", value="5000")
    }
)
public void callOtherServer() {
    // Some works
}

 @HystrixCommand 어노테이션에서 앞서 이야기한 parameter들을 입력할 수 있습니다. 위 예시의 callOtherService 메서드는 10,000ms(10초) 동안 20번 이상의 호출이 있는 경우, 호출의 50% 이상에서 에러가 발생하면 5,000ms(5초) 동안 해당 메서드 호출을 차단합니다. 5초가 지나면 바로 차단을 해제하는 것이 아니고, 요청 1개만 먼저 보내본 후 이것이 성공하면 그때 차단을 해제합니다. 요청이 실패한 경우 다시 5초간 회로를 차단합니다.

 

@HystrixCommand(commandKey = "externalServer1")
public void callSameServer() {
    // 동일 원격 서버 호출
}

@HystrixCommand(commandKey = "externalServer1")
public void callSameServer2() {
    // 동일 원격 서버 호출
}

 우리는 MSA 환경에서 다른 원격 서비스 호출 시 발생할 수 있는 여러 예외 상황을 컨트롤하기 위해 Hystrix를 이용합니다. 그렇다면 특정 원격 서비스를 호출하는 메서드들을 하나의 회로 차단기로 구성할 필요가 있습니다. 즉, 동일한 dependency를 갖는 메서드들은 함께 통계를 내어 회로 차단 여부를 결정하는 것이 합리적으로 보입니다. 이런 경우 여러 메서드에 동일한 commandKey를 설정하면 하나의 회로 차단기로 동작합니다. commandKey를 설정하지 않으면, default로 메서드명이 key로 쓰입니다. 즉, 메서드 단위로 회로 차단기가 설정됩니다.

@HystrixCommand(commandProperties = {
    @HystrixProperty(name="execution.isolation.thread.timeoutInMilliseconds", value="10000")
})
public void callSameServer() {
    // 10초 후 timeout
}

 지연되는 응답을 마냥 기다리지 않고, 빠른 실패로 처리하는 것이 회로 차단기의 주요한 임무입니다. Hystrix의 회로 차단기는 기본값으로 1초를 timeout 기준으로 사용합니다. 위의 예시처럼 execution.isolation.thread.timeoutInMilliseconds를 직접 입력하여 timeout 설정값을 변경할 수 있습니다. 메서드가 지연되어 기준 시간이 지나면 HystrixRuntimeException를 발생시키고 (만일 있다면) fallback이 수행됩니다.

 <스프링 마이크로서비스 코딩 공작소>의 저자 존 카넬은 되도록이면 Hystrix의 default timeout 1초를 임의로 수정하지 않기를 권장합니다. Best practice는 서비스/메서드 별 응답 시간을 상세히 파악한 후, 유독 응답이 지연되는 원격 서비스 호출이나 메서드들을 추려내어 별도의 스레드 풀로 격리하는 것입니다.

 

  • Fallback 구현

 Hystrix는 아래 네 가지 경우에 fallback으로 지정된 메서드를 실행합니다.

  1. 회로 차단기가 열린 경우
  2. HystrixBadRequestException을 제외한 모든 Exception
  3. Semaphore/ThreadPoolReject
  4. Timeout
@HystrixCommand(
    fallbackMethod = "myFallBackMethod",
    commandProperties = {
        @HystrixProperty(name="execution.isolation.thread.timeoutInMilliseconds", value="1000")
})
public void callSameServer() throws InterruptedException {
    // delay 2 seconds
    Thread.sleep(2000);
}

public void myFallBackMethod() {
    System.out.println("Fallback executed");
}

 위의 코드는 timeout으로 인해 fallback이 실행되는 예시입니다. 메서드 내에서 2초간 sleep 하여, 설정된 timeout 1초를 넘기면 HystrixRuntimeException이 발생하고, fallbackMethod로 설정된 myFallBackMethod가 실행됩니다.

 

 HystrixBadRequestException는 어떤 이유로 fallback이 수행되는 경우에서 제외될까요? 메서드를 호출한 caller 측에서 명백히 잘못한 경우, 우리는 HystrixBadRequestException를 던지도록 개발해야 합니다. 예를 들어, 사칙연산 서비스를 숫자가 아니라 문자를 넣어서 호출하는 경우는 회로를 차단하거나 fallback을 수행하는 것이 아닌 호출하는 측에 잘못된 호출임을 알리는 것이 옳습니다. 이런 경우 HystrixBadRequestException를 throw 하도록 합니다. Try-catch 구문에서 IllegalArgumentException, IllegalStateException과 같은 예외들을 catch 한 후, HystrixBadRequestException를 throw 하는 등의 처리가 필요합니다.

 Semaphore/ThreadPoolReject는 할당한 스레드 풀을 모두 소진한 경우입니다. 뒤에 이어서 알아보겠습니다.

 

  • Bulkhead 구현

 회로 차단기 별로 격리된 스레드 풀을 지정할 수 있습니다. Hystrix는 스레드 격리 방법으로 THREAD, SEMAPHORE 두 가지를 제공합니다.

THREAD 방식의 격리 전략

 THREAD. Hystrix의 기본 격리 방식으로서, caller로부터의 호출 스레드를 intercept 하여 Hystrix가 관리하는 스레드로 대신 호출합니다. 회로 차단기 별로 threadPoolKey를 이용하여 자신이 사용할 스레드 풀을 지정합니다. 위의 그림처럼 회로 차단기 여러 개가 같은 스레드 풀을 공유할 수 있습니다.

@HystrixCommand(
    fallbackMethod = "myFallbackMethod",
    threadPoolKey  = "myThreadPool",
    threadPoolProperties = {
        @HystrixProperty(name="coreSize", value="20"),
        @HystrixProperty(name="maxQueueSize", value="10"),
    })
public void callSameServer() throws InterruptedException {
    // Some works
}

 coreSize는 스레드 풀의 스레드 개수를 의미합니다. maxQueueSize는 스레드가 모두 점유 중일 때 들어온 요청이 대기하는 큐의 크기를 의미합니다. 큐를 사용하고 싶지 않으면 maxQueueSize를 -1로 설정합니다. 스레드 개수는 어떻게 정하면 될까요? Netflix에서는 아래와 같은 공식을 제안합니다.

 

Peak시의 RPS * Latency의 99% quantile(in sec) + 오버헤드를 대비한 여유분 = 스레드 개수

 

 예를 들어, 가장 사용량이 많을 때 초당 30개의 요청이 들어오고, 응답 시간을 오름차순으로 세워놓았을 때 100개 중 99번째 값이 0.2초라고 한다면, 30 RPS * 0.2 sec + 오버헤드를 대비한 여유분 = 10개 정도의 스레드가 적절한 개수라고 볼 수 있습니다.

 

SEMAPHORE. 세마포르는 원래 수기신호라는 뜻으로서, 선박이 정박하는 등의 상황에서 깃발로 신호를 보내는 것을 의미합니다. 앞서 이야기한 THREAD 방식에서는 요청 스레드를 intercept 하여 Hystrix가 관리하는 스레드로 요청을 처리했다면, SEMAPHORE 방식에서는 요청 스레드가 실제로 처리까지 담당합니다. 회로 차단기가 깃발을 하나씩 들고 항구에 서서 선박(요청)을 허용 가능한 수만큼 스레드 풀로 통과시키는 모습을 상상하면 되겠습니다.

 

세마포르(수기신호)

 

 복수의 회로 차단기가 하나의 스레드 풀을 공유할 수 있었던 THREAD 방식과 달리 SEMAPHORE방식에서는 회로 차단기 하나가 스레드 풀 하나씩 가지고 있습니다.

 

SEMAPHORE 방식의 격리 전략

@HystrixCommand(fallbackMethod = "myFallbackMethod",
    commandProperties = {
        @HystrixProperty(name = "execution.isolation.strategy", value = "SEMAPHORE"),
        @HystrixProperty(name = "execution.isolation.semaphore.maxConcurrentRequests", value = "3")
    })
public void callSameServer() throws InterruptedException {
    // Some works
}

 

 execution.isolation.strategy는 스레드 격리 전략을 어떻게 취할 것인지를 의미합니다. 이를 SEMAPHORE로 설정한 후, maxConcurrentRequests에 동시 처리 가능한 최대 요청 개수를 설정합니다. 이를 초과할 시, SemaphoreRejection이 발생하여 fallback 메서드가 실행됩니다.

 

 단, Netflix의 Hystrix 공식문서에서는 SEMAPHORE 방식을 지양하고 기본 전략인 THREAD 방식을 사용하길 권고하고 있습니다. 요청 스레드를 바로 스레드 풀에 할당하여 처리하는 SEMAPHORE와 달리 THREAD 방식은 요청 스레드를 intercept 하여 Hystrix가 관리하는 별도 스레드를 이용하기에 격리의 수준을 높일 수 있기 때문입니다. 또한, SEMAPHORE의 작업 스레드는 interrupt 하기가 까다로워 timeout을 제시간에 발생시키지 못하는 단점도 있습니다. THREAD 방식의 경우 스레드가 Hystrix의 관리하에 있기에 timeout을 정확하게 적용할 수 있습니다.

 아주 예외적인 상황에서만 SEMAPHORE 방식을 사용하길 권고하는데, 호출량이 매우 많고(적어도 초당 수백 건 이상) 개별 스레드에 오버헤드가 매우 큰, 보통은 네트워크를 타지 않는 호출인, 경우에 한정하여 SEMAPHORE 적용을 고민할 수 있다고 합니다. Netflix 자사 시스템에서는 메모리 캐시에서 metadata를 5,000 RPS로 fetch 하는 호출에 SEMAPHORE 방식의 스레드 격리 전략을 적용했다고 소개하고 있습니다.

 

아쉬운 은퇴 및 대안

 아쉽게도 fault tolerance 라이브러리의 대명사격이었던 Hystrix는 현재 개발이 중단되었습니다. 아직까지 프로젝트에 적용은 가능하나 되도록이면 신규 프로젝트에는 resilience4j(application level)나 Istio(infra level)를 이용하길 권장드립니다. 특히 resilience4j는 Spring Boot 프로젝트에서 앞서 소개한 Hystrix의 circuit breaker, fallback, bulkhead(Semaphore, Thread) 등의 패턴들을 모두 대체할 수 있습니다. Application 레벨에서 회복성 패턴을 적용하고자 할 때 Hystrix의 훌륭한 대안으로 꼽을 수 있습니다.

 

References

존 카넬, 일러리 후알리루포 산체스 저. 정성권 역 『스프링 마이크로서비스 코딩 공작소』. 길벗. 2022

Netflix/Hystrix Wiki, https://github.com/Netflix/Hystrix/wiki

[Spring Camp 2018] 11번가 Spring Cloud 기반 MSA로의 전환 : 지난 1년간의 이야기, Link

 
댓글
댓글쓰기 폼