코드 작성
API 게이트웨이와 주울 구현
스프링 부트 애플리케이션이 주울 게이트웨이로 동작하게 하려면 메인 클래스에 @EnableZuulProxy 애노테이션을 추가하기만 하면 된다. 주울은 프로퍼티 파일에서 라우팅을 구성할 수 있어 매우 쉽고 간편하다.
참고)
책이 워낙 오래돼서(2019년이면 벌써 5년 전...) 책 속 예제를 적용하기 위해 이런저런 수정을 해줘야 했다! 일단 마이크로서비스 아키텍처를 이해하는 게 중요해서, 자바와 스프링 부트 버전을 낮춰서 예제가 일단 돌아갈 수 있게 변경해 주었다. 스프링 부트 3에서는 Zuul 대신 Spring Cloud Gateway를 사용하는 방법을 추천하고 있다.
스프링 부트 2로 변경하고, 해당 버전에 맞는 Spring Cloud 버전을 설정해 주었다. Gradle 버전을 6.8.3으로 변경한 지라 Java도 이에 맞춰 11 버전으로 변경해 주었다. Spring Cloud 버전은 아래 링크에서 참고했다.
Spring Cloud
Spring Cloud provides tools for developers to quickly build some of the common patterns in distributed systems (e.g. configuration management, service discovery, circuit breakers, intelligent routing, micro-proxy, control bus, short lived microservices and
spring.io
또한, 책에 나온 대로 게이트웨이 프로젝트에 WebConfiguration 클래스를 만들어 CORS를 설정하는 경우 jetty에서 접속 시에 다음과 같은 에러가 나와서, 관련 글을 작성한 블로그를 참고해서 CORS를 설정하지 않도록 변경해 주었다.
[ERROR] The 'Access-Control-Allow-Origin' header contains multiple values
1. 문제 A 도메인에서 B도메인으로 cors xmlhttprequest 요청을 보낼때 The 'Access-Control-Allow-Origin' header contains multiple values 에러가 발생할 수 있다. 예를 들어 S3로 호스팅하고 있는 React에서 Nginx+Spring Boot
minholee93.tistory.com
gradle - wrapper의 gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '2.3.10.RELEASE'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
}
group = 'me.progfrog'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(11)
}
}
repositories {
mavenCentral()
}
ext {
set('springCloudVersion', 'Hoxton.SR12')
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-zuul'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
package me.progfrog.mallang_gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
@SpringBootApplication
@EnableZuulProxy
public class MallangGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(MallangGatewayApplication.class, args);
}
}
application.yml
더 읽기 쉽도록 application.properties에서 변경
server:
port: 8000
zuul:
prefix: /api
routes:
multiplications:
path: /multiplications/**
url: http://localhost:8080/multiplications
results:
path: /results/**
url: http://localhost:8080/results
leaders:
path: /leaders/**
url: http://localhost:8081/leaders
stats:
path: /stats/**
url: http://localhost:8081/stats
# 참고) 주울에서 보안이 강화되어 아래 설정은 불가하도록 변경됨
#endpoints:
# trace:
# sensitive: false
ribbon:
eureka:
enabled: false
- 게이트웨이 서버 포트를 8000으로 변경했다. REST API 소비자를 위한 엔트리 포인트가 된다.
- 마지막 부분에서 ribbon.eureka.enabled 설정값을 false로 설정한다. 유레카와 리본을 아직 도입하지 않았기 때문이다! 애플리케이션을 차근차근 개선하자.
- 나중에 주울이 동작하는 것을 살펴보기 위해 주울의 /trace 엔드포인트에 접속할 때 인증이 필요 없도록 설정(sensitive: false)한다.
- zuul 이하 나머지 부분은 라우팅을 설정하는 부분
- 모든 요청의 접두사를 설정한다. 주울로 들어오는 모든 요청은 URL 내부에 리다이렉트 할 때 주울이 제거하는 부분이 있어야 한다. 예를 들어 예상되는 URL이 http://localhost:8000/api/multiplications이면 http://localhost:8080/multiplications로 리다이렉트 된다(/api 접두사는 제거된다). 이런 방법은 경로를 그룹화하고 다양한 정책을 적용하는 편리한 방법이다. 예를 들어 /internal과 /public 접두사를 사용하는 두 개의 게이트웨이 설정을 사용할 수 있다.
- 모든 URL 패턴을 적절한 서비스로 라우팅 하도록 구성한다(현재는 물리적 주소로 하드코딩되어 있다). 예를 들어, 곱셈 마이크로서비스에서 /multiplications와 /results 개체를 모두 관리하므로 두 개의 패턴이 동일한 서비스를 가리킨다.
주울의 매핑 결과는 다음과 같다.
요청 패턴 | 목적지 |
http://localhost:8000/api/multiplications/** | http://localhost:8080/multiplications/** |
http://localhost:8000/api/results/** | http://localhost:8080/results/** |
http://localhost:8000/api/leaders/** | http://localhost:8081/leaders/** |
http://localhost:8000/api/stats/** | http://localhost:8081/stats/** |
요청 패턴을 살펴보면 첫 번째 목표를 달성하는 방법을 알 수 있다. 이제 요청을 중앙에서 처리할 수 있다. API 소비자는 마이크로서비스에 대해 전혀 알지 못한다. 모든 요청은 http://localhost:8000을 통과한다.
이제 모든 것을 연결하자! 변경사항을 프론트엔드에 적용하기 위해 곱셈과 게임화 자바스크립트 클라이언트가 http://localhost:8000/에 있는 게이트웨이 서비스를 가리키도록 수정한다. 또한 접두사 api/를 모든 요청에 추가해야 한다. 두 파일에 있는 모든 API 요청에서 새로운 변수를 쓰도록 수정한다.
gamification-client.js/multiplication-client.js
var SERVER_URL = "http://localhost:8000/api";
백엔드 쪽에서는 게임화 서비스가 곱셈 서비스를 직접 호출하는 대신 API 게이트웨이를 통과하도록 수정한다.
mallang-gamification의 application.properties 수정
# REST Client
multiplicationHost=http://localhost:8000/api
아무튼 이 모든 고난을...다 지나고 나면, 시스템을 돌릴 수 있다.
- RabbitMQ 서버를 실행하기
- 게이트웨이 마이크로서비스 실행하기
- 곱셈 마이크로서비스 실행하기
- 게임화 마이크로서비스 실행하기
- 제티 웹 서버 실행하기
docker run -it -d --name mallang-rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3.13-management
특히 제티 웹 서버 쪽은 코드가 변경되었으므로, 이미지를 다시 구워 컨테이너를 띄워야 한다.
docker build -t mallang-jetty .
docker run -d -p 9090:9090 --name mallang-jetty mallang-jetty
곱셈과 게임화 서비스가 제대로 동작하기 위해 첫 번째 단계를 먼저 수행해야 하고, 2 ~ 5 번째 단계는 원하는 순서대로 실행하면 된다. 아래 그림은 해당 요청을 일치하는 서비스로 라우팅 하는 게이트웨이와 시스템의 현재 상태를 보여준다. 아직 서비스 디스커버리는 없지만 곧 추가할 예정이다.
서비스 디스커버리 구현
이번 장의 처음에 세운 계획에 따라 새로운 게이트웨이 서비스를 포함해서 서비스 디스커버리와 로드 밸런싱을 마이크로서비스에 추가하겠다. 그러면 특정 호스트와 포트 구성에 구속되지 않기 때문에 서비스를 안전하게 확장하고 분산시킬 수 있다.
먼저 서비스 레지스트리를 만들자. 이것도 생성 후에 자바랑 스프링 부트 버전을 수정해줘야 한다.
plugins {
id 'java'
id 'org.springframework.boot' version '2.3.10.RELEASE'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
}
group = 'me.progfrog'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(11)
}
}
repositories {
mavenCentral()
}
ext {
set('springCloudVersion', 'Hoxton.SR12')
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-server'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
이 서비스를 유레카 레지스트리 서버로 변환하기 위해 애플리케이션 클래스에 @EnableEurekaServer 애노테이션을 추가한다. 또한 스프링 부트의 서버 기본 포트인 8080을 유레카 클라이언트가 예상하는 기본 포트 8761로 변경한다. 다른 포트로 설정할 수 있지만, 그럴 경우에는 모든 마이크로서비스가 기본 포트를 오버라이드하도록 설정해야 한다. 또 한 가지 흥미로운 점은 유레카 레지스트리는 스스로를 8761 포트로 등록한다는 점이다. 따라서, 포트를 8761로 수정하지 않거나 유레카 레지스트리 인스턴스가 스스로 등록하는 기능을 비활성화하면(eureka.client.register-with-eureka=false) 유레카가 스스로를 찾을 수 없어서 실행 시 실패한다. 서버가 스스로 등록하는 건 좋은 방법이므로 기본 포트로 변경한다. 또한 나중에 확장하기도 좋다.
package me.progfrog.mallang_service_registry;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
@EnableEurekaServer
@SpringBootApplication
public class MallangServiceRegistryApplication {
public static void main(String[] args) {
SpringApplication.run(MallangServiceRegistryApplication.class, args);
}
}
server.port=8761
유레카로 서비스 레지스트리 만들기는 이걸로 끝이다. 이 마이크로서비스를 다른 스프링 부트 애플리케이션처럼 실행할 수 있지만 아직 우리 아키텍처와 연결되지 않았다. 이제 나머지 서비스(mallang, mallang-gamfication, mallang-gateway)에 유레카 클라이언트를 추가하고 새로운 유레카 서버로 정보를 전송할 차례이다. 먼저 각 서비스에 적절한 의존성을 추가한다. 수정해야 할 내용은 다음과 같다.
- 스프링 클라우드 종속성을 해결하기 위한 의존성 관리 블록
- 스프링 클라우드 버전을 참조하는 새로운 속성
- 서비스 디스커버리 의존성(spring-cloud-starter-netflix-eureka-client)
- 마이크로서비스의 상태를 노출하기 위한 추가적인 의존성(spring-boot-starter-actuator)
보통은 새로운 의존성(3, 4 단계)을 추가하기만 하면 된다. 하지만 곱셈과 게임화 서비스의 경우 스프링 클라우드 의존성을 처음 사용하기 때문에 첫 번째와 두 번째 작업도 필요하다.
동일한 작업을 곱셈 서비스와 API 게이트웨이에도 적용해야 한다. 대표적으로 게임화 서비스를 기준으로 추가할 코드를 정리해 보겠다.
build.gralde
액추에이터, 유레카 의존성 추가 및 스프링 클라우드 버전 추가
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.7'
id 'io.spring.dependency-management' version '1.1.5'
}
group = 'me.progfrog'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext {
set('springCloudVersion', '2023.0.2')
}
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-amqp'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.amqp:spring-rabbit-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
스프링 부트 애플리케이션에 스프링 부트 액추에이터(Spring Boot Actuator) 의존성을 추가하면 각종 측정 정보, 매핑, 상태 정보, 로거 등 모니터링에 유용한 몇 가지 엔드포인트를 자동으로 만든다.
서비스 디스커버리와 로드 밸런서가 /health 엔드포인트로 서비스 작동 여부를 확인하도록 구성해 보자. 이 엔드포인트는 외부 소비자의 접속을 막기 위해 API 게이트웨이에서 라우팅 하지 않는다(그리고 운영 환경에서는 인프라가 외부 접속을 확실히 막아야 한다).
레지스트리 클라이언트의 build.gradle을 구성한 후, 서비스 레지스트리를 사용하려는 각 스프링 부트 애플리케이션을 좀 더 수정한다.
- 서비스 디스커버리 에이전트를 활성화하려는 메인 애플리케이션 클래스에 @EnableEurekaClient(스프링 부트 3에서는 @EnableDiscoveryClient) 애노테이션을 추가한다.
- 유레카가 서비스 레지스트리를 찾을 수 있도록 application.properties에 몇 가지 설정을 추가한다.
- 서비스 이름을 설정한다.
package me.progfrog.mallang_gamification;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@EnableDiscoveryClient
@SpringBootApplication
public class MallangGamificationApplication {
public static void main(String[] args) {
SpringApplication.run(MallangGamificationApplication.class, args);
}
}
# Service Name
spring.application.name=mallang-gamification
# Service Discovery
eureka.client.service-url.default-zone=http://localhost:8761/eureka/
애노테이션과 설정 파일 수정 내역을 곱셈 서비스(애플리케이션 명은 mallang)와 게이트웨이(애플리케이션 명은 mallang-gateway)에도 추가해야 한다. 또한 애플리케이션 명이 일관되도록 서비스 레지스트리 마이크로서비스에도 추가해 주자(애플리케이션 명은 mallang-service-registry).
시스템 전체의 서비스 디스커버리 설정을 마무리하는 작업은 API 게이트웨이 서비스(주울)의 라우팅 설정을 변경하는 것이다. 이제 게이트웨이도 디스커버리 클라이언트로 레지스트리와 서비스 이름을 특정 주소로 매핑할 수 있으므로 주울과 유레카 서버를 연결할 수 있다.
spring:
application:
name: mallang-gateway
server:
port: 8000
zuul:
ignoredServices: '*'
prefix: /api
routes:
multiplications:
path: /multiplications/**
serviceId: mallang
strip-prefix: false
results:
path: /results/**
serviceId: mallang
strip-prefix: false
leaders:
path: /leaders/**
serviceId: mallang-gamification
strip-prefix: false
stats:
path: /stats/**
serviceId: mallang-gamification
strip-prefix: false
eureka:
client:
service-url:
default-zone: http://localhost:8761/eureka/
- 가장 큰 차이점은 모든 경로에 URL 대신 serviceId 속성을 사용한 것이다. 이건 가장 중요한 변화로써 유연성이 생겼다! 동적으로 위치를 변경하고 여러 인스턴스로 확장할 수 있는 서비스를 제공한다. 해당 속상 값은 spring.application.name에 설정한 값과 동일해야 한다.
- 명시적인 경로를 사용하기 때문에 strip-prefix 값을 false로 설정한다. 따라서, 지정된 경로에서 아무것도 제거할 필요가 없다. 동적 라우팅으로 URL에서 서비스 네임을 삭제하는 경우 이 속성은 기본적으로 true로 설정된다.
- ignoredServices 속성으로 주울이 유레카에 등록된 서비스의 경로를 동적으로 등록하지 않게 한다. 아직은 우리가 직접 경로를 설정한다.
- 게이트웨이에도 유레카 클라이언트를 생성해서 레지스트리에서 찾을 수 있다.
- 로드 밸런서(리본)를 비활성화하는 설정을 제거했다.
서비스 디스커버리와 게이트웨이의 진정한 목적
기본적으로 주울은 레지스트리 내 서비스를 자동으로 경로에 추가한다. 이런 기본 동작은 제대로 사용하지 않으면 위험하다! 앞서 이야기한 것처럼 zuul.ignoredServices 속성을 추가하지 않는다면 설정 파일에 아무것도 추가할 필요 없이 다음과 같은 라우팅 구성을 얻을 수 있다.
- /multiplication/multiplications/** -> 곱셈 마이크로서비스 + /multiplications/**
- /multiplication/results/** -> 곱셈 마이크로서비스 + /results/**
- /gamification/leaders/** -> 게임화 마이크로서비스 + /leaders/**
- /gamification/stats/** -> 게임화 마이크로서비스 + /stats/**
하지만 위와 같이 URL을 사용한다면 소비자가 마이크로서비스를 완전히 모른다고 할 수 없다. 소비자는 다시 내부 구조를 가리킬 것이고 API 게이트웨이의 중요한 장점 중 하나를 잃게 된다.
만약 우리가 하나의 비즈니스 개체로 모델링했다면 동적 라우팅을 사용할 수 있다. 예를 들어, 곱셈 마이크로서비스 안에서 기능을 나누고 새로운 '결과 관리자(results-manager) 마이크로서비스'로 뽑아내서 /results 엔드포인트를 제공한다고 상상해 보자. 그러고 난 후 컨트롤러의 코드를 리팩터링 해서 기능을 루트로 옮긴다.
예를 들어, @RequestMapping("/multiplications")을 간단하게 @RequestMapping("/")로 변경하는 식이다. 그런 다음 모든 것이 명확하게 동작하도록 하기 위해 곱셈(multiplication) 마이크로서비스의 애플리케이션 명을 곱셈들(mutiplications)이라고 변경하고, 가상의 결과 관리자 애플리케이션명을 results로 변경한다.
strip-prefix의 기본값은 true이므로, 서비스가 게이트웨이를 통과할 때 URL에서 서비스명은 제거된다. 다시 차례대로 살펴보면 다음과 같다.
- 클라이언트는 GET http://localhost:8000/api/multiplications/1로 요청
- 주울은 레지스트리로부터 동적으로 추가된 경로를 가지고 있어서 /multiplications(서비스명)는 곱셈 서비스로 매핑된다. strip-prefix 값이 true이므로 GET http://localhost:8000/1로 매핑된다.
- 루트 요청을 수락하기 위해 컨트롤러를 리팩터링 했으므로 ID가 1인 곱셈이 리턴된다. 마이크로서비스가 곱셈 개체만 처리하기 때문에 가능하다.
/results 엔드포인트에도 동일하게 적용된다. 더 나아가 게임화 마이크로서비스도 분리하고, 경로가 마법처럼 설정된 시스템이 될 것이다. 이렇게 게이트웨이와 서비스 디스커버리를 사용하는 방법은 안타깝게도 인터넷상의 가이드에도 퍼져있어 서비스 디스커버리의 진정한 목적을 혼란스럽게 한다.
현실에서는 마이크로서비스는 꼭 하나의 비즈니스 개체로 매핑되지 않는다.
반대로 API 게이트웨이 패턴은 요청을 마이크로서비스로 리다이렉트 하는 위치와 방법을 완전히 제어하도록 해준다.
그리고 서비스 디스커버리는 자동적으로 기능을 찾아주는 용도가 아니라 동일한 마이크로서비스의 하나 이상의 인스턴스 중 효율적으로 인스턴스를 찾고 로드 밸런싱하는 용도이다.
명시적으로 경로를 지정하고 싶지 않다면(마이크로서비스가 너무 많은 경우) 직접 RouteLocator 인터페이스 구현체를 활용하거나 직접 구현해서 동적으로 로드하는 방법이 있다.
서비스 디스커버리 가지고 놀기
마이크로서비스(서비스 레지스트리)를 추가하고 다른 서비스에서 접속하도록 수정했으니 이제 시스템에서 서비스 디스커버리와 API 게이트웨이가 함께 어떻게 동작하는지 확인해 보자.
새로운 요청을 받으면 주울은 디스커버리 클라이언트로 특정 serviceId에 해당하는 URL을 찾아 요청을 리다이렉트 한다. 물리적 주소가 명시되지 않아서 시스템 내에서 시스템을 이동 및 수정, 여러 인스턴스를 배포하는 등 유연성을 얻을 수 있다.
API 게이트웨이와 서비스 디스커버리 마이크로서비스를 도입해 드디어 목표를 달성했다!
시스템을 시작하기 단계에 서비스 레지스트리 실행 단계를 추가하자.
- RabbitMQ 서버를 실행하기
- 서비스 레지스트리 마이크로서비스를 실행하기
- 게이트웨이 마이크로서비스 실행하기
- 곱셈 마이크로서비스 실행하기
- 게임화 마이크로서비스 실행하기
- 제티 웹 서버 실행하기
이전과 마찬가지로 순서가 중요한 건 첫 번째 단계이다. 마이크로서비스 이전에 서비스 레지스트리를 시작하는 것은 편의를 위한 것이지 필수적인 건 아니다. 서비스 레지스트리를 먼저 시작하지 않더라도, 등록이 가능할 때까지 계속 시도하기 때문에 어쨌든 마이크로서비스는 동작한다.
게이트웨이와 서비스레지스트리의 상호 작용이 어느 정도 시간이 걸리기 때문에 모든 마이크로서비스를 부팅한 후 처음 1분 이내에 애플리케이션을 실행할 경우 게이트웨이에서 서버 오류(HTTP 500 상태 코드)가 발생할 수 있다. 이럴 때는 서비스 레지스트리가 작업할 시간을 조금 더 주면 된다. 이번 장의 후반부에서 서킷 브레이커(Circuit Breaker) 패턴을 이용해 이러한 오류를 처리하는 방법을 알아보자.
서비스 디스커버리 기능은 여러 가지 방법으로 확인할 수 있다. 먼저 마이크로서비스를 시작할 때 콘솔에 다음과 같은 메시지가 출력된다.
보다시피 유레카는 레지스트리가 살아있는지 확인한 후 서비스를 등록한다.
브라우저에서는 다른 서비스의 상태도 확인할 수 있다. 유레카 서버 인터페이스(유레카 서버 대시보드)는 http://localhost:8761/로 접속한다. 웹 페이지에서 다음과 같이 등록된 서비스의 정보와 레지스트리 자체에 대한 몇 가지 세부 정보를 볼 수 있다.
사용자 입장에서는 크게 바뀐 점은 없다. 답안을 보내고 리더보드를 갱신한다. 큰 변화는 그 뒤에 있다!
요청이 오면 API 게이트웨이가 레지스트리로 인스턴스를 찾고, 적절한 마이크로서비스로 리다이렉트 한다. 우리 시스템은 제대로 된 마이크로서비스 아키텍처로 발전하고 있다.
아직 서비스 디스커버리의 가장 멋진 부분 -동일한 마이크로서비스 인스턴스를 여러 개 실행하는 시스템 확장-을 남겨두고 있다.
우리의 마이크로서비스는 확장할 준비가 됐나요?
계속 진행하기 전에 잠깐 우리가 하려고 하는 일을 생각해 보자!
서비스를 여러 인스턴스로 실행하면 주울(API 게이트웨이)이 유레카와 서비스 레지스트리의 도움을 받아 각 요청을 어떤 인스턴스로 리다이렉트 할지 결정한다. 하지만 정말 그렇게 될까? 현재 마이크로서비스 구현에서 이렇게 동작할까?
확장 가능한 시스템을 개발할 때, 마이크로서비스의 중요한 기본 개념을 알아야 한다. 단순히 서비스 디스커버리와 라우팅 하는 도구를 추가한다고 해서 모든 것이 제대로 동작하는 것은 아니다. 현재 데이터 전략과 통신 인터페이스가 목표에 맞는지 따져보자.
데이터베이스와 무상태 서비스
먼저 서비스는 무상태(stateless)여야 한다. 무상태 서비스란 어떤 데이터나 상태를 메모리에 저장하지 않는다는 뜻이다. 그렇지 않으면 서비스와 세션은 밀접한 연관이 생긴다. 컨텍스트 정보를 유지하기 위해 같은 사용자에게서 온 모든 요청은 결국 같은 마이크로서비스 인스턴스로 보내야 한다. 애플리케이션이 너무 복잡해지는 것을 피하려면 항상 상태를 저장하지 않는 마이크로서비스를 설계하는 것이 좋다.
우리 시스템에서는 데이터베이스가 서비스에 내장되어 있어서 제대로 확장되지 않는다. 요청마다 다른 데이터를 조회할 것이기 때문에 모든 인스턴스가 자체 데이터베이스를 갖지 않게 해야 한다. 모든 인스턴스는 데이터를 같은 곳에 보관해야 한다. 즉, 같은 데이터베이스 서버를 공유해야 한다.
H2 데이터베이스는 자동 혼합 모드를 활성화하기만 하면 서버 모드로도 동작한다. 모든 JDBC URL 앞에 AUTO_SERVER=TRUE라고 접미사를 붙인다. 곱셈과 게임화 서비스의 JDBC URL에 모두 추가해야 한다.
spring.datasource.url=jdbc:h2:file:~/gamification;DB_CLOSE_ON_EXIT=FALSE;AUTO_SERVER=TRUE
새로운 데이터 전략으로 마이크로서비스 로직은 잘 확장되지만, 공유 인스턴스가 하나뿐이다. 운영 시스템에서 이런 점을 해결하려면 데이터베이스 계층에 클러스터를 만들고 확장하는 데이터베이스 엔진을 선택해야 한다. 우리가 선택한 DB 엔진에 따라 접근 방식이 달라진다. 예를 들어, H2는 데이터 복제를 기반으로 하는 간단한 클러스터링 모드를 제공한다. 그리고 MariaDB는 Galera를 사용해 로드 밸런싱을 제공한다.
코드 관점에서 볼 때 좋은 점은 데이터베이스 계층의 로직을 그대로 둔 채 간단한 JDBC URL을 이용해 데이터베이스 클러스터를 처리할 수 있다는 점이다.
이벤트 중심 아키텍처와 로드 밸런싱
REST 통신의 관점에서는 데이터베이스 엔진 덕분에 시스템이 제대로 작동한다고 볼 수 있다. 어떤 인스턴스가 요청을 처리하든 답안을 제출하거나 데이터를 조회하면 일관된 결과가 나온다. 하지만 두 마이크로서비스에 걸쳐서 진행된다면 어떨까?
우리 시스템은 곱셈 마이크로서비스에서 이벤트를 발생시키고, 게임화 마이크로서비스에서 소비하는 방식으로 답안에서 점수로 이어지는 비즈니스 프로세스를 처리한다. 그렇다면 하나 이상의 게임화 인스턴스를 시작하면 어떻게 될까?
이 경우에 별다른 수정 없이 잘 동작할 것이다. 모든 게임화 서비스 인스턴스는 RabbitMQ가 공유하는 큐에 연결되어 동작한다. 하나의 인스턴스만 이벤트를 소비하고 처리한 후, 공유하는 데이터베이스에 결과를 저장한다. 어쨌든 운영 환경이 되면 같은 이벤트를 두 번 받거나, 마이크로서비스가 갑자기 정지해 이벤트가 사라지는 것을 방지하기 위해 설정을 수정해야 할 것이다.
시스템에서 일어날 수 있는 여러 가지 이슈를 방지하고 처리하는 방법은 RabbitMQ 신뢰성 가이드를 참고하자.
Reliability Guide | RabbitMQ
<!--
www.rabbitmq.com
RabbitMQ는 클러스터에서도 동작한다. 운영 환경에서는 RabbitMQ 서버 인스턴스 중 하나가 정지하더라도 시스템은 계속 동작하도록 인프라를 구성해야 한다. 클러스터에 대한 하나의 데이터베이스 URL처럼 코드 관점에서는 달라지는 것이 없다. 하나의 인스턴스만 있는 것처럼 RabbitMQ에 연결된다.
리본으로 로드 밸런싱하기
이제 마이크로서비스를 확장할 준비가 되었으니 로드 밸런싱으로 여러 인스턴스 요청을 리다이렉트해 보자. 고가용성(또는 탄력성)을 제공하는 것은 분산 시스템에서 가장 중요한 기능이다. 서비스가 배포된 지리적인 영역 중 하나가 잘 응답하지 않거나 서비스가 트래픽으로 포화되는 등 서비스는 언제나 실패할 수 있다.
스프링 클라우드 넷플릭스의 리본은 스프링 부트로 로드 밸런싱을 구현하는 좋은 선택이다. 리본은 유레카와 함께 제공되고, 주울과도 잘 결합된다. 이러한 도구를 따로 다르고 있더라도 리본 없이 유레카를 사용(또는 로드 밸런싱 없이 서비스 디스커버리를 사용)하는 것은 일반적인 시나리오는 아니다. 왜냐하면 그런 경우에는 물리적인 위치(IP와 포트)에 서비스 별칭을 일대일로 매핑하는 이점만 얻을 수 있기 때문이다.
리본을 게이트웨이 서비스에 추가하기 위해 특별히 할 일은 없다. 리본은 기본적으로 유레카와 함께 제공되고, 스프링 부트로 자동 설정된다. 이 절의 후반부에서 추가로 설정하겠지만 지금은 기본 설정으로 진행한다.
로드 밸런싱이 잘 동작하는지 확인하기 위해 곱셈 서비스의 /random 엔드포인트에 서비스의 포트를 출력하는 로그를 추가한다. 롬복의 @Slf4j 애노테이션을 이용한다.
package me.progfrog.mallang.controller;
import lombok.extern.slf4j.Slf4j;
import me.progfrog.mallang.domain.Multiplication;
import me.progfrog.mallang.service.MultiplicationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/multiplications")
@Slf4j
final class MultiplicationController {
private final MultiplicationService multiplicationService;
private final int serverPort;
@Autowired
public MultiplicationController(final MultiplicationService multiplicationService,
@Value("${server.port}") int serverPort) {
this.multiplicationService = multiplicationService;
this.serverPort = serverPort;
}
@GetMapping("/random")
public Multiplication getRandomMultiplication() {
log.info("무작위 곱셈이 생성된 서버 @ {}", serverPort);
return multiplicationService.createRandomMultiplication();
}
}
결과 엔드포인트가 호출될 때도 로그를 출력한다.
package me.progfrog.mallang.controller;
import lombok.extern.slf4j.Slf4j;
import me.progfrog.mallang.domain.MultiplicationResultAttempt;
import me.progfrog.mallang.service.MultiplicationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/results")
@Slf4j
final class MultiplicationResultAttemptController {
private final MultiplicationService multiplicationService;
private final int serverPort;
@Autowired
public MultiplicationResultAttemptController(final MultiplicationService multiplicationService,
@Value("${server.port}") int serverPort) {
this.multiplicationService = multiplicationService;
this.serverPort = serverPort;
}
@PostMapping
ResponseEntity<MultiplicationResultAttempt> postResult(@RequestBody MultiplicationResultAttempt attempt) {
boolean correct = multiplicationService.checkAttempt(attempt);
MultiplicationResultAttempt attemptCopy = new MultiplicationResultAttempt(
attempt.getUser(),
attempt.getMultiplication(),
attempt.getResultAttempt(),
correct
);
return ResponseEntity.ok(attemptCopy);
}
@GetMapping
ResponseEntity<List<MultiplicationResultAttempt>> getStatistics(@RequestParam("alias") String alias) {
return ResponseEntity.ok(multiplicationService.getStatsForUser(alias));
}
@GetMapping("/{resultId}")
ResponseEntity<MultiplicationResultAttempt> getResultById(final @PathVariable("resultId") Long resultId) {
log.info("조회 결과 {} 를 가져온 서버 @ {}", resultId, serverPort);
return ResponseEntity.ok(multiplicationService.getResultById(resultId));
}
}
로드 밸런싱 가지고 놀기
전에 했던 것처럼 분산 시스템의 모든 서비스를 다시 시작한다. 모든 서비스를 실행한 후 곱셈 서비스의 두 번째 인스턴스를 실행한다. 이 서비스 인스턴스는 포트 번호로 연결되어 있다. 새로운 인스턴스를 시작하려면 충돌을 피하기 위해 포트 번호를 변경해야 한다.
이제 같은 데이터베이스를 공유하고, 두 개의 포트에서 실행되는 곱셈 서비스 인스턴스를 갖게 된다. 유레카 서버 대시보드에 접속하면 유레카에 등록된 두 개의 곱셈 마이크로서비스 인스턴스를 확인할 수 있다.
이제 두 인스턴스가 동작하는 것을 보자! UI 클라이언트로 접속해 /random 엔드포인트를 요청하기 위해 페이지를 몇 번 새로고침한다. 그리고 서비스 인스턴스가 8080과 8180 포트에서 실행되는지 로그를 확인한다. 그러면 리본이 간단한 라운드 로빈 전략으로 각 요청을 매번 다른 서비스에 리다이렉트 하는 모습을 확인할 수 있다. 이러한 로드 밸런싱 전략을 변경하는 방법은 뒤에서 알아보자.
이번에는 곱셈 서비스 인스턴스 중 하나를 종료시켜 보자. 이론상으로는 해당 인스턴스가 즉시 레지스트리에서 제거되고 모든 트래픽이 살아있는 인스턴스로 리다이렉트 될 것이다. 실제로는 페이지를 두 번 새로고침할 때마다 게이트웨이 로그에서 오류가 발생한다.
2, 3분 뒤 곱셈 요청을 다시 보내본다면 기대한 대로 동작한다. 모든 요청은 살아있는 인스턴스로 리다이렉트 된다. 그 기간 내에 유레카 대시보드에서 인스턴스가 더 이상 살아있지 않다는 것을 깨닫기까지 얼마나 걸리는지 확인할 수 있다. 다음에는 설정을 미세하게 조정해서 이러한 현상을 개선하는 방법을 알아보겠다!
해냈다! API 게이트웨이, 서비스 디스커버리, 로드 밸런서를 구현했다.
분산 시스템을 적절한 마이크로서비스 아키텍처로 개선했다. 게이트웨이, 서비스 디스커버리, 로드 밸런싱을 추가하고 UI와 분리했다. 세 번째 사용자 스토리의 솔루션을 잘 구현한 것이다! 물론 아직 더 개선해야 할 점이 남아있지만...
실운영 준비: 서비스 레지스트리 확장하기
실운영 단계에서 이런 시스템을 구현한다면 레지스트리에 대한 고가용성이 필요하다. 이러한 기능은 유레카 서버와 함께 제공된다.
https://cloud.spring.io/spring-cloud-netflix/reference/html/#spring-cloud-eureka-server-peer-awareness
원하는 인스턴스마다 프로파일을 만들기만 하면 된다.
로드 밸런싱 전략을 미세 조정하기
방금 확인한 것처럼 스프링 부트는 기본적으로 로드 밸런싱을 위한 리본을 라운드 로빈 전략으로 구성한다. 또한 상태 체크 메커니즘을 none으로 설정한다(IPing 인터페이스의 구현체인 NoOpPing을 주입한다). 즉, 로드 밸런서는 서비스가 살아있는지 확인하지 못한다(NoOpPing의 isAlive 메서드는 단순히 true를 반환한다). 개념적으로 보면 일리가 있다. 서비스 레지스트리인 유레카가 인스턴스를 등록하고 해지하기 때문이다.
하지만 유레카는 서비스가 갑자기 종료되는 것을 알아차리는 것이 매우 늦다. 유레카가 각 인스턴스에 연락해 상태를 확인하는 것이 아니라 각 인스턴스가 일정 시간(기본적으로 30초) 이후에 레지스트리에 연락해서 상태를 갱신한다(인스턴스가 "나 살아있음!!"이라고 말하는 것을 상상해 보자). 더 긴 시간(기본적으로 90초)이 지나면 서비스 레지스트리는 상태를 갱신하지 않는 인스턴스를 등록해제한다. leaseRenewalIntervalInSeconds 매개변수를 수정하는 것은 좋은 생각처럼 보이지만, 공식 문서에는 변경하지 않는 것이 좋다고 한다. 결과적으로 인스턴스가 갑자기 중지되면 레지스트리에서 즉시 제거되지 않고 몇 분 동안 애플리케이션은 장애가 발생한다.
Understanding eureka client server communication
AWS Service registry for resilient mid-tier load balancing and failover. - Netflix/eureka
github.com
리본은 서비스에 핑을 보내고 결과에 따라 로드 밸런싱을 하는 기능이 있다. 이 기능으로 문제를 해결할 수 있다! 이 기능을 위해서는 스프링 빈 두 개를 설정해야 하는데, 기본 상태 체크 메커니즘을 변경하는 IPing과 기본 로드 밸런싱 전략을 수정하는 IRule이다. 그리고 메인 클래스가 애노테이션으로 설정 클래스를 가리키게 한다.
package me.progfrog.mallang_gateway.configuration;
import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.AvailabilityFilteringRule;
import com.netflix.loadbalancer.IPing;
import com.netflix.loadbalancer.IRule;
import com.netflix.loadbalancer.PingUrl;
import org.springframework.context.annotation.Bean;
public class RibbonConfiguration {
@Bean
public IPing ribbonPing(final IClientConfig config) {
return new PingUrl(false, "/actuator/health");
}
@Bean
public IRule ribbonRule(final IClientConfig config) {
return new AvailabilityFilteringRule();
}
}
package me.progfrog.mallang_gateway;
import me.progfrog.mallang_gateway.configuration.RibbonConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.netflix.ribbon.RibbonClients;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
@EnableZuulProxy
@EnableEurekaClient
@RibbonClients(defaultConfiguration = RibbonConfiguration.class)
@SpringBootApplication
public class MallangGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(MallangGatewayApplication.class, args);
}
}
- RibbonConfiguration은 다른 방식으로 주입되기 때문에 @Configuration 애노테이션을 설정하지 않았다. 이를 참조하기 위해 메인 애플리케이션 클래스에 @RibbonClients라는 새로운 애노테이션을 추가한다. 로드 밸런싱 설정을 달리 한 여러 리본 클라이언트를 설정할 수 있다.
- PingUrl 구현체는 서비스가 살아있는지 확인한다. 엔드포인트를 알고 있으니(스프링 액추에이터에 포함되어 있다), /actuator/health를 가리키도록 설정한다. false 플래그는 엔드포인트의 보안이 안전하지 않다는 것을 의미한다.
- AvailabilityFilteringRule은 기본적인 RoundRobinRule의 대안이다. 이 룰 또한 인스턴스를 돌아가면서 선택하지만 가용성도 고려한다. 핑을 보내 체크하고, 응답하지 않는 인스턴스는 건너뛴다.
지금 테스트를 하면 등록된 여러 인스턴스 중 하나를 중지했을 때 살아 있는 유일한 인스턴스로 트래픽이 리다이렉트 되는 반응 시간이 훨씬 줄어들었다는 것을 확인할 수 있다. 물론 그 시간 내에 유레카 서비스 레지스트리의 상태는 완전히 전과 동일하다. 인스턴스를 등록해제하는 데는 여전히 시간이 걸린다. 클라이언트 측(로드 밸런서)에 개선점이 있다. 게이트웨이가 인스턴스를 체크하고, 동작하지 않으면 다른 인스턴스를 선택한다.
이 같은 설정은 예제일 뿐이며, 공식 저장소에서 다른 로드 밸런싱 전략을 찾을 수 있다. 응답 시간, 지리적인 여건 등에 따라 로드 밸런싱을 구현할 수 있다. 적절한 운영 환경을 위한 제일 좋은 아이디어는 계획대로 설계하고, 테스트(부하를 주고 결과를 모니터링하기!)를 해본 후 결과에 따라 조정하는 것이다.
ribbon/ribbon-loadbalancer/src/main/java/com/netflix/loadbalancer at master · Netflix/ribbon
Ribbon is a Inter Process Communication (remote procedure calls) library with built in software load balancers. The primary usage model involves REST calls with various serialization scheme support...
github.com
다음 그림은 서비스 디스커버리와 로드 밸런싱을 추가한 논리적 관점을 보여준다.
서킷 브레이커와 REST 클라이언트
하이스트릭스를 이용한 서킷 브레이커 구현
현실에서는 에러가 발생한다. 서비스에 연결할 수 없거나 제시간에 응답하지 못할 수 있다. 이 경우 분산 시스템의 일부분이 응답을 못한 것이므로 전체 시스템이 장애가 나서는 안된다. 서킷 브레이커 패턴은 일부분이 응답하지 않아 시스템 전체가 장애가 나는 시나리오에 대한 해결책이다.
이 패턴은 두 개의 상태를 기반으로 한다. 회로가 닫혀있으면, 요청이 목적지에 도달하고 응답을 수신한다는 뜻이다. 만약 오류가 있거나 타임아웃이 일어나서 연결점이 중단되면 회로가 열린다. 이는 시스템의 다른 부분을 호출한다는 뜻이다. 이 부분은 교체 선수처럼 행동하며 장애가 일어났을 때 기본적인 응답을 제공한다. 더 이상의 에러 없이 시스템이 처리할 수 있는 관리 가능한 응답이다.
스프링 클라우드 넷플릭스는 서킷 브레이커 패턴의 구현체인 하이스트릭스를 포함하고 있다. 이 프로젝트에서는 게이트웨이에도 연결할 수 있으므로, 서비스가 응답하지 않을 때 기본 응답을 제공할 수 있다.
하이스트릭스와 주울
주울과 하이스트릭스를 연결하려면 FallbackProvider 인터페이스를 구현해서 빈으로 설정을 제공해야 한다. 스프링 부트 컨텍스트에서 이 인터페이스를 구현한 빈을 주입하면 API 게이트웨이에서 서비스를 이용할 수 없을 때 하이스트릭스 폴백(사전에 정의한 HTTP 응답)을 제공할 수 있다. 주울이 요청을 리다이렉트 하지 못하면 특정 서비스에 대한 폴백이 있는지 확인하고, 폴백이 있으면 기본 응답을 구성해서 반환한다.
곱셈 서비스에 적용해보자(실제로는 폴백을 제공할 수 있는 모든 경로에 대해 작업을 해야 한다). /random을 호출했는데, 서비스를 사용할 수 없다면 인수 A에 오류 메시지를 담아 보내서 곱셈 문제 대신 출력한다.
package me.progfrog.mallang_gateway.configuration;
import org.springframework.cloud.netflix.zuul.filters.route.FallbackProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpResponse;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
public class CustomFallbackProvider implements FallbackProvider {
@Override
public String getRoute() {
return "mallang";
}
@Override
public ClientHttpResponse fallbackResponse(String route, Throwable cause) {
return new ClientHttpResponse() {
@Override
public HttpStatus getStatusCode() throws IOException {
return HttpStatus.OK;
}
@Override
public int getRawStatusCode() throws IOException {
return HttpStatus.OK.value();
}
@Override
public String getStatusText() throws IOException {
return HttpStatus.OK.toString();
}
@Override
public void close() {
}
@Override
public InputStream getBody() throws IOException {
return new ByteArrayInputStream("{\"factorA\":\"죄송합니다, 서비스가 중단되었습니다!\",\"factorB\":\"?\",\"id\":null}".getBytes());
}
@Override
public HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccessControlAllowCredentials(true);
headers.setAccessControlAllowOrigin("*");
return headers;
}
};
}
}
package me.progfrog.mallang_gateway.configuration;
import org.springframework.cloud.netflix.zuul.filters.route.FallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class HystrixFallbackConfiguration {
@Bean
public FallbackProvider fallbackProvider() {
return new CustomFallbackProvider();
}
}
하이스트릭스는 주울과 통합하는 것 이상의 기능을 제공한다. @HystrixCommand와 @EnableCircuitBreaker 같은 애노테이션을 추가하고 폴백을 구성해 다른 REST 소비자에서도 사용할 수 있다. 이제 표준 REST 클라이언트와 어떻게 동작하는지 알아보자.
REST 클라이언트의 하이스트릭스
이 시스템에 서킷 브레이커가 잘 맞는 부분이 또 있다. 게임화 서비스가 곱셈 서비스의 REST API를 호출해 인수 중 하나가 행운의 숫자인지 확인하는 부분이다. 그 시점에 서비스에 접근할 수 없으면 비즈니스 프로세스는 실패할 수밖에 없다. 폴백 응답은 행운의 숫자를 포함하거나, 포함하지 않을 수 있다. 서비스가 중지되면 사용자가 배지를 받지 못할 테니 폴백 응답을 구현하자.
먼저 게임화 마이크로서비스에 하이스트릭스*를 추가한다.
* 하이스트릭스는 스프링 부트 3 버전에서 사용할 수 없으므로 추가하는 의존성 및 코드를 약간 달리하였다.
implementation 'io.github.resilience4j:resilience4j-spring-boot3'
implementation 'org.springframework.boot:spring-boot-starter-aop'
package me.progfrog.mallang_gamification.client;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import lombok.extern.slf4j.Slf4j;
import me.progfrog.mallang_gamification.client.dto.MultiplicationResultAttempt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
/**
* Multiplication 마이크로서비스와 REST로 연결하기 위한
* MultiplicationResultAttemptClient 인터페이스 구현체
*/
@Component
@Slf4j
public class MultiplicationResultAttemptClientImpl implements MultiplicationResultAttemptClient {
private final RestTemplate restTemplate;
private final String multiplicationHost;
@Autowired
public MultiplicationResultAttemptClientImpl(
final RestTemplate restTemplate,
@Value("${multiplicationHost}") final String multiplicationHost) {
this.restTemplate = restTemplate;
this.multiplicationHost = multiplicationHost;
}
@CircuitBreaker(name = "retrieveMultiplicationResultAttemptById", fallbackMethod = "defaultResult")
@Override
public MultiplicationResultAttempt retrieveMultiplicationResultAttemptById(final Long multiplicationResultAttemptId) {
return restTemplate.getForObject(
multiplicationHost + "/results/" + multiplicationResultAttemptId,
MultiplicationResultAttempt.class);
}
private MultiplicationResultAttempt defaultResult(final Long multiplicationResultAttemptId, Throwable throwable) {
log.info("defaultResult: Fallback method called due to exception: {}", throwable.toString());
return new MultiplicationResultAttempt("fakeAlias", 10, 10, 100, true);
}
}
실제 시나리오에서는 우리가 예상하는 대로 테스트하는 것이 쉽지 않다. UI를 통해 곱셈 문제의 정답을 보내고, 답안은 곱셈 마이크로서비스를 거치게 된다. 그러고 나서 MultiplicationSolvedEvent가 발생한 후에 서비스를 중지시켜야 게임화 서비스가 곱셈 서비스에 도달할 수 없다. 다행히 하이스트릭스에서 정의한 응답을 테스트할 수 있는 간단한 방법이 있다. 게임화 서비스 내 application.properties의 곱셈 호스트 값을 존재하지 않는 URL로 수정하기만 하면 된다.
# REST Client
multiplicationHost=http://localhost:8001/api
테스트를 해보면, 새로운 버전의 게임화 서비스는 잘못된 곱셈 서비스 URL을 가리키고 있기 때문에, 정답을 보낼 때마다 하이스트릭스가 동작해서 기본 응답을 반환한다.
페인으로 REST 소비자 만들기
페인으로 REST 서비스가 마치 우리 코드의 일부인 것처럼 소비할 수 있다. @FeignClients를 생성하고 요청과 메서드를 매핑하면 된다. 가장 큰 장점은 코드에서 @RestTemplates로 요청을 직접 처리하는 것을 피하고, 외부 인터페이스를 마치 우리 코드의 일부인 것처럼 처리할 수 있다는 점이다.
페인은 유레카, 리본, 하이스트릭스와 잘 어울린다. 페인 클라이언트는 유레카와 리본을 이용해 서비스를 찾고 로드 밸런싱을 수행한다. 또한 인터페이스 레벨에서 애노테이션을 이용해 하이스트릭스 폴백이 포함된 클래스를 지정한다.
우리 시스템에서 MultiplicationResultAttemptClientImpl 클래스를 제거하고 인터페이스와 애노테이션을 이용할 수도 있다. 물론 이 경우 하이스트릭스 폴백을 별도의 클래스로 옮겨야 한다.
프로젝트에서는 페인을 사용하는 장점이 크지 않기 때문에 사용하지 않는다! API 게이트웨이를 통하지 않고 서비스가 서로 직접 호출하는 경우에 유리하다.
[개인프로젝트/헬린이] 1. 메일 서버 연동
1. mailgun 가입 및 API key 발급 Transactional Email API Service For Developers | MailgunPowerful Transactional Email APIs that enable you to send, receive, and track emails, built with developers in mind. Learn more today!www.mailgun.com대시보
progfrog.tistory.com
마이크로서비스 패턴과 Paas
이번 절에서는 마이크로서비스 아키텍처에 적용해야 할 몇 가지 중요한 패턴을 알아보았다.
서비스 디스커버리, 로드 밸런싱, API 게이트웨이, 서킷 브레이커가 있었다. 그리고 이를 구현하는 도구와 스프링 부트에서 사실상 표준인 스프링 클라우드 넷플릭스를 살펴보았다. 그 과정에서 서비스 레지스트리 마이크로서비스와 API 게이트웨이 마이크로서비스를 시스템에 추가했다.
이 모든 패턴이 어떻게 동작하는지 이해했으니, 이런 의문이 든다. "마이크로서비스 아키텍처를 구축할 때마다 이런 작업을 해야할까?!?!?" "스프링 보다 더 상위 레벨에서 이 모든 걸 추상화해주는 프레임워크는 없을까?" "스프링 부트 애플리케이션 개발에만 집중하고 어딘가에 넣어서 즉시 실행할 수는 없을까?"
이 세 가지 질문에 대한 일반적인 답은 Paas(Platform as a Service) 솔루션으로 추상화하는 것이다. 이러한 플랫폼에는 서비스 디스커버리, 로드 밸런싱, 라우팅(API 게이트웨이)과 서킷 브레이커 패턴뿐 아니라 중앙 집중식 로깅과 통합 인증 등 여러 기능이 있다. 이런 플랫폼은 대부분 클라우드 형태로서 서비스 제공자는 해당 패턴과 함께 스토리지, CPU, 네트워크 등을 구독할 수 있는 요금제를 제공한다.
서비스 제공 업체에 따라 다양한 PaaS가 있다. 아마존 AWS, 구글 앱 엔진, 피보탈 클라우드파운드리, 마이크로소프트 애저 등이 있다. 이들은 모두 비슷한 서비스를 제공한다.
먼저 할 일은 마이크로서비스를 패키징하고(예를 들어 buildpack 등을 사용) 플랫폼에 직접 배포하는 것이다. 업로드한 마이크로서비스는 몇 가지 설정만 마치면 자동으로 검색된다. 서비스 레지스트리나 게이트웨이는 플랫폼의 일부이므로 배포할 필요가 없고, 몇 가지 라우팅 규칙과 로드 밸런싱 정책을 구성하기만 하면 된다. 데이터베이스와 메시지 브로커는 필요에 따라 투명하게 확장되는 탄력적인 서비스로 제공된다. 설치 마법사로 만든 후 제공되는 URL로 애플리케이션에서 접속할 수 있다.
이런 플랫폼 중 하나에 스프링 부트 애플리케이션을 배포하는 것이 얼마나 쉬운가는 CloudFoundry 가이드를 보면 알 수 있다. 공식 문서를 통해 서비스를 확장하는 것이 얼마나 쉬운지, 어떻게 동일한 호스트명에 여러 인스턴스를 할당해서 라우팅 하는지도 확인할 수 있다. 또한 피보탈에서 하이스트릭스 기반의 서킷 브레이커를 제공하는 것도 알 수 있다.
이 시점에서 중요한 것은 우리가 마이크로서비스를 설계할 때 필요한 것을 이미 알고 있다는 점이다. 따라서, 모든 선택 사항을 균형 있게 조정할 수 있고, 어디서 어떻게 패턴을 구현할지 결정할 수 있다. 요구사항, 일정, 예산에 따라 전부 구현하거나, 플랫폼과 프레임워크에 의존하는 솔루션을 선택할 수 있다.
정리
마이크로서비스의 가장 중요한 개념인 서비스 디스커버리, 로드 밸런싱, API 게이트웨이, 그리고 서킷 브레이커를 배웠다. 스프링 클라우드 넷플릭스에서는 이런 패턴을 구현해서 제공(유레카, 리본, 주울, 하이스트릭스)하고 이를 기존 서비스와 잘 연결해서 확장을 통한 고가용성을 얻을 수 있었다.
이런 도구가 왜 필요한지 이해하기 위해 여러 단계를 거쳤다! 그리고 이번 장의 후반부에서 점진적으로 코드에 적용하면서 로드 밸런싱과 서킷 브레이커 기능을 테스트했다.
요구사항과 예산이 맞다면 이런 패턴을 구현하는 PaaS 솔루션으로 프로젝트 개발 기간을 줄일 수 있다.
다음 장에서는 마이크로서비스 세계의 또 다른 과제인 엔드투엔드 통합 테스트에 초점을 맞춘다.