본문 바로가기
MSA/Spring Microservice In Action

5. 나쁜 상황에 대비한 스프링 클라우드와 넷플릭스 히스트릭스의 클라이언트 회복성 패턴

by 화트마 2022. 2. 19.

분산 시스템에서의 장애에 대응하는 애플리케이션 구축 방법은 중요한 일

  • 원격 서비스 문제는 탐지하기 어렵고 비정상적인 서비스 하나가 여러 애플리케이션을 짧은 시간에 다운시킬 수 있기 때문

클라이언트 회복성 패턴

  1. 클라이언트 측 부하 분산
  2. 회로 차단기(circuit breaker)
  3. 폴백(fallback)
  4. 벌크헤드(bulkhead)

1. 클라이언트 측 부하 분산

  • 클라이언트가 서비스 디스커버리 에이전트(넷플릭스 유레카)를 이용해 서비스의 모든 인스턴스를 검색한 후 해당 서비스 인스턴스의 실제 위치를 캐싱하는 것. (넷플릭스 리본라이브러리)
  • 문제가 된 서비스 인스턴스를 제거해 서비스 호출이 그 인스턴스로 전달되는 것을 막는다

2. 회로 차단기

  • 원격 자원에 대한 모든 호출을 모니터링하고, 호출이 필요한 만큼 실패하면 회로 차단기가 활성화되어 빨리 실패하게 만들며, 고장 난 원격 자원은 더 이상 호출되지 않도록 차단한다.
  • 회로 차단 패턴이 제공하는 핵심 기능
    1. 빠른 실패 (fail fast)
    빨리 실패함으로써 애플리케이션 전체를 다운시킬 수 있는 자원 고갈 이슈 방지
    2. 원만한 실패 (fail gracefully)
    타임아웃과 폴백을 사용하여 원만하게 실패하거나 대체 매커니즘을 찾을 수 있음
    3. 원만한 회복(recover seamlessly)
    회로 차단기는 요청 자원이 온라인 상태인지 주기적으로 확인하고, 사람의 개입 없이 자원 접근을 다시 허용 가능

3. 폴백 처리

  • 원격 서비스에 대한 호출이 실패할 때 예외를 발생시키지 않고 서비스 소비자가 대체 코드 경로를 실행해 다른 방법으로 작업을 수행할 수 있다

4. 벌크헤드

  • 원격 자원에 대한 호출을 자원별 스레드 풀로 분리하여 특정 원격 자원의 호출이 느려져 전체 애플리케이션이 다운될 수 있는 위험을 줄일 수 있다

스프링 클라우드와 히스트릭스를 위한 라이선싱 서버 설정

스프링 히스트릭스 메이븐 의존성 추가

pom.xml
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>

 

서비스 부트스트랩 클래스에 히스트릭스 애너테이션 추가

licensing-service/src/main/java/com/hmg/licenses/LicensingServiceApplication.java
@SpringBootApplication
@EnableEurekaClient
@EnableCircuitBreaker // 스프링 클라우드에 이 서비스에서 히스트릭스를 사용할 것이라고 지정
public class Application {
    @LoadBalanced
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

히스트릭스를 사용한 회로 차단기 구현

히스트릭스 구현 방법 두가지 범주

  1. 라이선싱 및 조직 서비스 모두 자기 데이터베이스에 대한 호출을 히스트릭스 회로 차단기에 연결
  2. 두 서비스 사이의 호출을 히스트릭스에 연결

→ 두 범주의 호출이 다르지만 히스트릭스 사용 방법은 완전히 동일함

 

@HystrixCommand

  • 이 애너테이션을 사용해 히스트릭스 회로 차단기가 관리하는 자바 클래스 메서드라고 표시함
  • 스프링 프레임워크가 @HystrixCommand를 만나면 메서드를 감싸는 프록시를 동적으로 생성하고 원격 호출을 처리하기 위해 확보한 스레드가 있는 스레드 풀로 해당 메서드에 대한 모든 호출을 관리한다.
licensing-service/src/main/java/com/hmg/licenses/services/LicenseService.java
@HystrixCommand // 회로차단기로 getLicensesByOrg() 메서드를 연결
public List<License> getLicensesByOrg(String organizationId){
    logger.debug("LicenseService.getLicensesByOrg  Correlation id: {}", UserContextHolder.getContext().getCorrelationId());
    randomlyRunLong();

    return licenseRepository.findByOrganizationId(organizationId);
}
  • @HystrixCommand를 사용하면 getLicensesByOrg() 메서드 호출 시마다 히스트릭스 회로 차단기와 해당 호출이 연결되어 메서드 호출이 1,000밀리초(기본값)보다 오래 걸릴 때마다 호출을 중단한다.

 

3회 호출 중 1회정도 1초이상 지연되는 데이터베이스 질의문을 수행하는 상황 테스트

private void randomlyRunLong(){ // 데이터 베이스 3호출 3회중 1회 지연
  Random rand = new Random();
  int randomNum = rand.nextInt((3 - 1) + 1) + 1;
  if (randomNum==3) sleep();
}

private void sleep(){
    try {
        Thread.sleep(11000); // 11,000밀리초 (11초) 동안 대기
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

 

해당 메서드 엔드포인트 여러번 호출 시 타임아웃 에러 메시지 응답

GET http://localhost/v1/organizations/e254f8c-c442-4ebea82a-e2fc1d1ff78a/licenses/

히스트릭스가 감싼 코드(getLicensesByOrg)를 실행하는 데 1,000밀리초(기본값) 이상 걸린다면 서비스 호출은 HystrixRuntimeException 예외를 던진다.

 

회로 차단기의 타임아웃 사용자 정의

호출이 타임아웃되기 전까지 히스트릭스가 기다릴 시간을 사용자 정의

@HystrixCommand(
	commandProperties = {
		@HystrixProperty(name="execution.isolation.thread.timeoutInMilliseconds",value="12000")
	}
)
public List<License> getLicensesByOrg(String organizationId){
    logger.debug("LicenseService.getLicensesByOrg  Correlation id: {}", UserContextHolder.getContext().getCorrelationId());
    randomlyRunLong();

    return licenseRepository.findByOrganizationId(organizationId);
}

폴백 프로세싱

회로 차단기 패턴의 장점은 원격 자원의 소비자와 리소스 사이에 ‘중간자’를 두어 개발자에게 서비스 실패를 가로채고 다른 대안을 선택할 기회를 준다는 것. 이를 폴백 전략이라 함.

@HystrixCommand(fallbackMethod = "buildFallbackLicenseList")
public List<License> getLicensesByOrg(String organizationId){
    logger.debug("LicenseService.getLicensesByOrg  Correlation id: {}", UserContextHolder.getContext().getCorrelationId());
    randomlyRunLong();

    return licenseRepository.findByOrganizationId(organizationId);
}

private List<License> buildFallbackLicenseList(String organizationId){
    List<License> fallbackList = new ArrayList<>();
    License license = new License()
            .withId("0000000-00-00000")
            .withOrganizationId( organizationId )
            .withProductName("Sorry no licensing information currently available");

    fallbackList.add(license);
    return fallbackList;
}
  • @HystrixCommand에 fallbackMethod 속성을 추가.
  • 이 폴백 메서드는 @HystrixCommand가 보호하려는 메서드와 같은 클래스에 있어야함
  • @HystrixCommand가 보호하는 메서드에 전달되는 모든 매개변수를 폴백이 받으므로 폴백 메서드는 이전 메서드와 서식이 완전히 동일해야 함
  • buildFallbackLicenseList()는 가짜 정보를 담고 있는 License 객체 하나를 만들어 반환

 

엔드포인트 호출 테스트

타임아웃 에러 발생 시 예외가 발생하지 않는 대신 가짜 라이선스 값이 결과로 반환


벌크헤드 패턴 구현

  • 벌크헤드 패턴을 적용하지 않는다면 기본적인 호출은 전체 자바 컨테이너에 대한 요청을 처리하는 스레드에서 이루어진다. 대규모 상황에서 한 서비스에서 발생한 성능 문제로 자바 컨테이너의 모든 스레드가 최대치에 도달해 비정상 종료될 수 있다.
  • 벌크헤드 패턴은 원격 자원 호출을 자신의 스레드 풀에 격리하므로 오작동 서비스를 억제하고 컨테이너의 비정상 종료를 방지한다.
  • 서로 다른 원격 자원 호출 간에 벌크헤드 생성 : 각 원격 자원 호출이 자기 스레드 풀 이용하여 성능 나쁜 서비스가 다른 서비스 호출에 영향을 주는 것을 방지

 

벌크헤드 구현

  1. 메서드 호출을 위한 별도 스레드 풀 설정하기
  2. 스레드 풀의 스레드 숫자 설정하기
  3. 스레드가 분주할 때 큐에 들어갈 요청 수에 해당하는 큐의 크기 설정하기
@HystrixCommand(fallbackMethod = "buildFallbackLicenseList",
	threadPoolKey = "licenseByOrgThreadPool", // 스레드 풀의 고유 이름 정의
	threadPoolProperties = // 스레드 풀 동작을 정의하고 설정
		{@HystrixProperty(name="coreSize",value="30"), // 스레드 풀의 스레드 개수 정의 (기본값 10)
		@HystrixProperty(name="maxQueueSize", value="10")} // 스레드 풀 앞에 배치할 큐와 큐에 넣을 요청수 정의 (기본값 -1)
)
public List<License> getLicensesByOrg(String organizationId){
    logger.debug("LicenseService.getLicensesByOrg  Correlation id: {}", UserContextHolder.getContext().getCorrelationId());
    randomlyRunLong();

    return licenseRepository.findByOrganizationId(organizationId);
}

히스트릭스 세부 설정

  • 히스트릭스는 오래 수행되는 호출의 타임아웃 기능 외에도 호출 실패 횟수를 모니터링해 호출이 필요 이상으로 실패할 때 원격 자원에 도달하기 전에 호출을 실패시켜 서비스로 들어오는 이후 호출을 자동으로 차단함
  • 원격 자원에 성능 문제가 있는 경우 빨리 실패하여 자원 고갈 문제와 비정상 종료 위험 낮춤
  • 빠른 실패로 호출을 막으면 문제있던 시스템이 회복할 수 있는 시간을 얻는다.

 

히스트릭스 회로차단기 차단 결정 과정

  • 서비스 에러 포착 시 서비스 호출 빈도 검사용 10초 타이머를 시작 (기본값 10초)
  • 10초 시간대 동안 원격 자원 호출이 최소 호출 횟수를 넘으면 전체 실패 비율을 조사하기 시작 (기본 임계치는 50%)
    전체 실패 비율이 임계치를 초과하면 회로를 차단하고 원격 자원에 대한 추가 호출을 막음.
  • 히스트릭스가 원격 호출에 대해 회로 차단기 차단 시 새로운 활동 시간대를 시작
    5초마다 (시간대 설정 가능) 히스트릭스는 호출을 허용하여 호출이 성공하면 회로 차단기를 초기화 하고 다시 호출을 허용함.

 

회로 차단기 행동 양식 구현 (commandProperties)

@HystrixCommand(
    fallbackMethod = "buildFallbackLicenseList",
    threadPoolKey = "licenseByOrgThreadPool",               // 스레드 풀의 고유 이름 정의
    threadPoolProperties = { // 스레드 풀 동작을 정의하고 설정
        @HystrixProperty(name="coreSize",value="30"),       // 스레드 풀의 스레드 개수 정의 (기본값 10)
        @HystrixProperty(name="maxQueueSize", value="10")}, // 스레드 풀 앞에 배치할 큐와 큐에 넣을 요청수 정의 (기본값 -1)
    commandProperties={ // 회로 차단기 동작 사용자 정의
        @HystrixProperty(name="circuitBreaker.requestVolumeThreshold", value="10"),     // 최소 연속 호출 횟수 (기본값 20)
        @HystrixProperty(name="circuitBreaker.errorThresholdPercentage", value="75"),   // 실패해야 하는 호출 비율 (기본값 50%)
        @HystrixProperty(name="circuitBreaker.sleepWindowInMilliseconds", value="7000"),// 차단된 후 서비스 회복 상태 확인할 때 까지 대기할 시간 간격 (기본값 5,000ms)
        @HystrixProperty(name="metrics.rollingStats.timeInMilliseconds", value="15000"),// 서비스 호출 문제를 모니터할 시간 간격 (기본값 10,000 ms)
        @HystrixProperty(name="metrics.rollingStats.numBuckets", value="5")}            // 설정한 시간 간격 동안 통계를 수집할 횟수 (기본값 10)
)
public List<License> getLicensesByOrg(String organizationId){
    logger.debug("LicenseService.getLicensesByOrg  Correlation id: {}", UserContextHolder.getContext().getCorrelationId());
    randomlyRunLong();

    return licenseRepository.findByOrganizationId(organizationId);
}

 

클래스 레벨 프로퍼티 구성 방법

예시) 특정 클래스 안의 모든 자원에 10초 타임아웃 설정
@DefaultProperties(
	commandProperties = {
		@HystrixProperty(name="execution.isolation.thread.timeoutInMiliseconds", value="10000")})
class MyService { ... }

스레드 컨텍스트와 히스트릭스

@HystrixCommand 격리 전략

1. THREAD

  • 기본 히스트릭스 격리 전략
  • 모든 히스트릭스 명령은 호출을 시도한 부모 스레드와 컨텍스트를 공유하지 않는 격리된 스레드 풀에서 수행

2. SEMAPHORE

  • 히스트릭스는 새로운 스레드를 시작하지 않고 @HystrixCommand가 보호하는 분산 호출을 관리하며 타임아웃 발생 시 부모 스레드를 중단시킨다

 

격리 전략 설정

@HystrixCommand(
	commandProperties={
		@HystrixProperty(name="execution.isolation.strategy", value="SEMAPHORE")})
기본적으로 THREAD 방식 권장. THREAD 격리 방식은 히스트릭스 명령 스레드와 부모 스레드 사이의 격리 수준을 높이며, SEMAPORE 방식보다 무겁다. SEMAPORE 격리 모델은 경량이며, 서비스에서 대용량을 처리하고 비동기 I/O 프로그래밍 모델(예, Netty 같은 비동기 I/O 컨테이너)을 적용할 때 사용해야 함

 

ThreadLocal과 히스트릭스

  • 기본적으로 히스트릭스는 부모 스레드의 컨텍스트를 히스트릭스 명령이 관리하는 스레드에 전파하지 않는다.

예시

  • REST 서비스에 대한 호출을 가로채 컨텍스트 정보를 추출해 UserContext객체로 변환 후 ThreadLocal에 저장하여 필요시마다 읽어옴.
  • 그러나 히스트릭스 보호 메서드 실행 시 이 ThreadLocal 값 (UserContext)이 전파되지 않음.
licensing-service/src/main/java/com/hmg/licenses/utils/UserContextFilter.java
@Component
public class UserContextFilter implements Filter {
    private static final Logger logger = LoggerFactory.getLogger(UserContextFilter.class);

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException {

        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
				
		// HTTP 호출 헤더에서 검색한 값을 UserContextHolder의 UserContext에 저장한다.
        UserContextHolder.getContext().setCorrelationId(httpServletRequest.getHeader(UserContext.CORRELATION_ID));
        UserContextHolder.getContext().setUserId(httpServletRequest.getHeader(UserContext.USER_ID));
        UserContextHolder.getContext().setAuthToken(httpServletRequest.getHeader(UserContext.AUTH_TOKEN));
        UserContextHolder.getContext().setOrgId(httpServletRequest.getHeader(UserContext.ORG_ID));

        logger.debug("UserContextFilter Correlation id: {}", UserContextHolder.getContext().getCorrelationId());

        filterChain.doFilter(httpServletRequest, servletResponse);
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {}

    @Override
    public void destroy() {}
}
  • UserContextHolder는 ThreadLocal 클래스에 UserContext를 저장하는데 사용.
licensing-service/src/main/java/com/hmg/licenses/utils/UserContextHolder.java
public class UserContextHolder {
    // 정적 ThreadLocal 변수에 저장되는 UserContext
	private static final ThreadLocal<UserContext> userContext = new ThreadLocal<UserContext>();

	// UserContext 객체를 사용하기 위해 가져오는 getContext 메서드
    public static final UserContext getContext(){
        UserContext context = userContext.get();

        if (context == null) {
            context = createEmptyContext();
            userContext.set(context);

        }
        return userContext.get();
    }

    public static final void setContext(UserContext context) {
        Assert.notNull(context, "Only non-null UserContext instances are permitted");
        userContext.set(context);
    }

    public static final UserContext createEmptyContext(){
        return new UserContext();
    }
}

 

라이선싱 서비스에 로깅 추가

logger.info("LicenseService.getLicensesByOrg Correlation id: {}", UserContextHolder.getContext().getCorrelationId());
  • src/main/java/com/thoughtmechanix/licenses/utils/UserContextFilter.java의 doFilter() 메서드
  • src/main/java/com/thoughtmechanix/licenses/controllers/LicenseServiceController.java의 getLicenses() 메서드
  • src/main/java/com/thoughtmechanix/licenses/services/LicenseService.java의 getLicensesByOrg() 메서드.
    이 메서드는 @HystrixCommand 애너테이션 존재

 

서비스 호출 테스트

GET http://localhost:8080/v1/organizations/e254f8c-c442-4ebe-a82a-e2fc1d1ff78a/licenses/

 

로그 메시지 결과

UserContext Correlation id: TEST-CORRELATION-ID
LicenseServiceController Correlation id: TEST-CORRELATION-ID
LicenseService.getLicenseByOrg Correlation:
  • 히스트릭스가 보호하는 LicenseService.getLicenseByOrg() 메서드에서는 상관관계 ID가 출력되지 않는다. (부모 컨텍스트를 공유 받지 못함)

 

HystrixConcurrencyStrategy 동작

  • 히스트릭스 사용하면 히스트릭스 호출을 감싸는 병행성 전략을 사용자 정의하고 부모 스레드의 컨텍스트를 히스트릭스 명령이 관리하는 스레드에 주입할 수 있다

 

사용자 정의 HystrixConcurrencyStrategy 구현 작업

  1. 히스트릭스 병행성 전략 클래스 사용자 정의
  2. 히스트릭스 명령에 UserContext를 주입하도록 자바 Callable 클래스 정의
  3. 히스트릭스 병행성 전략을 사용자 정의하기 위해 스프링 클라우드 구성

 

히스트릭스 병행성 전략 클래스 사용자 정의

src/main/java/com/hmg/licenses/hystrix/ThreadLocalAwareStrategy.java
// Hystrix 병행성 젼략을 위한 클래스
public class ThreadLocalAwareStrategy extends HystrixConcurrencyStrategy{ // HystrixConcurrencyStrategy 클래스를 상속한다.
    private HystrixConcurrencyStrategy existingConcurrencyStrategy;

    // 스프링 클라우드가 미리 정의한 병행성 클래스를 이 클래스의 생성자에 전달한다.
    public ThreadLocalAwareStrategy(
            HystrixConcurrencyStrategy existingConcurrencyStrategy) {
        this.existingConcurrencyStrategy = existingConcurrencyStrategy;
    }

    // 일부 메서드의 재정의 필요.
    // existingCouncurrencyStrategy 메서드 구현을 호출하거나 부모 HystrixConcurrencyStrategy 메서드를 호출한다.
    @Override
    public BlockingQueue<Runnable> getBlockingQueue(int maxQueueSize) {
        return existingConcurrencyStrategy != null
                ? existingConcurrencyStrategy.getBlockingQueue(maxQueueSize)
                : super.getBlockingQueue(maxQueueSize);
    }

	...

    // 히스트릭스로 보호된 메서드(callable) 호출 전 추가 작업 처리를 위해 새로운 Callable(DelegatingUserContextCallable)로 감싼다
    // 부모 스레드의 ThreadLocal 값을 히스트릭스로 보호된 메서드의 스레드로 전달한다.
    @Override
    public <T> Callable<T> wrapCallable(Callable<T> callable) {
        return existingConcurrencyStrategy != null
                ? existingConcurrencyStrategy
                .wrapCallable(new DelegatingUserContextCallable<T>(callable, UserContextHolder.getContext()))
                : super.wrapCallable(new DelegatingUserContextCallable<T>(callable, UserContextHolder.getContext()));
    }
}

 

히스트릭스 명령에 UserContext를 주입하도록 자바 Callable 클래스 정의

src/main/java/com/hmg/licenses/hystrix/DelegatingUserContextCallable.java
public final class DelegatingUserContextCallable<V> implements Callable<V> {
    private final Callable<V> delegate;
    private UserContext originalUserContext;

    // 히스트릭스로 보호된 코드를 호출하는 원본 Callable과 부모 스레드에서 받은 UserContext 전달
    public DelegatingUserContextCallable(Callable<V> delegate,
                                             UserContext userContext) {
        this.delegate = delegate;
        this.originalUserContext = userContext;
    }

    // @HystrixCommand 애너테이션이 메서드를 보호하기 전에 호출되는 Call() 함수
    public V call() throws Exception {
        // UserContext 설정. UserContext를 저장하는 ThreadLocal 변수는 히스트릭스가 보호하는 메서드를 실행하는 스레드에 연결된다.
        UserContextHolder.setContext( originalUserContext );

        try {
            // UserContext 설정 후 히스트릭스가 보호하는 메서드 실행
            return delegate.call();
        }
        finally {
            this.originalUserContext = null;
        }
    }

    public static <V> Callable<V> create(Callable<V> delegate,
                                         UserContext userContext) {
        return new DelegatingUserContextCallable<V>(delegate, userContext);
    }
}

 

히스트릭스 병행성 전략을 사용자 정의하기 위해 스프링 클라우드 구성

src/main/java/com/hmg/licenses/hystrix/ThreadLocalConfiguration.java

 

@Configuration
public class ThreadLocalConfiguration {
    // 구성 객체가 생성될 때 기존 HystrixConcurrencyStrategy와 자동 연결한다.
    @Autowired(required = false)
    private HystrixConcurrencyStrategy existingConcurrencyStrategy;

    @PostConstruct
    public void init() {
        // 기존 히스트릭스 플러그인의 레퍼런스 유지
        // 새로운 병행성 전략을 등록하기 때문에 모든 히스트릭스 컴포넌트를 가져와 히스트릭스 플러그인을 재설정한다.
        HystrixEventNotifier eventNotifier = HystrixPlugins.getInstance()
                .getEventNotifier();
        HystrixMetricsPublisher metricsPublisher = HystrixPlugins.getInstance()
                .getMetricsPublisher();
        HystrixPropertiesStrategy propertiesStrategy = HystrixPlugins.getInstance()
                .getPropertiesStrategy();
        HystrixCommandExecutionHook commandExecutionHook = HystrixPlugins.getInstance()
                .getCommandExecutionHook();

        HystrixPlugins.reset();

        // HystrixConcurrencyStrategy(ThreadLocalAwareStrategy)를 히스트릭스 플러그인에 등록한다.
        HystrixPlugins.getInstance().registerConcurrencyStrategy(new ThreadLocalAwareStrategy(existingConcurrencyStrategy));
        // 히스트릭스 플러그인이 사용하는 모든 히스트릭스 컴포넌트를 재등록한다.
        HystrixPlugins.getInstance().registerEventNotifier(eventNotifier);
        HystrixPlugins.getInstance().registerMetricsPublisher(metricsPublisher);
        HystrixPlugins.getInstance().registerPropertiesStrategy(propertiesStrategy);
        HystrixPlugins.getInstance().registerCommandExecutionHook(commandExecutionHook);
    }
}

 

서비스 호출 재 테스트

GET http://localhost:8080/v1/organizations/e254f8c-c442-4ebe-a82ae2fc1d1ff78a/licenses/

  • 이제 히스트릭스로 보호된 메서드에서도 부모 스레드의 컨텍스트를 공유 받음

참고

  • 2018년부터 Netflix 에서 hystrix를 유지관리 모드로 (maintenance mode, 새로운 기능을 추가하지 않고 버그 및 보안 문제만 수정) 전환하여 hystrix 대체로 Resilience4j를 권장함.

댓글