프로젝트/기술적 선택

[기술적 선택] Spring Cloud Netflix Eureka & Spring Cloud Gateway

배발자 2023. 9. 6.
반응형

개요

프로젝트를 진행하면서 아쉬웠던 점은 조금 애매한 MSA 구조였다. 마이크로 서비스마다 서로 통신하는 것이 아니라 Front에서 A 서버로 요청을 진행하고 응답 데이터 반환 후 다시 Front에서 B 서버로 요청하면서 2번의 API 통신으로 비효율적으로 진행되었다. 즉, Front에서 A 서버의 요청 후 A 서버에서 B 서버로의 직접적인 통신은 없었다는 것이다. 

 

또한, 프로젝트를 진행하면서 Nginx를 통해 로드밸런싱을 진행하였지만, upstream을 통한 라우팅 블록이 끝이였고, 구체적으로 어떠한 마이크로 서비스들로 로드 밸런싱을 할 것인지에 대한 구체적인 설계는 진행하지 않았다.   

 

이번 포스팅은 이전에 진행했던 프로젝트를 다시 회고하면서 어떤 기술들을 도입하면 좀 더 유연한 아키텍처로 재탄생 시킬 수 있을지 학습하면서 알게된 내용을 정리하려고 한다.

 

- Spring Cloud Netflix Eureka  

- Spring Cloud Gateway 

- Spring Cloud Config

- Spring Cloud Bus 

 

여러 기술들 중에서도 최근 필자가 관심있는 것은 위의 기술들이다. 이번 포스팅에서는 Netflix EurekaSpring Gateway에 대해서 정리해보려고 한다. 

 

Service Discovery - Spring Cloud Netfilx Eureka

Service Discovery

보통 한 서비스에서 다른 서비스로 요청을 보낼 때 IP와 Port 정보를 이용하는데 클라우드 환경에서 IP와 Port 정보는 동적으로 변하게 된다. 예를 들어, 트래픽 증가시 k8s를 활용하여 오토 스케일링(Scale-Out)이 진행되면서 서버가 추가적으로 구동된다면 동적으로 변하는 정보에 따라 해당 서비스를 식별해야 한다. 

 

이처럼 서비스의 위치와 가용 상태를 관리하면서 클라이언트가 요청할 서비스를 식별이 가능하도록 해야한다. Service Discovery는 외부의 서비스들이 마이크로 서비스를 검색하기 위해 사용하는 전화번호부와 같은 역할이며, 각각의 마이크로 서비스가 어느 위치에 있는지를 동록해 놓은 곳이라고 보면 된다. 

 

Eureka 

마이크로 서비스 인스턴스의 목록과 위치(Host, Port)가 동적으로 변하는 MSA 환경에서 사용자가 각 서비스의 위치를 모두 관리하기는 어려울 것이다. Eureka는 중간 계층 서버의 로드 밸런싱 및 장애 조치를 목적으로 서비스를 찾기 위해 클라우드에서 주로 사용되는 REST(Representational State Transfer) 기반 서비스이다. 

 

흐름을 조금 더 설명하자면, 클라이언트가 요청을 보내면 API Gateway에 전달되고 Eureka 서버에 가서 필요한 서비스가 어느 곳에 있는지 찾고 해당 서비스로 호출하게 된다. 

 

Eureka는 등록된 모든 서비스의 정보를 registry로 관리하고, 이에 대한 접근 정보를 요청하는 서비스에게 목록을 제공한다. 

 

 

[Eureka Server 세팅] 

// build.gradle
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-server'
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryserviceApplication {

    public static void main(String[] args) {
        SpringApplication.run(DiscoveryserviceApplication.class, args);
    }
}
# application.yml 세팅

#유레카 서버가 웹 서버의 성격으로 기동이 된다.
server:
  port: 8761

#마이크로 서비스 고유한 ID -> 어플리케이션 네임
spring:
  application:
    name: discoveryservice

#유레카 클라이언트 설정. -> 유레카 라이브러리가 포함된 채 스프링이 기동되면 기본적으로 유레카 클라이언트 역할로서 어딘가에 등록하는 역할
#설정하지 않으면 default true로 설정된다. 기본적으로 현재 작업하는 것은 클라이언트 정보를 전화번호부에 등록한다.
#즉, 자신의 정보를 등록하지 않아도 돈다. 서버로서 기동만 하면 된다.
ureka:
  client:
    register-with-eureka: false
    fetch-registry: false

 

먼저 의존성을 주입받고 @EnableEurekaServer 어노테이션을 추가한다. 그리고 application.yml을 위와 같이 설정을 한다. 

이렇게 설정을 하면 전화번호부 책이 만들어진 것이다. 그리고 각 마이크로 서비스들이 생성될 때마다 등록되고 사라질 때마다 해제될 것이다. 이렇게 구성하고 서버를 돌리면 다음과 같은 화면이 뜰 것이다. 

 

 

"http://127.0.0.1:8761"

 

 

Eureka Client

Eureka에서 서비스의 정보를 레지스트리에 관리하기 위해서 각 마이크로 서비스에는 다음과 같이 설정해야한다.

* 마이크로 서비스(클라이언트)는 총 4개로 작업을 할 것이며 각각 독립된 환경의 서비스이다. (first-service 2개, second-service 2개)

 

 

[first-service 세팅]

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
@Slf4j
@RestController
@RequestMapping("/first-service/")
public class FirstServiceController {
    
    @GetMapping("/welcome")
    public String welcome(){
            
        return "Welcome to the First service.";
    }
#  포트 랜덤으로 돌려
server:
  port: 0

#  어플리케이션 네임으로 라우팅. 
spring:
  application:
    name: my-first-service

eureka:
  client:
# 유레카 서버에 등록 true  
    fetch-registry: true
    register-with-eureka: true
    service-url:
      defaultZone: http://localhost:8761/eureka
  instance:
    instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}}

 

[second-service 세팅]

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
@Slf4j
@RestController
@RequestMapping("/second-service/")
public class SecondServiceController {

    @GetMapping("/welcome")
    public String welcome() {
        
        return "Welcome to the Second service.";
    }
server:
  port: 0
  
spring:
  application:
    name: my-second-service
    
eureka:
  client:
# 유레카 서버에 등록 true  
    fetch-registry: true
    register-with-eureka: true
    service-url:
      defaultZone: http://localhost:8761/eureka
  instance:
    instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}}

 

  • port : 값을 0으로 주게 되면 매번 랜덤하게 사용가능한 포트. 
  • name : 프로젝트의 애플리케이션 이름
  • instance-id : 프로젝트를 유레카 서버에 등록 ID

 

instance id를 다르게 하는 이유는 같은 프로젝트를 여러 개 띄우게 되면 포트번호가 같기 때문에 여러 개 띄워지더라도 1개만 표시되는 현상이 발생한다. 따라서 실행되는 프로젝트마다 구분해주기 위해 표기되는 instance-id값을 변경하기 위해 port값을 0으로 랜덤값을 주게한다. 

 

first-service와 second-service는 각각 2개씩 구동시키면 총 4개의 클라이언트가 실행되었을 때 다음과 같은 화면이 유레카 서버 UI에 나와야한다. 

 

 

API Gateway - Spring Cloud Gateway

API Gateway

유레카 서버를 띄워 마이크로 서비스의 정보를 레지스트리로 보관하고 있다면, 이러한 목록을 보고 어디로 전화를 할 것인지 수행하는 친구가 하나 있어야하는데 그 친구가 API Gateway이다. 

 

API 게이트웨이는 API 서버 앞단에서 모든 API 서버들의 엔드포인트를 단일화하여 묶어주고 API에 대한 인증과 인가 기능에서부터 메세지에 따라서 여러 서버로 라우팅 하는 고급기능까지 많은 기능을 담당할 수 있다.

 

전체적인 흐름은 먼저 클라이언트가 요청을 보내면 API Gateway가 요청을 받아서 Service Discovery(Eureka)에서 마이크로 서비스의 위치 정보를 확인한다. 그리고나서 API Gateway가 해당 정보에 위치한 마이크로 서비스로 요청을 한다. 그리고 API Gateway로 응답을 반환하여 클라이언트에게 돌려준다. 

 

 

Spring Cloud Gateway 

Spring Cloud Gateway는 MSA 환경에서 사용되는 API Gateway 중 하나이며, Tomcat이 아닌 Netty를 사용한다. API Gateway는 모든 요청이 통과하는 곳이기 때문에 성능적인 측면이 매우 중요한데, Sping MVC과 같은 기존의 방식에선 하나의 요청에 하나의 스레드가 할당되어 성능적인 이슈가 발생할 수있다. 하지만 Netty는 비동기 WebAppicationServer이며 하나의 스레드로 많은 요청을 처리할 수 있는 방식이다. 

 

Spring Cloud Gateway의 구성은 크게 3가지이며 Route, Predicate, Filter가 있다. 

 

[Route]

Route는 API Gateway에서 가장 기본이 되는 요소로 요청할 서비스의 고유한 값인 id, 요청할 uri, Predicate, Filter로 구성되어 있다. 요청된 URI의 조건이 predicate와 일치하는지 확인 후, 일치하는 경우 해당 URI 경로로 요청을 매칭한다.

 

[Predicate]

API Gateway로 들어온 요청이 주어진 조건을 만족하는지 확인하는 구성요소이다. 하나 이상의 조건을 정의할 수 있으며, 만약 Predicate 조건에 맞지 않는 경우 HTTP 404 Not Found 응답을 반환한다.

 

[Filter]

API Gateway로 들어오는 요청에 대해 Filter를 적용하여 선처리 및 후처리를 할 수 있게 해주는 구성요소이다. 

 

클라이언트가 Spring Cloud Gateway에 요청을 하게 되면 gateway 내부의 predicate에서 어떤 서비스로 갈지 판단 후 요청을 전달한다. Filter는 작업 전에 수행되는 Pre Filter와 처리 이후 수행되는 Post Filter로 나누어진다.

 

 

spring cloud gateway를 활용하기 위해 "spring-cloud-starter-gateway " dependency를 추가해야 한다. 게이트웨이 동작을 위한 코드를 작성하는 방식에는 Java Code로 작성하는 방법과 application.yml에 작성하는 방법이 존재한다. 

 

 

[Gateway Server 기본 yml 설정]

# application.yml

server:
  port: 8000

eureka:
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://localhost:8761/eureka

 

(1) Config.java 

//정보 등록을 위해
@Configuration
public class FilterConfig {
    //bean 등록
    @Bean
    public RouteLocator gatewayRoutes(RouteLocatorBuilder builder) {
    // 매개변수로 전달되어있는 라우트 객체에다가 path정보를 등록
        return builder.routes()
                 //first-service라는 값이 요청이 오면 이동 작업
                .route(r-> r.path("/first-service/**")
                        //필터 적용해서, 필터 객체에 request header를 추가(key-value)
                        .filters(f -> f.addRequestHeader("first-request", "first-request-header")
                        //response Header 반환
                        .addResponseHeader("first-response", "first-response-header"))
                        //uri로 이동
                        .uri("http://localhost:8081"))
                 //second-service라는 값이 요청이 오면 이동 작업
                .route(r-> r.path("/second-service/**")
                        //필터 적용해서, 필터 객체에 request header를 추가(key-value)
                        .filters(f -> f.addRequestHeader("second-request", "second-request-header")
                                //response Header 반환
                                .addResponseHeader("second-response", "second-response-header"))
                        //uri로 이동
                        .uri("http://localhost:8082"))
                .build();
    }
}

 

config 파일에서 헤더에 key-value값을 설정하여 보내준다. 

 

(2) application.yml

server:
  port: 8000

eureka:
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://localhost:8761/eureka

spring:
  application:
    name: apigateway-service
  cloud:
    gateway:
      routes:
        - id: first-service
          uri: lb://MY-FIRST-SERVICE
          predicates:
            - Path=/first-service/**
          filters:
            - AddRequestHeader=first-request, first-request-header2
            - AddResponseHeader=first-response, first-response-header2
        - id: second-service
          uri: lb://MY-SECOND-SERVICE
          predicates:
            - Path=/second-service/**
          filters:
            - AddRequestHeader=second-request, second-request-header2
            - AddResponseHeader=second-response, second-response-header2

 

config 파일을 사용하지 않는다며 기본 application.yml 설정에서 위와 같이 수정하면 된다.  

 

 

[Custom Filter]

@Component
@Slf4j
public class CustomFilter extends AbstractGatewayFilterFactory<CustomFilter.Config> {

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

    @Override
    public GatewayFilter apply(Config config) {
        // Custom Pre Filter
        // exchange, chain 객체 받아.
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            ServerHttpResponse response = exchange.getResponse();

            log.info("Custom PRE filter : request id -> {}", request.getId());

            // Custom Post Filter
            //비동기 방식으로 지원할 때 단일값 Mono 타입을 전달.
            return chain.filter(exchange).then(Mono.fromRunnable(()-> {
                log.info("Custom POST filter : response code -> {}", response.getStatusCode());
            }));
        };
    }

    public static class Config {
        // configuration 정보 집어 넣기
    }

}
spring:
  application:
    name: apigateway-service
  cloud:
    gateway:
      routes:
        - id: first-service
          uri: lb://MY-FIRST-SERVICE
          predicates:
            - Path=/first-service/**
          filters:
            - CustomFilter

 

  • yml 에서 route별로 CustomFilter라고 명시하여 필요할 때 사용할 수 있다. 
  • AbstractGatewayFilterFactory를 상속받아야 하고 apply 메소드를 오버라이딩하여 필터가 수행할 동작을 정의해주어야 한다.
  • 비동기 서버 Netty에서는 동기 서버(ex:tomcat)와 다르게 request/response 객체를 선언할 때 Server~를 사용한다.
    • ServletHttpRequest -> ServerHttpRequest
    • ServletHttpResponse -> ServerHttpResponse

 

[Global Filter] 

 

@Component
@Slf4j
public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> {

    public GlobalFilter() {

        super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
        // Custom Pre Filter
        // exchange, chain 객체 받아.

        return (exchange, chain) -> {

            ServerHttpRequest request = exchange.getRequest();
            ServerHttpResponse response = exchange.getResponse();

            log.info("Global PRE baseMessage : {}", config.getBaseMessage());

            if (config.isPreLogger()) {
                log.info("Global PRE Start : request id -> {}", request.getId());
            }
            // Custom Post Filter
            //비동기 방식으로 지원할 때 단일값 Mono 타입을 전달.
            return chain.filter(exchange).then(Mono.fromRunnable(()-> {
                if (config.isPreLogger()) {
                    log.info("Custom Filter End : response code -> {}", response.getStatusCode());
                }
            }));
        };
    }

    @Data
    public static class Config {
        private String baseMessage;
        private boolean preLogger;
        private boolean postLogger;
    }
server:
  port: 8000

eureka:
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://localhost:8761/eureka

spring:
  application:
    name: apigateway-service
  cloud:
    gateway:
      default-filters:
        - name: GlobalFilter
          args:
            baseMessage: Spring Cloud Gateway Global Filter
            preLogger: true
            postLogger: true
      routes:
        - id: first-service
          uri: lb://MY-FIRST-SERVICE
          predicates:
            - Path=/first-service/**
          filters:
            - CustomFilter
        - id: second-service
          uri: lb://MY-SECOND-SERVICE
          predicates:
            - Path=/second-service/**
          filters:
            - CustomFilter

 

yml에서 CustomFilter라고 명시를 하였지만 Global Filter는 공통적인 필터를 사용할 때 한 번에 사용한다. 즉, CustomFilter와 생성하는 방법은 동일하지만, route 별로 적용하는 것이 아닌 공통적으로 실행되는 필터이며 모든 필터의 가장 첫번째로 실행이 되고 가장 마지막에 종료가 된다.

 

API 게이트웨이, 유레카, 마이크로서비스(4) 리스트

 

반응형

댓글