[마이크로서비스] 회복성패턴, Resilience4j (6)

hyeokjin·2022년 8월 14일
1

microservice

목록 보기
6/13
post-thumbnail

클라이언트의 회복성

클라이언트 측 회복성 소프트웨어 패턴들은 에러나 성능 저하로 원격 자원이 실패할 때 원격 자원의 클라이언트가 고장나지 않게 보호하는데 중점을 둔다

네 가지 클라이언트 회복성 패턴을 살펴보자
아래 사진은 마이크로서비스에 대한 서비스 소비자와 마이크로서비스 사이에 어떻게 위치하는지 보여준다.

  • 클라이언트 측 로드 벨런싱 : 서비스 디스커버리를 설명하면서 클라이언트 측 로드 밸런싱을 소개했었다. 클라이언트 측 로드 밸런서는 서비스 클라이언트와 서비스 소비자 사이에 위치하기 때문에 서비스 인스턴스가 에러를 발생하거나 정상적으로 동작하지 않는지 탐지하고 클라이언트 측 로드 밸런서가 문제를 탐지하면 가용 서비스 풀에서 문제된 서비스 인스턴스를 제거하여 해당 서비스 인스턴스로 더 이상 호출되지 않게한다.

  • 회로차단기 : 원격 서비스가 호출될 때 호출을 모니터링한다. 호출이 너무 오래걸리면 차단기가 개입해서 호출을 종료한다. 회로 차단기 패턴은 원격자원에 대한 모든 호출을 모니터링하다가 호출이 충분히 실패하면 회로 차단기 구현체가 열리면서 빠르게 실패하고 고장 난 원격 자원에 대한 추가 호출을 방지한다.

  • 폴백처리 : 원격 서비스 호출이 실패할 때 서비스 소비자가 대체 코드 경로를 실행하여 다른 수단을 통해 작업을 수행한다. 보통 다른 데이터 소스에서 데이터를 찾거나 향후 처리를 위해 사용자 요청을 큐에 입력하는 작업이 포함된다.

  • 벌크헤드 : 원격 자원에 대한 호출을 자원별 스레드 풀로 분리하면, 느린 원격 자원 호출 하나로 발생한 문제가 전체 애플리케이션을 다운시킬 위험을 줄인다. 스레드 풀은 서비스의 벌크헤드 역할을 한다. 각 원격 자원을 분리하여 스레드 풀에 각각 할당 된다. 즉, 느린 서비스의 호출 그룹에 대한 스레드 풀만 포화되어 요청 처리를 중단 될 수 있다. 스레드 풀별로 서비스를 할당하면 다른 서비스는 포화되지 않기 때문에 이러한 병목현상을 우회하는데 유용하다.

Resilience4j

히스트릭스에서 영감을 받은 내결함성 라이브러리다. 네트워크 문제나 여러 서비스의 고장으로 발생하는 결함 내성을 높이기 위해 다음 패턴을 제공한다.

  • 회로 차단기 : 요청받은 서비스가 실패할 때 요청을 중단한다.
  • 재시도 : 서비스가 일시적으로 실패할 때 재시도한다.
  • 벌크헤드 : 과부하를 피하고자 동시 호출하는 서비스 요청 수를 제한한다.
  • 속도제한 : 서비스가 한 번에 수신하는 호출 수를 제한한다.
  • 폴백 : 실패하는 요청에 대해 대체 경로를 설정한다.

아래는 진행하고자하는 서비스들의 소스를 참고할 깃헙 주소이다.

https://github.com/hyeokjinON/microservice_study/tree/master/chapter7


이제 각각 회복성 패턴을 구현해보고, 테스트 해보자. 라이선싱 서비스에 회로 차단기 구현은 서비스 메서드를 추가하고 @CircuitBreaker을 선언해준다.

회로 차단기 구현

아래 아키택쳐를 참고한다.

먼저 컨트롤러에 getLicenses()를 추가한다.

LicenseController.java


	@RequestMapping(value="/",method = RequestMethod.GET)
	public List<License> getLicenses( @PathVariable("organizationId") String organizationId) throws TimeoutException {
		return licenseService.getLicensesByOrganization(organizationId);
	}
    

licenseService.java 클래스 파일의 getLicensesByOrganization() 메서드를 추가한다.

LicenseService.java

	// Resilience4j 회로 차단기를 사용하여 메서드를 @CircuitBreaker 으로 매핑한다.
	@CircuitBreaker(name = "licenseService")
	public List<License> getLicensesByOrganization(String organizationId) throws TimeoutException {
		logger.debug("getLicensesByOrganization Correlation id: {}",
				UserContextHolder.getContext().getCorrelationId());
		randomlyRunLong();
		return licenseRepository.findByOrganizationId(organizationId);
	}

그리고 라이선싱 서비스 데이터베이스 호출에 의도적인 타임아웃을 추가한다.

	// 데이터베이스 호출이 오래 실행될 가능성은 3분의 1이다
	private void randomlyRunLong() throws TimeoutException{
		Random rand = new Random();
		int randomNum = rand.nextInt((3 - 1) + 1) + 1;
		if (randomNum==3) sleep();
	}
	
	// 5초를 슬립한 후 TimeoutException 예외를 발생시킨다.
	private void sleep() throws TimeoutException{
		try {
			System.out.println("Sleep");
			Thread.sleep(5000);
			throw new java.util.concurrent.TimeoutException();
		} catch (InterruptedException e) {
			logger.error(e.getMessage());
		}
	}

postman에서 아래 호출을 해보자.

http://localhost:8080/v1/organization/e839ee96-28de-4f67-bb79-870ca89743a0/license

실패 중인 서비스를 계속 호출하면 링 비트 버퍼(실패율)가 다차서 위와 같은 에러가 표시가 된다.

데이터베이스 외에도 마이크로서비스를 호출하든지 간에 동일한 애너테이션을 사용할 수 있다. 라이선싱 서비스에서 라이선스와 연관된 조직 이름을 검색해야 할 때 RestTemplate 호출 부분을 메서드로 분리하고 @CircuitBreaker 를 추가하면 된다.

회로 차단기 사용자 정의

라이선스 및 조직 서비스의 bootstrap.yml 파일에서 회로 차단기 패턴을 사용자 정의하는 방법을 보여준다

bootstrap.yml

resilience4j.circuitbreaker:
  instances:
    # 라이선싱 서비스 인스턴스 구성(회로 차단기 애너테이션에 전달되는 이름과 동일)
    licenseService:
      # 상태 정보 엔드포인트에 대한 구성 정보 노출 여부를 설정한다.
      registerHealthIndicator: true
      # 링 버퍼의 닫힌 상태 크리를 설정한다(기본값 100)
      ringBufferSizeInClosedState: 5
      # 링 버퍼의 반열린 상태 크기를 설정한다.(기본값 10)
      ringBufferSizeInHalfOpenState: 3
      # 열린 상태의 대기 시간을 설정한다.( 기본값 60,000ms)
      waitDurationInOpenState: 10s
      # 실패율 임계치를 백분율(%)로 설정한다( 기본값 50)
      failureRateThreshold: 50
      # 실패로 기록될 예외를 설정한다.
      recordExceptions:
        - org.springframework.web.client.HttpServerErrorException
        - java.io.IOException
        - java.util.concurrent.TimeoutException
        - org.springframework.web.client.ResourceAccessException

폴백 처리

이 패턴은 '중개자'로 원격 자원과 그 소비자 사이에 위치하기 때문에 실패를 가로채서 다른 대안을 취할 수 있다.
라이선싱 서비스는 현재 사용가능한 라이선싱 정보가 없을을 나타내는 객체를 반환해보도록 해보자

LicensingService.java

	// 서비스 호출이 실패할 때 호출되는 함수 하나를 정의한다 (fallbackMethod)
	@CircuitBreaker(name = "licenseService", fallbackMethod = "buildFallbackLicenseList")
	public List<License> getLicensesByOrganization(String organizationId) throws TimeoutException {
		logger.debug("getLicensesByOrganization Correlation id: {}",
				UserContextHolder.getContext().getCorrelationId());
		randomlyRunLong();
		return licenseRepository.findByOrganizationId(organizationId);
	}

	// 이 폴백 메서드에서는 하드코딩된 값을 반환한다.
	private List<License> buildFallbackLicenseList(String organizationId, Throwable t){
		List<License> fallbackList = new ArrayList<>();
		License license = new License();
		license.setLicenseId("0000000-00-00000");
		license.setOrganizationId(organizationId);
		license.setProductName("Sorry no licensing information currently available");
		fallbackList.add(license);
		return fallbackList;
	}

postman에서 확인해보자

포스트맨에서 호출하고 3분의 1확률로 타임아웃이 발생할 때 서비스 호출에서 예외가 발생하지 않아야하고, 대신 위 사진과 같이 더미 라이선스 값이 반환된다.

벌크헤드 패턴 구현

벌크헤드 패턴은 원격 자원 호출을 자체 스레드 풀에 격리해서 한 서비스의 오작동을 억제하고 컨테이너를 멈추지 않게 한다. Resilience4j는 벌크헤드 패턴을 위해 두 가지 다른 구현을 제공한다. 이 구현 타입에 따라 동시 실행 수를 제한할 수 있다

  • 세마포어 벌크헤드 : 세마포어 격리 방식으로 서비스에 대한 동시 요청 수를 제한한다. 한계에 도달하면 요청을 거부한다

  • 스레드 풀 벌크헤더 : 제한된 큐와 고정 스레드 풀을 사용한다. 이방식은 풀과 큐가 다 찬 경우만 요청을 거부한다.

아래 아키택쳐를 참고하자

구현방법은 아래와 같다.

bootstrap.yml

resilience4j.bulkhead:
  instances:
    bulkheadLicenseService:
      # 스레드를 차단할 최대 시간 (기본값 0)
      maxWaitDuration: 2ms
      # 최대 동시 호출 수 (기본값 25)
      maxConcurrentCalls: 20


resilience4j.thread-pool-bulkhead:
  instances:
    bulkheadLicenseService:
      # 스레드 풀에서 최대 스레드 수 (기본값은 Runtime.getRuntime().availableProcessors()이다/)
      maxThreadPoolSize: 1
      # 코어 스레드 풀 크기 (기본값은 Runtime.getRuntime().availableProcessors()이다/)
      coreThreadPoolSize: 1
      # 큐 용량
      queueCapacity: 1
      # 유휴 스레드가 종료되기 전 새 태스크를 기다리는 최대 시간 (기본값 20ms)
      keppAliveDuration: 20ms

LicenseService.java

		
        @CircuitBreaker(name = "licenseService", fallbackMethod = "buildFallbackLicenseList")
        // 벌크헤드 패턴을 위해 인스턴스 이름과 폴백 메서드를 설정한다
        // 기본 세마포어 방식에서 스레드 풀 방식으로 변경하려면 type= Type.THREADPOOL 를 추가한다
		@Bulkhead(name = "bulkheadLicenseService", type= Type.THREADPOOL, fallbackMethod = "buildFallbackLicenseList")
    	public List<License> getLicensesByOrganization(String organizationId) throws TimeoutException {
		logger.debug("getLicensesByOrganization Correlation id: {}",
				UserContextHolder.getContext().getCorrelationId());
		randomlyRunLong();
		return licenseRepository.findByOrganizationId(organizationId);
	}

재시도 패턴 구현

재시도 패턴은 서비스가 처음 실패했을 때 서비스와 통신을 재시도하는 역할을 한다. 고장이 나도 동일한 서비스를 한 번 이상 호출해서 기대한 응답을 얻을 수 있는 방법을 제공하는 것이다. 이 패턴의 경우 해당 서비스 인스턴스에 대한 재시도 횟수와 재시도 사이에 전달하려는 간격을 지정해야한다

bootstrap.yml

resilience4j.retry:
  instances:
    retryLicenseService:
      # 재시도 최대 횟수
      maxRetryAttempts: 5
      # 재시도 간 대기 시간
      waitDuration: 10000
      # 재시도 대상이 되는 예외 목록
      retry-exceptions:
        - java.util.concurrent.TimeoutException

LicenseService.java

		
        @CircuitBreaker(name = "licenseService", fallbackMethod = "buildFallbackLicenseList")
		@Bulkhead(name = "bulkheadLicenseService", type= Type.THREADPOOL, fallbackMethod = "buildFallbackLicenseList")
        // 재시도 패턴을 위해 인스턴스 이름과 풀백 메서드를 설정한다.
        @Retry(name = "retryLicenseService", fallbackMethod = "buildFallbackLicenseList")
    	public List<License> getLicensesByOrganization(String organizationId) throws TimeoutException {
		logger.debug("getLicensesByOrganization Correlation id: {}",
				UserContextHolder.getContext().getCorrelationId());
		randomlyRunLong();
		return licenseRepository.findByOrganizationId(organizationId);
	}

속도 제한기 패턴 구현

재시도 패턴은 주어진 시간 내 소비할 수 있는 양보다 더많은 호출로 발생하는 서비스 과부화를 막는다

AtomicRateLimiter 와 SemaphorBaseRateLimiter 라는 두 가지 구현체를 제공하며 RateLimiter의 기본 구현체는 AtomicRateLimiter이다

벌크헤드 패턴과 속도 제한기 패턴의 주요 차이점은 벌크헤드 패턴이 동시 호출 수를 제한하는 역할을 하고, 속도제한기는 주어진 시간 프레임 동안 총 호출 수를 제한할 수 있다는 것이다.

즉, 동시 횟수를 차단하고 싶다면 벌크헤드가 최선이지만 특정 기간의 총 호출 수를 제한하려면 속도 제한기가 더 낫다.

두 시나리오를 검토 하고 있다면 이 둘을 결합할 수도 있다.

구현방법은 아래와 같다.

bootstrap.yml

resilience4j.ratelimiter:
  instances:
    licenseService:
      # 갱신 제한 기간 동안 가용한 허용 수를 정의한다.
      limitForPeriod: 5
      # 갱신 제한 기간을 정의한다. (기본값 500ns 나노초)
      limitRefreshPeriod: 5000
      # 스레드가 허용을 기다리는 시간을 정의한다 ( 기본값 5s 초)
      timeoutDuration: 1000ms

LicenseService.java

		
        @CircuitBreaker(name = "licenseService", fallbackMethod = "buildFallbackLicenseList")
		@Bulkhead(name = "bulkheadLicenseService", type= Type.THREADPOOL, fallbackMethod = "buildFallbackLicenseList")
        @Retry(name = "retryLicenseService", fallbackMethod = "buildFallbackLicenseList")
        // 속도 제한기 패턴을 위한 인스턴스 이름과 폴백 메서드를 설정한다
		@RateLimiter(name = "licenseService", fallbackMethod = "buildFallbackLicenseList")
    	public List<License> getLicensesByOrganization(String organizationId) throws TimeoutException {
		logger.debug("getLicensesByOrganization Correlation id: {}",
				UserContextHolder.getContext().getCorrelationId());
		randomlyRunLong();
		return licenseRepository.findByOrganizationId(organizationId);
	}

ThreadLocal

ThreadLocal에서 일부 값을 정의하여 Resilience4j 애너테이션을 사용하는 메서드 전체에 전파되는지 확인할 수 있다.

스레드로 작업할 떄 특정 객체의 모든 스레드는 변수를 공유 하는데, 이것은 스레드를 안전하지 못하게 만든다. 자바에서 스레드를 안전하게 만드는 가장 흔한 방법은 동기화를 사용하는 것이다. 하지만 동기화를 피하고 싶다면 ThreadLocal 변수를 사용한다.

ThreadLocal을 사용하면 동일한 스레드에서만 읽고 쓸 수 있는 변수를 생성할 수 있다

예를 살펴보자.
REST 기반 환경에서는 서비스를 운영 관리하는데 도움이 될 수있는 컨텍스트 정보를 서비스 호출로 전달하고 싶을 때가 많다

REST 호출의 HTTP 헤더에 상관관계 ID 나 인증 토큰을 전달할수 있으며 이 토큰은 모든 다운스트림 서비스 호출로 전파 될 수 있다.
상관관계 ID는 한 트랜잭션에서 여러 서비스 호출을 추적할 수 있는 고유 식별자다

이는 서비스 호출 내 어디든 이 값을 사용할 수 있도록 스프링 Filter 클래스로 REST 서비스의 모든 호출을 가로챌 수 있다.

상관관계ID를 이용하면, 회복 패턴 에서도 로그을 남겨 어떤 서비스 경로에 문제가 있는지 찾아볼 수 있다.

라이선싱 서비스에 사용할 수 있는 스프링 필터를 만들어보자

UserContextFilter.java

package com.optimagrowth.license.utils;

import java.io.IOException;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@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 헤더에서 Context로 설정된 값을 조회한 후 UserContextHolder에 저장한다
        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().setOrganizationId(httpServletRequest.getHeader(UserContext.ORGANIZATION_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.java

package com.optimagrowth.license.utils;

import org.springframework.util.Assert;

public class UserContextHolder {
	// UserContext를 정적 ThreadLocal 변수에 저장한다.
    private static final ThreadLocal<UserContext> userContext = new ThreadLocal<UserContext>();

	// 사용되는 UserContext 객체를 조회한다.
    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();
    }
}

UserContext.java

package com.optimagrowth.license.utils;

import org.springframework.stereotype.Component;

@Component
public class UserContext {
    public static final String CORRELATION_ID = "tmx-correlation-id";
    public static final String AUTH_TOKEN     = "tmx-auth-token";
    public static final String USER_ID        = "tmx-user-id";
    public static final String ORGANIZATION_ID = "tmx-organization-id";

    private String correlationId= new String();
    private String authToken= new String();
    private String userId = new String();
    private String organizationId = new String();

    public String getCorrelationId() { return correlationId;}
    public void setCorrelationId(String correlationId) {
        this.correlationId = correlationId;
    }

    public String getAuthToken() {
        return authToken;
    }

    public void setAuthToken(String authToken) {
        this.authToken = authToken;
    }

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }
	public String getOrganizationId() {
		return organizationId;
	}
	public void setOrganizationId(String organizationId) {
		this.organizationId = organizationId;
	}

}

LicenseController 로그에 UserContextHolder.getContext().getCorrelationId() 호출하여 상관관계ID를 추적할 수 있다

LicenseController.java

	@RequestMapping(value="/",method = RequestMethod.GET)
	public List<License> getLicenses( @PathVariable("organizationId") String organizationId) throws TimeoutException {
		logger.debug("LicenseServiceController Correlation id: {}", UserContextHolder.getContext().getCorrelationId());
		return licenseService.getLicensesByOrganization(organizationId);
	}

postman을 이용하여 헤더에 값을 넣고 호출해보자

로그를 살펴보면 상관관계 ID 값이 지정해준 경로에 따라 로그가 출력되는 것을 볼 수 있다.

이번 챕터는 여기서 마무리한다.


🧨 다음 챕터에서는 스프링 클라우드 게이트웨이를 이용한 서비스 라우팅에 대해 알아보도록 하겠다.

profile
노옵스를향해

0개의 댓글