땃쥐네

[Spring Cloud] Netflix Eureka, Spring Cloud Gateway 사용해보기 본문

Spring/Spring Cloud

[Spring Cloud] Netflix Eureka, Spring Cloud Gateway 사용해보기

ttasjwi 2022. 11. 6. 18:30

 

마이크로 서비스 아키텍쳐(MSA, MicroService Architecture)에 흥미가 생겨서 Spring Cloud를 학습하기 시작했습니다.

 

DevOps의 철학 이해 및 실천에 있어서 MSA 학습은 좋은 경험이라는 생각이 들었습니다.

 

물론 MSA를 개인 프로젝트에 적용한다거나, 어디 가서 사용할 수 있는 기술 스택이라고 말하기엔 문제가 많을 듯하고 그저 경험으로만 삼기 위해 학습합니다. 생각 이상으로 고려할게 정말 많더라구요. 분산 트랜잭션 처리, Kafka, Jenkins와 같은 CI/CD 툴, ... 등등을 학습해야하는데 이것까지 너무 깊게 들어가면 취준생으로서 소모할 시간이 너무 늘어나게 되니까요.

 

이번 글에서는 Service Discovery, Gateway를 사용하여 간단한 마이크로서비스 아키텍처를 구축하는 것을 다뤄보려고 합니다. 이들 각 요소가 무엇인지 깊게 다루려면 거의 책의 한 챕터 이상을 할애해야할 정도로 양이 많은지라 모든 것을 깊게 다루지 못 하는 점은 양해부탁드립니다.


Eureka Service 프로젝트 생성 및 실행

 

서비스 디스커버리를 통해 마이크로서비스를 등록하고, 서비스 명을 통해 서비스의 구체적인 물리적 위치를 조회할 수 있습니다. Spring Cloud에서는 Netflix Eureka를 서비스 디스커버리로 제공하는데 이걸 사용해보겠습니다.

 

우선 서비스 디스커버리 역할을 할 Eureka Server를 생성하겠습니다.

 

start.spring.io 또는 Spring initializer를 통해 스프링 프로젝트를 생성하면 되고, Dependencies에 Eureka Server를 추가합니다.

 

별도로 build.gradle에 추가적인 설정을 추가하지는 않습니다.

 

application.yml

server:
  port: 8761

spring:
  application:
    name: my-discovery-service # 마이크로서비스 이름 -> 실제 등록은 대소문자 구분 없이 대문자로 등록됨

eureka:
  client:
    register-with-eureka: false # 유레카 서버 자기자신을 등록할 것인가? -> false
    fetch-registry: false # 유레카 서버로부터 인스턴스들의 정보를 주기적으로 가져올 것인가 -> false

server.port

등록하려는 서버의 포트 번호입니다. 8761번 포트에 바인딩합니다.

spring.application.name

유레카 서버의 서비스 이름입니다. 여기서는 my-discovery-service라고 등록하겠습니다.

서비스가 어떤 이름을 가지는지 이것으로 구분하기 위해 사용합니다.

eureka.client.register-with-eureka

유레카 서버에 해당 인스턴스를 등록하겠냐는 설정입니다. 유레카 서버 자기 자신을 등록하진 않을 예정이므로 false로 합니다.

eureka.client.fetch-registry

유레카 서버로부터 레지스트리의 정보를 주기적으로 가져와 캐싱하겠냐는 설정입니다. 유레카 서버 자기 자신이 레지스트리를 관리하므로 false로 합니다.

 

이 외에도, 고가용성을 위하여 여러 대의 유레카 서버를 기동하여 연결할 수 있는데, 이 부분은 Spring Cloud Netflix의 공식 문서를 참고하시기 바랍니다.

 

메인 클래스

@SpringBootApplication
@EnableEurekaServer
public class DiscoveryApplication {

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

}

메인 클래스에서, @EnableEurekaServer 를 달아주셔야 유레카 서버를 기동할 수 있습니다.

 

유레카 서버 실행

이 상태에서 유레카 서버를 기동시켜보시면 서버가 무사히 기동되는걸 확인할 수 있습니다.(Tomcat에서 기동됩니다.) http://localhost:8761 로 접속해보면 현재 유레카 서버의 상태 및, 등록된 서비스 정보들이 출력되는 것을 확인할 수 있습니다.

 

아직 저희는 서비스를 별도로 등록하지 않았는데요. 추가적으로 여러가지 역할의 서비스를 생성해 등록해봅시다.


MemberService 프로젝트

member 관련 기능을 수행하는 MemberService 마이크로 서비스를 생성하겠습니다. (이번 글에서는 단순히 형식상으로만사용합니다.)

 

Dependencies : Spring Web, Lombok, Eureka Discovery Client

 

- Spring Web : 전통적인 서블릿 기반 Spring MVC 웹 애플리케이션을 만듭니다.

- Lombok : Getter, Setter, 로깅 편의성 등을 제공하는 Lombok을 dependencies에 추가합니다.

- Eureka Discovery Client : 유레카 클라이언트로 등록하기 위해 의존성을 추가합니다.

 

application.yml

server:
  port: 0

spring:
  application:
    name: member-service

eureka:
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://localhost:8761/eureka
  instance:
    instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}}

server.port

여기서 포트 번호를 0으로 지정하면 애플리케이션 로딩 시점에 랜덤 포트에 바인딩하여 실행됩니다. 다른 포트와 충돌되지 않습니다. 구체적인 서버 포트를 개발자가 지정하지 않는 것이 특징인데 이를 통해 수평 확장을 유연하게 처리할 수 있게 합니다.

 

spring.application.name

현재 서비스의 이름을 지정합니다. 유레카에 등록할 예정인데 인스턴스의 유형을 이것으로 구분하기 때문에 적절하게 네이밍하는 것이 중요합니다. (참고로 대소문자를 구분하지 않고 대문자로 보이게 됩니다.)

 

eureka.client.register-with-eureka

현재 서비스를 유레카 레지스트리에 등록합니다.

 

eureka.client.fetch-registry

유레카 서비스의 레지스트리를 정기적으로 fetch해와서 로컬에 캐싱합니다. 어느 정도 간격으로 가져올 지 별도로 설정할 수 있는데 이것은 

 

eureka.client.service-url.defaultZone

유레카 서비스의 실제 url을 직접 등록해야합니다.

 

eureka.instance.instance-id

유레카 서비스 인스턴스 각각의 고유한 식별자입니다. 이것을 지정하지 않으면 인스턴스의 식별자가 localhost:member-service:0로 등록됩니다. 위에서 저희가 0번 포트로 명시한 그대로 기입되어 등록된 것입니다. 이렇게 하면 해당 서비스를 여러 대를 띄워도 식별자가 중복되기에 나중에 등록된 것으로 덮어씌워 등록됩니다. 따라서, 서비스명 + 랜덤한 문자열을 지정하여 구분할 수 있도록 합니다.

 

메인 클래스

//@EnableDiscoveryClient
@SpringBootApplication
public class MemberServiceApplication {

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

}

특별히 추가적으로 설정할 것은 없습니다.

해당 서비스를 디스커버리 클라이언트로 등록하려면 원래 명시적으로 @EnableDiscoveryClient를 걸어주는게 원칙이긴 한데, build.gradle에서 org.springframework.cloud:spring-cloud-starter-netflix-eureka-client 의존성이 등록되어 있으면 이 어노테이션을 굳이 달아줄 필요가 없습니다. 명시적으로 걸어주는 것을 선호한다면 이 어노테이션을 사용하시면 됩니다.

 

Member 컨트롤러

@Slf4j
@RequestMapping("/members")
@RestController
public class MemberController {

    @GetMapping("/health")
    public String health(HttpServletRequest request) {
        int port = request.getServerPort();
        log.info("port = {}", port);
        return String.format("[Member Service] port = %d", request.getServerPort());
    }
}

가볍게, 요청-응답을 처리할 수 있도록 MemberController를 생성하고 빈으로 등록합니다.

 

이곳으로 요청이 들어오면 서버의 실행 시 실제 포트 번호를 로깅하고 응답으로 포트 정보를 포함하여 응답하도록 하겠습니다.

 

복수의 인스턴스 실행

Run/Debug Configuration에서 기본적인 실행 설정을 복제하고(실행 설정을 선택한 상태에서 왼쪽 상단 세번째에 위치한 copy configuration버튼을 누르면 실행 설정이 복제됩니다.) 각각 1,2로 이름을 붙입니다.

 

Tomcat started on port(s): 3287 (http) with context path ''	

Tomcat started on port(s): 3305 (http) with context path ''

실제로 서비스 각각의 콘솔창을 확인해보면 하나는 3287 포트에서, 하나는 3305 포트에서 실행된 것을 확인할 수 있습니다.

 

서비스 주소 추상화 및 인스턴스 등록/삭제 자동화

서비스 인스턴스를 2개 실행하면, 위와 같이 MEMBER-SERVICE 서비스명 아래에 인스턴스 2개가 등록된 것을 확인할 수 있습니다.

 

서비스 인스턴스들이 MEMBER-SERVICE 라는 이름으로 추상화된 것인데요. 이제 이를 통해 서비스 디스커버리를 사용하는 각각의 마이크로 서비스들은 사용하는 다른 마이크로 서비스의 구체적 주소를 사용하지 않고 서비스명을 통해 접근할 수 있게 합니다.

 

그보다 더 중요한 점은, 유레카에 서비스별 인스턴스의 구체적인 주소/포트번호를 개발자 스스로 등록하지 않았다는 것입니다. 랜덤 포트 바인딩 설정을 통해 애플리케이션이 어느 포트에서 실행되는지 모름에도 불구하고, 마이크로 서비스 스스로 자신의 구체적인 주소를 유레카에 등록한 것입니다.

 

우리는 런타임에 각각의 마이크로 서비스를 매번 랜덤 포트에서 실행하고 자동으로 유레카에 등록시킬 수 있게 함으로서 수평확장을 좀 더 유연하게 처리할 수 있게 합니다. 이 방식을 사용하지 않았다면, 우리는 유레카에 수동적으로 서비스 인스턴스 주소를 등록하고 서비스 디스커버리를 중단시키고 다시 실행해야했을겁니다.

 

참고로, 도중에 애플리케이션을 종료하면 이후 유레카 레지스트리에 등록된 정보가 사라집니다. 언제든 손쉽게 각각의 서비스인스턴스를 등록하고, 중단할 수 있습니다.


Gateway 등록

저희가 구축할 마이크로서비스 아키텍처에서 제일 앞단에 위치하여 사용자 요청을 라우팅하는 역할을 하는 Gateway를 만들어보겠습니다. 예전에는 zuul을 사용했는데, Spring Boot 2.4 이후 Maintenance 상태가 되었기에 Spring 측에서는 Spring Cloud Gateway를 쓰는 것을 권장합니다.

 

 

이번엔 서비스에 접속할 때 거쳐가는 Gateway를 만들어보겠습니다. 서비스 인스턴스에 대한 요청을 이곳에서 받기 위함입니다.

 

Dependencies에는 Spring Cloud Gateway, Eureka Discovery Client를 추가합니다.

 

- Spring Cloud Gateway : 우리의 서비스의 Gateway 역할을 수행하도록 합니다. 

- Spring Netflix Eureka Client : 유레카 클라이언트로 등록하고, 레지스트리 정보를 가져오기 위해서 유레카 클라이언트 의존성을 추가합니다.

 

application.yml

server:
  port: 8000

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

spring:
  application:
    name: gateway-service
  cloud:
    gateway:
      routes:
        - id: member-service
          uri: lb://MEMBER-SERVICE
          predicates:
            - Path=/members/**

8000번 포트에서 게이트웨이를 실행하고, 유레카에 클라이언트로 등록하고 주기적으로 registry를 가져올 수 있도록 합니다.

 

gateway에서 routes에 라우팅 정보를 기입합니다. (/routes)

 

route들을 구별하기 위해 식별자인 id를 지정하고,

uri에서는 유레카에 등록한 서비스 명으로 접근할 수 있도록 lb://MEMBER-SERVICE로 지정합니다. 구체적인 주소를 지정하지 않습니다.

 

predicates에는 여러가지 조건을 지정할 수 있는데, 여기서는 /members/ 아래로 요청이 들어왔을 때 라우팅하도록 했습니다. 여기서 주의할 점은 조건을 '/members/**'로 걸었다면, 실제 포워딩된 할 서버가 localhost:8081이면 'localhost:8081/members/**'로 처리되어 포워딩되게 되는데, 실제 마이크로서비스에서도 반드시 요청의 엔드포인트를 '/members/' 하위에 만들어야합니다. (위의 MemberService 에서도 이와 같이 설정했습니다.)

 

이 외에도, filter 등을 설정하여 부가 로직(사전, 사후)등을 설정할 수 있는데 이 부분까지 이야기하게 되면 설명이 길어지므로 Spring Cloud Gateway 공식 문서를 대신 올리도록 하겠습니다.(추후 일정이 가능하면면 이 부분도 포스팅하도록 하겠습니다.) 이 부분에서 스프링 클라우드 게이트웨이의 동작 원리가 정리되어 있습니다.

 

 

Gateway 애플리케이션 실행

Netty started on port 8000

 

애플리케이션을 구동하면 8000번 포트에서 Netty가 실행됩니다. Tomcat은 요청마다 하나의 스레드가 할당되는 방식이라서 동시에 많은 요청을 처리시 곤란함이 많은데, Netty는 1개의 이벤트를 받는 스레드와 다수의 worker스레드로 동작하게 되고 비동기적으로 동시에 많은 요청을 처리할 수 있게 합니다.

 

 

유레카 서버 확인

유레카에 GATEWAY-SERVICE 및 MEMBER-SERVICE가 등록된 것을 확인할 수 있습니다.


Gateway를 통해 MEMBER-SERVICE 접근

Postman에서 탭 두개를 띄워놓고 http://localhost:8000/members/health로 각각 get 요청을 보냈습니다.

그랬더니 3287, 3305 번 포트가 찍혀서 응답이 오는 것을 확인할 수 있습니다.

 

port = 3287

port = 3305

 

실제로 인텔리제이의 Member Service 콘솔을 각각 확인해보면 각각 로그가 찍혀있는 것을 확인할 수 있습니다.

 

서비스 두개로 적절히 로드밸런싱이 된 것입니다.


전체 흐름

현재 아키텍처의 대략적인 개요입니다.

 

시스템 구축

최우선적으로 Eureka 서버를 기동하고, 각각의 마이크로 서비스(Gateway, MemberService, ...) 를 기동합니다.

 

유레카 클라이언트 - 마이크로 서비스의 지속적인 통신을 통한 레지스트리 최신화

마이크로 서비스들은 저희가 클라이언트로 설정했기 때문에 주기적으로 레지스트리를 유레카로부터 fetch한뒤 레지스트리를 로컬에 캐싱하고, 자신의 정보를 push합니다. 만약 이 와중에 정상상태를 전달하지 못 한 서비스는 가용 서비스 인스턴스 풀에서 제거되고, 서비스 레지스트리는 이를 갱신하여 최신화하고 궁극적인 일관성을 유지할 수 있습니다.

 

이 방식을 사용함으로서 도중에 유레카 서비스에 문제가 생기더라도, 캐싱해둔 레지스트리를 통해 다른 서비스에 접근할 수 있어서 약간의 안정성을 보장할 수 있습니다.

 

클라이언트 사이드 로드 밸런싱

이제 클라이언트들은 외부 마이크로 서비스를 찾을 때, 로컬에 캐싱한 레지스트리를 확인하고 라운드 로빈 부하 분산 알고리즘'과 같은 단순한 알고리즘을 사용하여 부하 분산을 시킵니다. 

 

문제점

클라이언트는 주기적으로 서비스 디스커버리 서비스와 소통하여, 서비스 인스턴스에 대한 캐시를 갱신하는데, 클라이언트 캐시는 궁극적으로 일관적이지만 레지스트리가 갱신되기 이전에 비정상 서비스 인스턴스를 호출할 위험은 항상 존재하게 됩니다. 이 과정에서 서비스 호출이 실패하면, 로컬 서비스 디스커버리 캐시가 무효화되고 서비스 디스커버리 클라이언트는 서비스 검색 에이전트에서 항목 갱신을 시도하게 됩니다.

 

이 부분에 대한 것은 이후 추가적으로 확인하여 다루도록 하겠습니다.

 


- 추천 서적 : 스프링 마이크로서비스 코딩 공작소 개정 2판

- 추천 강의 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA)

Comments