[토이프로젝트][MSA] 감정일기장-7 : 마이크로서비스 간 통신(FeignClient, Resilience4j, Zipkin)

onlydev7777·2024년 9월 23일

☀️ 개요

마이크로서비스 서비스 간 API 통신 시 발생할 수 있는 문제 상황에 대한 해결책으로 FeignClient, CircuitBreaker, Zipkin 을 사용한다.

해당 프로젝트에서는 사용자의 일기장 리스트(EDIARY-DIARY) 조회 요청 시
해당 사용자 정보(AUTH-SERVICE)와 해당 사용자의 포인트 정보(EDIARY-POINT) 를 FeignClient 통신해서 클라이언트로 응답 한다.

1️⃣ FeignClient

  • Netflix 에서 제공하는 HTTP 통신 추상화 라이브러리로 Spring Cloud와 통합되어서 사용됨
    • Spring Cloud Eureka Server + Spring Cloud Gateway 와 연동해서 로드밸런싱 지원
    • Spring Cloud CircuitBreaker Resilience4j 와 연동해서 페일오버, 리트라이 지원
  • 인터페이스 선언 방식의 호출로 코드 유지보수성 높음

1. EnableFeignClients

  • FeignClientsRegistrar 를 import 해서 CircuitBreakerAutoConfiguration 수행
  @Retention(RetentionPolicy.RUNTIME)
  @Target(ElementType.TYPE)
  @Documented
  @Import(FeignClientsRegistrar.class)
  public @interface EnableFeignClients {
      //...(중략)
  }

2. FeignConfig

  • FeignClient 호출 시 HTTP 헤더에 default 로 JWT 토큰 값 세팅 하기 위한 설정
@RequiredArgsConstructor
@Configuration
public class FeignConfig {

  @Bean
  public RequestInterceptor feignInterceptor() {
    return new AuthRequestInterceptor();
  }

  static class AuthRequestInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {
      template.header(HttpHeaders.AUTHORIZATION, TokenUtil.getToken());
    }
  }
}

3. FeignClient 인터페이스 선언

  • @FeignClient 의 name 속성은 Discovery 에 등록된 어플리케이션 이름을 설정
  @FeignClient(name = "auth-service")
  public interface AuthServiceClient {

    @GetMapping(value = "/member/{id}")
    ApiResult<MemberResponse> getMember(@PathVariable Long id);
  }

2️⃣ CircuitBreaker-Resilience4J 설정

FeignClient 와 연동해서 다른 마이크로서비스에 오류가 발생했을 때 오류 전파를 막기 위해 리트라이, 페일오버 기능 지원

1. Resilience4JConfig

  • CircuitBreakerConfig 설정
    • failureRateThreshold : 실패 임계치 설정
      • default : 50
    • slidingWindowType
      • 슬라이딩윈도우 타입을 Count로 가져갈지, Time 으로 가져갈지 설정
      • defualt : COUNT
    • slidingWindowSize
      • 슬라이딩 윈도우 크기 설정
      • default : 100
    • 아래와 같이 설정 시 최종 10번의 요청 중 4개 이상이 실패했을 때 4초동안 써킷브레이커 OPEN
  • TimeLimiterConfig 설정
    • 비동기 서비스 호출해서 timeoutDuration 설정 시간 동안 응답이 오지 않으면 페일오버 처리
  @Configuration
  public class Resilience4JConfig {

    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> globalCustomConfiguration(ContextAwareScheduledThreadPoolExecutor executor) {
      CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
          .failureRateThreshold(40)  //실패 임계치 % 설정
          .waitDurationInOpenState(Duration.ofSeconds(4)) //써킷 open 지속 시간
          .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)  //카운트 or 시간 기반으로 설정할 수 있음
          .slidingWindowSize(10)   // 카운트일 경우 사이즈 만큼의 마지막 요청 횟수로 실패 임계치 계산, 시간일 경우 사이즈 만큼의 seconds 로 call 집계해서 실패 임계치 계산
          .build();

      TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom()
          .timeoutDuration(Duration.ofSeconds(4)) //supplier 서비스(auth-service)의 타임아웃 오류 발생 기준 seconds
          .build();

      return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
          .timeLimiterConfig(timeLimiterConfig)
          .circuitBreakerConfig(circuitBreakerConfig)
          .build()
      );
    }
  }

2. DiaryController

  • 다이어리 정보 조회요청 시 회원 정보 API 를 호출해서 회원정보와 같이 응답
  • 써킷브레이커 run 메서드 수행
    • 페일오버 시 빈 객체 리턴
  @GetMapping("/{memberId}/month-list")
  public ResponseEntity<ApiResult<DiaryListResponse>> findDiariesByMonth(@PathVariable Long memberId, @RequestParam String diaryYearMonth,
      HttpServletRequest request) {
    log.debug("start /diary/{memberId}/month-list");
    List<DiaryResponse> diaryResponseList = service.findDiariesByMonth(memberId, diaryYearMonth).stream()
        .map(mapper::toResponse)
        .toList();

request.getHeader(HttpHeaders.AUTHORIZATION));
    log.info("Authorization Token : {}", TokenUtil.getToken());
    CircuitBreaker circuitbreaker = circuitBreakerFactory.create("circuitbreaker");
    MemberResponse memberResponse = circuitbreaker.run(
        () -> {
          log.info("Authorization Async Token : {}", TokenUtil.getToken());
          return authServiceClient.getMember(memberId).getResponse();
        },
        throwable -> {
          throwable.printStackTrace();
          return new MemberResponse();
        }
    );

    PointResponse pointResponse = circuitbreaker.run(
        () -> pointServiceClient.getPointByMemberId(memberId).getResponse(),
        throwable -> {
          throwable.printStackTrace();
          return new PointResponse();
        }
    );

    memberResponse.setPointResponse(pointResponse);
    Payload payload = TokenUtil.getPayload();
    log.debug(payload.toString());
    log.debug("end /diary/{memberId}/month-list");

    return ResponseEntity.ok(ApiResult.OK(
        new DiaryListResponse(diaryResponseList, memberResponse)
    ));
  }

⛔️ 오류 Case 발생

🤔 문제원인 파악

  • FeignConfig 에서 HttpHeader 의 Authorization 값을 SecurityContext 에 담겨있는 토큰 값으로 설정 하였으나 해당 토큰 값이 이전 요청의 토큰 값임을 확인
  • Resilience4JConfig 의 TimeLimiterConfig 설정으로 인해 CircuitBreaker run 메서드가 새로운 스레드로 비동기 방식으로 동작 확인

😋 해결방안 도출

1) SecurityContextHolder 전략 MODE_INHERITABLETHREADLOCAL 로 수정

  • 자식 스레드로 SecurityContext 전파 하도록 수정
    -> 동일 오류 발생
    SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);

2) ContextAwareScheduledThreadPoolExecutor 의 contextPropagators 설정

  • 스레드 생성 시 SecurityContext 가 전파 되도록 SecurityContextPropagator 구현체 설정
    -> Token 값이 Thread-Safe 하게 설정 되었음을 확인

1. SecurityContextPropagator

  • 새 스레드 생성 시 현재 스레드의 SecurityContext 값 설정
  • 새 스레드 만료 시 새 스레드의 SecurityContext 값 clear
  public class SecurityContextPropagator implements ContextPropagator<SecurityContext> {

    @Override
    public Supplier<Optional<SecurityContext>> retrieve() {
      return () -> Optional.ofNullable(SecurityContextHolder.getContext());
    }

    @Override
    public Consumer<Optional<SecurityContext>> copy() {
      return context -> SecurityContextHolder.setContext(context.orElse(null));
    }

    @Override
    public Consumer<Optional<SecurityContext>> clear() {
      return context -> SecurityContextHolder.clearContext();
    }
  }

2. Resilience4JConfig 설정 수정

  • ContextAwareScheduledThreadPoolExecutor 빈 등록
    • 쓰레드 풀 사이즈 : 8 설정
    • contextPropagators 에 SecurityContextPropagator 설정
@Configuration
public class Resilience4JConfig {

  @Bean
  public ContextAwareScheduledThreadPoolExecutor contextAwareScheduledThreadPool() {
    return ContextAwareScheduledThreadPoolExecutor.newScheduledThreadPool()
        .corePoolSize(8)
        .contextPropagators(new SecurityContextPropagator()).build();
  }

  @Bean
  public Customizer<Resilience4JCircuitBreakerFactory> globalCustomConfiguration(ContextAwareScheduledThreadPoolExecutor executor) {
    CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
        .failureRateThreshold(40)  //실패 임계치 % 설정
        .waitDurationInOpenState(Duration.ofSeconds(4)) //써킷 open 지속 시간
        .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)  //카운트 or 시간 기반으로 설정할 수 있음
        .slidingWindowSize(10)   // 카운트일 경우 사이즈 만큼의 마지막 요청 횟수로 실패 임계치 계산, 시간일 경우 사이즈 만큼의 seconds 로 call 집계해서 실패 임계치 계산
        .build();

    TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom()
        .timeoutDuration(Duration.ofSeconds(4)) //supplier 서비스(auth-service)의 타임아웃 오류 발생 기준 seconds
        .build();

    return factory -> {
      factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
          .timeLimiterConfig(timeLimiterConfig)
          .circuitBreakerConfig(circuitBreakerConfig)
          .build()
      );
      factory.configureGroupExecutorService(group -> executor);
      factory.configureExecutorService(executor);
    };
  }
}

3️⃣ Zipkin 연동

Zipkin 연동해서 traceId 를 통해 마이크로서비스 간 요청 로깅을 추적한다.

Zipkin

  • management.tracing.sampling.probability
    • 모든 요청을 Zipkin에 전송하여 트레이싱
  • management.propagation.consume
    • 트레이스 헤더 수신 시 B3 방식으로 처리
      • B3 방식 : 서비스 A -> B -> C 순으로 호출 시 C의 spanId는 B의 spanId 와 같음
  • management.propagation.produce
    • 트레이스 헤더 전송 시 B3_MULTI 방식으로 처리
      • B3_MULTI 방식 : 서비스 A -> B -> C 순으로 호출 시 C의 spanId는 B의 spanId 와 다름
        이를 구분하기 위해 X-B3-ParentSpanId 헤더 추가
  • management.zipkin.tracing.endpoint
    • Zipkin 에서 트레이싱 처리를 위해 제공하는 REST API 설정
  • logging.pattern.level
    • Zipkin 에서 모니터링 가능한 로깅 형태로 출력
      Zipkin Log
management:
  tracing:
    sampling:
      probability: 1.0
    propagation:
      consume: B3
      produce: B3_MULTI
  zipkin:
    tracing:
      endpoint: "http://localhost:9411/api/v2/spans"

logging:
  level:
    com.emotion.emotiondiarydiary.api: DEBUG
  pattern:
    level: '%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]'

★ Github

front-end("front-msa" 브랜치) : https://github.com/onlydev7777/emotion-diary-react
back-end : https://github.com/onlydev7777/emotion-diary-msa
inflearn-msa : https://github.com/onlydev7777/springboot-msa-3.0/tree/master

profile
https://github.com/onlydev7777

0개의 댓글