[토이프로젝트][MSA] 감정일기장-5 : Spring Cloud Eureka Server - Spring Cloud Gateway 연동

onlydev7777·2024년 9월 19일

☀️ 개요

Spring Cloud Eureka Server / Spring Cloud Gateway (Spring Eureka Client) 간 연동을 통해 마이크로서비스 서버 딕셔너리, 로드밸런싱, 로깅 추적, JWT 인증 을 수행하도록 한다.

  1. 클라이언트(웹브라우저 / FeignClient / REST API 등) 에서 Gateway 서버로 요청을 한다.
  2. 해당 요청에 상응하는 Gateway 필터가 수행된다.
    - JWT 인증이 필요한 요청은 JWT 인증을 수행한다.
  3. 모든 필터 검증이 완료 되면 Gateway와 연동된 Eureka Server 에 등록된 Eureka Client 목록 중 해당 요청에 상응하는 마이크로서비스로 Request 를 전달한다.
  4. 요청을 전달받은 마이크로서비스가 수행 후 Gateway 로 결과를 리턴 하고 Gateway 에서 응답한다.

1️⃣ Spring Cloud Eureka Server

1. @EnableEurekaServer

  • EurekaServerMarkerConfiguration 을 import 해서 EurekaServerAutoConfiguration 이 수행되도록 설정
  • @EnableEurekaServer 어노테이션으로 EurekaServer 관련 자동설정 활성화
  @Target(ElementType.TYPE)
  @Retention(RetentionPolicy.RUNTIME)
  @Documented
  @Import(EurekaServerMarkerConfiguration.class)
  public @interface EnableEurekaServer {

  }
  @SpringBootApplication
  @EnableEurekaServer
  public class DiscoveryServiceApplication {
    public static void main(String[] args) {
      SpringApplication.run(DiscoveryServiceApplication.class, args);
    }
  }

2. application.yml

  • register-with-eureka: false
    • Eureka 서버에서 해당 서버 등록 불가 설정 (Because Server)
  • fetch-registry: false
    • Eureka 서버에 등록된 인스턴스 목록을 가져오지 않도록 설정 (Because Server)
  • service-url.defaultZone : http://localhost:${server.port}/eureka/
    • Eureka Client와 연결 할 URL 주소
eureka:
  client:
    register-with-eureka: false
    fetch-registry: false
    service-url:
      defaultZone: http://localhost:${server.port}/eureka/

2️⃣ Spring Cloud Gateway - Eureka Client 설정

Spring Cloud Gateway 도 Eureka Client 에 등록 필요

1. @EnableDiscoveryClient

  • EnableDiscoveryClientImportSelector 을 import 해서 AutoServiceRegistrationConfiguration 이 수행되도록 설정
  • AutoServiceRegistrationConfiguration 에서 AutoServiceRegistrationProperties 활성화
  • EurekaClientAutoConfiguration 에서 AutoServiceRegistrationProperties Read 해서 Eureka 관련 설정 Read
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(EnableDiscoveryClientImportSelector.class)
public @interface EnableDiscoveryClient {
	boolean autoRegister() default true;
}

2. EnableDiscoveryClientImportSelector

EnableDiscoveryClientImportSelector -> AutoServiceRegistrationConfiguration -> AutoServiceRegistrationProperties 활성화

  @Order(Ordered.LOWEST_PRECEDENCE - 100)
  public class EnableDiscoveryClientImportSelector extends SpringFactoryImportSelector<EnableDiscoveryClient> {

      @Override
      public String[] selectImports(AnnotationMetadata metadata) {
          String[] imports = super.selectImports(metadata);

          AnnotationAttributes attributes = AnnotationAttributes
                  .fromMap(metadata.getAnnotationAttributes(getAnnotationClass().getName(), true));

          boolean autoRegister = attributes.getBoolean("autoRegister");

          if (autoRegister) {
              List<String> importsList = new ArrayList<>(Arrays.asList(imports));
              importsList.add("org.springframework.cloud.client.serviceregistry.AutoServiceRegistrationConfiguration");
              imports = importsList.toArray(new String[0]);
          }
          else {
              Environment env = getEnvironment();
              if (env instanceof ConfigurableEnvironment configEnv) {
                  LinkedHashMap<String, Object> map = new LinkedHashMap<>();
                  map.put("spring.cloud.service-registry.auto-registration.enabled", false);
                  MapPropertySource propertySource = new MapPropertySource("springCloudDiscoveryClient", map);
                  configEnv.getPropertySources().addLast(propertySource);
              }

          }

          return imports;
      }

      @Override
      protected boolean isEnabled() {
          return getEnvironment().getProperty("spring.cloud.discovery.enabled", Boolean.class, Boolean.TRUE);
      }

      @Override
      protected boolean hasDefaultFactory() {
          return true;
      }

  }

  @Configuration(proxyBeanMethods = false)
  @EnableConfigurationProperties(AutoServiceRegistrationProperties.class)
  @ConditionalOnProperty(value = "spring.cloud.service-registry.auto-registration.enabled", matchIfMissing = true)
  public class AutoServiceRegistrationConfiguration {

  }

3. EurekaClientAutoConfiguration

EurekaClientAutoConfiguration 에서 AutoServiceRegistrationProperties 빈 설정 체크 후 EurekaAutoServiceRegistration 빈 등록

	@Bean
	@ConditionalOnBean(AutoServiceRegistrationProperties.class)
	@ConditionalOnProperty(value = "spring.cloud.service-registry.auto-registration.enabled", matchIfMissing = true)
	public EurekaAutoServiceRegistration eurekaAutoServiceRegistration(ApplicationContext context,
			EurekaServiceRegistry registry, EurekaRegistration registration) {
		return new EurekaAutoServiceRegistration(context, registry, registration);
	}

4. application.yml

  • register-with-eureka: true
    • Eureka 서버에서 해당 서버 등록 할 수 있도록 설정 (Because Client)
  • fetch-registry: true
    • Eureka 서버에 등록된 Eureka Client 목록을 가져올 수 있도록 설정 (클라이언트 간 FeignClient 통신 시 필요)
  • service-url.defaultZone : http://localhost:9761/eureka/
  • Eureka Server와 연결 할 URL 주소
  • 9761 은 Eureka Server 포트
eureka:
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://localhost:9761/eureka

3️⃣ Spring Cloud Gateway 필터 설정

1. application.yml

  • spring.cloud.gateway.default-filters

    • 모든 라우트에 공통적으로 적용될 기본필터 정의
    • GatewayProperties 클래스의 List\<FilterDefinition> defaultFilters 필드 바인딩
    • DedupeResponseHeader 설정
      • DedupeResponseHeaderGatewayFilterFactory 에서 중복제거 처리
  • spring.cloud.gateway.globalcors

    • 모든 요청에 대한 CORS 설정
    • GlobalCorsProperties, CorsConfiguration 에서 설정 세팅
    • CorsWebFilter 에서 CORS 검증할 때 CorsConfiguration 읽어서 검증 처리
  • spring.cloud.gateway.routes

    • URL Path 별 사용자 정의 필터
    • GatewayProperties 클래스의 List\<RouteDefinition> routes 필드 바인딩
    • RouteDefinition 에 필터 별로 Path 설정 바인딩
spring:
  cloud:
    gateway:
      default-filters: # 모든 서비스에 적용되는 default-filter
        - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
        - name: LoggingFilter
          args:
            preLogger: true
            postLogger: true

      globalcors:
        cors-configurations:
          '[/**]':
            allowedOrigins: 'http://localhost:8081' # front-app url
            allow-credentials: true # JWT 나 쿠키를 사용해 메시지를 보내야 할 경우 true
            allowedHeaders: '*'
            allowedMethods: # 전체 메서드 명시
              - PUT
              - GET
              - POST
              - DELETE
              - OPTIONS
              - PATCH
            exposed-headers:
              - Authorization
              - Refresh-Token
            max-age: 3600

      
      routes: # predicates 에 매칭되는 uri, filter 정의
        - id: auth-service
          uri: lb://AUTH-SERVICE
          predicates:
            - Path=/auth/**, /member/**
          filters:
            - JwtValidationCheckFilter

        - id: ediary-diary
          uri: lb://EDIARY-DIARY
          predicates:
            - Path=/diary/**
          filters:
            - RemoveRequestHeader=Cookie
            #            - RewritePath=/diary/(?<segment>.*), /$\{segment}
            - RewritePath=/diary(?:/(?<segment>.*))?, /$\{segment}
            - JwtValidationCheckFilter

        - id: ediary-point
          uri: lb://EDIARY-POINT
          predicates:
            - Path=/point/**
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/point(?:/(?<segment>.*))?, /$\{segment}
            - JwtValidationCheckFilter

2. LoggingFilter

  • spring.cloud.gateway.default-filters 글로벌 설정 필터
  • AbstractGatewayFilterFactory 자식 클래스
  • Netty 기반 비동기 처리
  @Slf4j
  @Component
  public class LoggingFilter extends AbstractGatewayFilterFactory<LoggingFilter.Config> {

    public LoggingFilter() {
      super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
      return new OrderedGatewayFilter((exchange, chain) -> {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();

        if (config.isPreLogger()) {
          log.info("Logging Pre Filter : request id -> {}", request.getId());
        }

        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
          if (config.isPostLogger()) {
            log.info("Logging Post Filter : response code -> {}", response.getStatusCode());
          }
        }));
      }, Ordered.HIGHEST_PRECEDENCE);
    }

    @Setter
    @Getter
    public static class Config {

      private boolean preLogger;
      private boolean postLogger;
    }
  }

3. JwtValidationCheckFilter

  • 마이크로서비스로 로드밸런싱 되기 이전에 JWT의 유효성 검증을 수행하는 사용자 정의 필터
    • 인증 로직 공통화
      • Gateway 에서 기본 인증 수행
      • 마이크로서비스에서 중복 인증 로직 불필요
      • 마이크로서비스에서는 세부적인 권한 검사 및 추가 검증 수행
    • 성능 최적화
      • 마이크로서비스 통신 이전 JWT 검증 함으로서 인증 오류에 대한 통신 비용 절감
  • AbstractGatewayFilterFactory 자식 클래스
  • Netty 기반 비동기 처리
  @Slf4j
  @Component
  public class JwtValidationCheckFilter extends AbstractGatewayFilterFactory<JwtValidationCheckFilter.Config> {

    private static final String TOKEN_PREFIX = "Bearer ";
    private final Environment env;

    public JwtValidationCheckFilter(Environment env) {
      super(Config.class);
      this.env = env;
    }

    @Override
    public GatewayFilter apply(Config config) {
      return (exchange, chain) -> {
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getURI().getPath();

        //로그인, 회원가입 요청 Valid Check Pass!
        if (path.startsWith("/auth") || path.startsWith("/oauth2") || path.startsWith("/login/oauth2")) {
          return chain.filter(exchange);
        }

        HttpHeaders headers = request.getHeaders();
        if (!headers.containsKey(HttpHeaders.AUTHORIZATION)) {
          return onError(exchange, "No Authorization Header", HttpStatus.UNAUTHORIZED);
        }

        String bearerToken = headers.get(HttpHeaders.AUTHORIZATION).get(0);
        String decodedToken = URLDecoder.decode(bearerToken, StandardCharsets.UTF_8);
        if (!decodedToken.startsWith(TOKEN_PREFIX)) {
          return onError(exchange, "Invalid prefix in token", HttpStatus.UNAUTHORIZED);
        }
        String jwt = decodedToken.substring(7);

        if (!isJwtValid(jwt)) {
          return onError(exchange, "JWT Token is not valid", HttpStatus.UNAUTHORIZED);
        }

        return chain.filter(exchange);
      };
    }

    private Mono<Void> onError(ServerWebExchange exchange, String error,
        HttpStatus httpStatus) {
      ServerHttpResponse response = exchange.getResponse();
      response.setStatusCode(httpStatus);

      log.error(error);
      return response.setComplete();
    }

    private String resolveToken(String bearerToken) {
      String decodedToken = URLDecoder.decode(bearerToken, StandardCharsets.UTF_8);
      if (decodedToken.startsWith(TOKEN_PREFIX)) {
        return decodedToken.substring(7);
      }
      return null;
    }

    private boolean isJwtValid(String jwt) {
      String secretKey = env.getProperty("jwt.secret-key");
      SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));

      String subject = null;
      try {
        JwtParser parser = Jwts.parser()
            .verifyWith(key)
            .build();

        subject = parser
            .parseSignedClaims(jwt)
            .getPayload()
            .getSubject();

      } catch (Exception e) {
        log.error(e.getMessage(), e);
        return false;
      }

      if (subject == null || subject.isEmpty()) {
        return false;
      }

      return true;
    }

    public static class Config {

    }
  }

★ 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개의 댓글