이 글은 인프런 Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) 강의를 들은 후에 정리한 글입니다.
(https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C-%EB%A7%88%EC%9D%B4%ED%81%AC%EB%A1%9C%EC%84%9C%EB%B9%84%EC%8A%A4/dashboard)
이 글에서 설명되는 내용들은 모두 실제 강의와 다른 Spring Boot 3.1.2 버전입니다.
API Gateway란?
API Gateway Service
각각의 Microservice에 요청되는 모든 정보에 대해서 일괄적으로 처리해 줌
클라이언트가 직접적으로 Microservice를 호출하지 않도록 함
제공하는 기능
인증 권한 부여, 서비스 검색 통합, 응답 캐싱, 일괄적인 정책 처리, 회로 차단 및 QoS 다시 시도, 속도 제한, 부하 분산(로드 밸런싱), 로깅/추적/상관관계, 헤더/쿼리 문자열 처리 및 청구 반환, IP 허용 목록에 추가
Netflix Ribbon
Spring에서 Microservice 간의 통신 방법
1. RestTemplate: 인스턴스를 생성하고, URL에 파라미터를 제공하여 다른 서비스 호출
2. Feign Client: 직접적인 URL을 제공할 필요 없이. Microservice의 이름을 등록하여 다른 서비스 호출
Ribbon
Client Side Load Balancer
ip와 포트 번호 필요 없이 서비스 이름으로 호출
Health Check: 서비스가 정상적으로 작동 중인지 확인
※ Ribbon은 SpringBoot 2.4에서 maintenance 상태. 즉, 더 이상 사용하지 않는 상태이다.
Netflix Zuul
Netflix Zuul
Gateway 역할을 함
Client에서 First Service와 Second Service를 호출할 때 이를 사용
Client가 직접적으로 Microservice에 정보를 넘기는 것이 아닌 Netflix Zuul을 통해 정보를 넘김
※ Netflix Zuul은 SpringBoot 2.4에서 maintenance 상태. 즉, 더 이상 사용하지 않는 상태이다.
Netflix Zuul - 프로젝트 생성
First Service와 Second Service를 생성한다.
원래는 Zuul을 직접 구현하기 위해 강의처럼 스프링부트 2.3.8 버전을 선택하여 구현해야 하지만, 현재의 버전과 함께 사용하기 위해 복잡한 버전 변경과 Zuul을 사용하는 부분은 직접 구현하지 않았다.
다음과 같은 dependency를 가지는 FIRST-SERVICE와 SECOND-SERVICE를 생성한다.
- Lombok
- Spring web
- Eureka Discovery Client
다음과 같이 FrstServiceController를 추가한다.
// FirstServiceController.java
@RestController
@RequestMapping("/")
public class FirstServiceController {
@GetMapping("/welcome")
public String welcome(){
return "Welcome to the First Service";
}
}
그리고 application.yml에 다음과 같이 추가한다.
# application.yml
server:
port: 8081
spring:
application:
name: my-first-service
eureka:
client:
fetch-registry: false
register-with-eureka: false
그리고 위에서 추가한 방법을 바탕으로 두 번째 서비스를 구현한다.
위에 대한 결과는 다음과 같다.
위와 같이 첫 번째 서비스와 두 번째 서비스가 모두 잘 구동됨을 확인할 수 있다.
Zuul 서비스에 관련된 추가적인 내용은 SpringBoot 2.4부터 maintenance 상태
즉, 더 이상 사용하지 않는 상태가 되었으므로, 이에 대한 내용은 추가하지 않겠다.
Zuul Filter에 대한 내용도 마찬가지이다. 따로 추가하지 않겠다.
※ Zuul Filter (https://blog.neonkid.xyz/208)
Spring Cloud Gateway란?
Spring Cloud Gateway를 사용해서 게이트웨이 서비스와 라우팅 서비스를 구현해 보자.
먼저 다음과 같은 Dependency들을 추가한다.
- Eureka Discovery Client
- DevTools
- Gateway
프로젝트 이름은 gateway-service로 한다.
application.yml에 다음과 같이 추가한다.
# application.yml
server:
port: 8000
eureka:
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://localhost:8761/eureka
spring:
application:
name: gateway-service
cloud:
gateway:
routes:
- id: first-service
uri: http://localhost:8081/
predicates:
- Path=/first-service/**
- id: second-service
uri: http://localhost:8082/
predicates:
- Path=/second-service/**
이전에 사용하지 않았던 추가된 정보들만 해보자면, predicates의 조건, 즉 경로의 조건을 만족하는 경우에 같이 지정된 uri로 라우팅 시켜주는 설정을 한 것이다.
구동 후 브라우저에서 다음과 같이 8000 포트의 조건을 만족하는 Path로 접속하면, 위에서 설정한 대로 FIRST-SERVICE와 SECOND-SERVICE가 나타남을 확인할 수 있다.
게이트웨이를 사용한 라우팅이 잘 되었음을 확인할 수 있다.
Spring Cloud Gateway - 프로젝트 생성
먼저 다음과 같은 Dependency들을 추가한다.
- Eureka Discovery Client
- Lombok
- Gateway
프로젝트 이름은 apigateway-service로 한다.
application.yml에 다음과 같이 추가한다.
# application.yml
server:
port: 8000
eureka:
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://localhost:8761/eureka
spring:
application:
name: apigateway-service
cloud:
gateway:
routes:
- id: first-service
uri: http://localhost:8081/
predicates:
- Path=/first-service/**
- id: second-service
uri: http://localhost:8082/
predicates:
- Path=/second-service/**
방금 위에서 진행했던 것과 동일하기에 설명을 생략한다.
Zuul에서는 동기 방식으로 Tomcat 서버를 사용하였지만, Spring Cloud Gateway에서는 비동기 방식으로 Netty 서버를 사용한다고 한다.
구동 후 브라우저에서 다음과 같이 8000 포트의 조건을 만족하는 Path로 접속하면, 위에서 설정한 대로 FIRST-SERVICE와 SECOND-SERVICE가 나타나는 것이 아닌 404 에러 페이지가 나타날 것이다.
왜냐하면 Gateway는 조건에 만족하는 path 자체를 uri에 붙여서 넘기기 때문에 우리는 http://localhost:8081/welcome으로 넘어갈 것으로 기대하였으나 실제로는 http://localhost:8081/first-service/welcome으로 넘어가기 때문이다.
그렇기에 FIRST-SERVICE를 다음과 같이 변경하고, SECOND-SERVICE도 같은 방식으로 변경한다.
// FirstServiceController.java
@RestController
@RequestMapping("/first-service")
public class FirstServiceController {
@GetMapping("/welcome")
public String welcome(){
return "Welcome to the First Service";
}
}
그 후 다시 구동하면, 다음과 같이 잘 라우팅 되었음을 확인할 수 있다.
Spring Cloud Gateway - Filter 적용
Spring Cloud Gateway의 필터에 대한 간단한 그림이다.
위의 그림에서 확인할 수 있듯이 Filter를 작동하는 방법에는 Property를 사용하는 것과, Java Code를 사용하는 것이 있는데 우선 Java Code를 사용하는 경우를 봐보자.
Java Code를 사용하는 방법
우선 application.yml에 gateway 관련 부분을 모두 주석 처리한다. 왜냐하면 yml에서 작동하던 filtering을 자바 코드를 사용하여 구현하도록 변경할 것이기 때문이다.
다음으로 config라는 패키지 안에 FilterConfig라는 새로운 클래스를 생성한다.
// FilterConfig.java
@Configuration
public class FilterConfig {
@Bean
public RouteLocator gatewayRoutes(RouteLocatorBuilder builder){
return builder.routes()
.route(r->r.path("first-service/**")
.filters(f->f.addRequestHeader("first-request", "first-request-header")
.addResponseHeader("first-response", "first-response-header"))
.uri("http://localhost:8081"))
.route(r->r.path("second-service/**")
.filters(f->f.addRequestHeader("second-request", "second-request-header")
.addResponseHeader("second-response", "second-response-header"))
.uri("http://localhost:8082"))
.build();
}
}
또한 header에 추가한 값들이 잘 나타나는지 확인하기 위해, FIRST-SERVICE를 다음과 같이 변경하고, SECOND-SERVICE도 같은 방식으로 변경한다.
// FirstServiceController.java
@RestController
@RequestMapping("/first-service")
@Slf4j
public class FirstServiceController {
...
@GetMapping("/message")
public String message(@RequestHeader("first-request") String header){
log.info(header);
return "Hello World in First Service";
}
}
변경 후 브라우저에서 확인해 보면 아래와 같이 나타난다.
FIRST-SERVICE의 콘솔창에는 아래와 같이 나타난다.
또한 헤더의 값으로 설정한 대로 잘 넘어오는 것도 확인할 수 있다.
Property를 사용하는 방법
우선 저번에 구현한 클래스를 사용한 filter를 비활성화하기 위해 ConfigFilter에서 Configuration, Bean 어노테이션을 주석처리한다.
이제 application.yml을 다음과 같이 수정한다.
# application.yml
...
spring:
application:
name: apigateway-service
cloud:
gateway:
routes:
- id: first-service
uri: http://localhost:8081/
predicates:
- Path=/first-service/**
filters:
- AddRequestHeader=first-request, first-request-header2
- AddResponseHeader=first-response, first-response-header2
- id: second-service
uri: http://localhost:8082/
predicates:
- Path=/second-service/**
filters:
- AddRequestHeader=second-request, second-request-header2
- AddResponseHeader=second-response, second-response-header2
이번에는 브라우저가 아닌 Postman을 사용해서 확인해 보겠다.
이처럼 Postman을 사용해 http://localhost:8000/first-service/message에 GET 요청을 보냈을 때 원하는 값이 나타나고, 헤더에도 설정했던 대로 값이 들어가 있음을 확인할 수 있다.
FIRST-SERVICE의 콘솔창에도 아래와 같이 의도한 대로 잘 나타난다.
http://localhost:8000/second-service/message에 GET 요청을 보내도, SECOND-SERVICE와 관련하여 의도대로 잘 작동함을 확인할 수 있다.
Spring Cloud Gateway - Custom Filter 적용
CustomFilter 클래스를 새로 추가한다.
// CustomFilter.java
@Component
@Slf4j
public class CustomFilter extends AbstractGatewayFilterFactory<CustomFilter.Config> {
public CustomFilter(){
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
// Custom Pre Filter
return (exchange, chain) -> {
// Custom Pre Filter
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
log.info("Custom PRE filter: request id -> {}", request.getId());
// Custom Post Filter
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
log.info("Custom POST filter: response id -> {}", response.getStatusCode());
}));
};
}
public static class Config{
// Put the configuration properties
}
}
application.yml에 이전 Filter에 대한 주석 처리와 다른 내용을 다음과 같이 추가한다.
# application.yml
...
spring:
application:
name: apigateway-service
cloud:
gateway:
routes:
- id: first-service
uri: http://localhost:8081/
predicates:
- Path=/first-service/**
filters:
# - AddRequestHeader=first-request, first-request-header2
# - AddResponseHeader=first-response, first-response-header2
- CustomFilter
- id: second-service
uri: http://localhost:8082/
predicates:
- Path=/second-service/**
filters:
# - AddRequestHeader=second-request, second-request-header2
# - AddResponseHeader=second-response, second-response-header2
- CustomFilter
기존의 필터 대신 위에서 코드로 만든 CustomFilter를 사용하기 위함이다.
FIRST-SERVICE에 다음 내용을 추가하고, SECOND-SERVICE에도 같은 방식으로 내용을 추가한다.
// FirstServiceController.java
@RestController
@RequestMapping("/first-service")
@Slf4j
public class FirstServiceController {
...
@GetMapping("/check")
public String check(){
return "Hi, there. This is a message from First Service";
}
}
이제 Postman에서 실행 결과를 확인하자.
또한 콘솔에도 다음과 같이 출력된다.
Spring Cloud Gateway - Global Filter
어떠한 라우트 정보가 실행되더라도, 이에 상관없이 공통적으로 적용되는 필터이다.
GlobalFilter라는 새로운 클래스를 만든다.
// GlobalFilter.java
@Component
@Slf4j
public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> {
public GlobalFilter(){
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
log.info("Global PRE filter baseMessage: {}", config.getBaseMessage());
if(config.isPreLogger()) log.info("Global filter Start: request id -> {}", request.getId());
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
if(config.isPostLogger()) log.info("Global Filter End: response code -> {}", response.getStatusCode());
}));
};
}
@Data
public static class Config{
// Put the configuration properties
private String baseMessage;
private boolean preLogger;
private boolean postLogger;
}
}
CustomFilter와 크게 다르지 않지만, 두 가지 포인트만 집어보겠다.
1. isPreLogger/isPostLogger
Config 클래스의 preLogger와 postLogger의 타입이 true인지, false인지 체크하는 것으로, boolean 타입이기에 get이 아닌 is를 붙인다.
2. Config 데이터 설정은 이후에 수정할 application.yml 파일에서 진행한다.
그럼 이제 application.yml을 다음과 같이 추가한다.
# application.yml
...
spring:
application:
name: apigateway-service
cloud:
gateway:
default-filters:
- name: GlobalFilter
args:
baseMessage: Spring Cloud Gateway Global Filter
preLogger: true
postLogger: true
...
baseMessage, preLogger, postLogger의 데이터를 붙여준다.
다음과 같이 콘솔 창에서 출력되는 것을 확인할 수 있다.
Spring Cloud Gateway - Logging Filter
이번에는 Logging Filter를 SECOND-SERVICE에만 적용시켜 보겠다.
LoggingFilter라는 새로운 클래스를 만든다.
// LoggingFilter.java
@Component
@Slf4j
public class LoggingFilter extends AbstractGatewayFilterFactory<LoggingFilter.Config> {
public LoggingFilter(){
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
GatewayFilter filter = new OrderedGatewayFilter((exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
log.info("Logging Filter baseMessage: {}", config.getBaseMessage());
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);
return filter;
}
@Data
public static class Config{
// Put the configuration properties
private String baseMessage;
private boolean preLogger;
private boolean postLogger;
}
}
application.yml도 다음과 같이 수정한다.
# application.yml
...
routes:
- id: first-service
uri: http://localhost:8081/
predicates:
- Path=/first-service/**
filters:
# - AddRequestHeader=first-request, first-request-header2
# - AddResponseHeader=first-response, first-response-header2
- CustomFilter
- id: second-service
uri: http://localhost:8082/
predicates:
- Path=/second-service/**
filters:
# - AddRequestHeader=second-request, second-request-header2
# - AddResponseHeader=second-response, second-response-header2
- name: CustomFilter
- name: LoggingFilter
args:
baseMessage: Hi, There!
preLogger: true
postLogger: true
SECOND-SERVICE에만 적용할 것이기 때문에, 위에서 확인할 수 있듯이 LoggingFilter는 SECOND-SERVICE 관련 filters 속성에만 들어가 있다.
그리고 만약 매개변수가 없는 Filter들만 적용시킨다면, 단순히 Filter들의 이름만 적는 것으로 적용을 완료할 수 있지만 만약 하나라도 매개변수가 들어가는 Filter라면(LoggingFilter처럼) 위의 예시처럼 name이라는 속성을 Filter마다 다 붙여줘야 한다.
이제 서비스를 구동시켜 보겠다.
아래는 FIRST-SERVICE 요청 시 나타나는 Filter들로, LoggingFilter 관련 내용은 당연하게도 찾아볼 수가 없다.
다음으로 SECOND-SERVICE 요청 시 나타나는 정보들이다. LoggingFilter를 확인할 수 있다.
LoggingFilter가 가장 먼저 출력됨을 확인할 수 있는데, 이는 Ordered.HIGHEST_PRECEDENCE 덕분이다.
Spring Cloud Gateway - Load Balancer
Spring Cloud Gateway - Eureka 연동
이제 Eureka 서버에 FIRST-SERVICE와 SECOND-SERVICE를 등록하여서, Client가 특정 주소를 요청하면 DiscoveryServer에서 해당 서비스의 위치를 찾고, 반환해 주는 위치를 통해서 해당 서비스를 사용할 수 있도록 해보겠다.
먼저, Eureka Client를 추가해 주자.
apigateway-service, first-service, second-service의 pom.xml에 다음과 같이 의존성을 추가한다.
...
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
...
다음으로 apigateway-service의 application.yml에 다음 내용을 추가한다.
...
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
- CustomFilter
- 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
- name: CustomFilter
- name: LoggingFilter
args:
baseMessage: Hi, There!
preLogger: true
postLogger: true
...
apigateway-service, first-service, second-service의 application.yml에도 다음과 같이 추가한다.
...
eureka:
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://localhost:8761/eureka
...
그 후 DiscoveryServer와 다른 모든 서비스들을 구동하면, 브라우저의 대시보드에서 다음과 같은 내용을 확인할 수 있다.
Postman을 통해 정상적으로 작동하는지도 확인해 보자.
이제 FIRST-SERVICE와 SECOND-SERVICE를 두 개씩 구동시켜 보자.
하나의 서비스에 대한 여러 개의 인스턴스를 생성하는 방법을 잘 모른다면 이전 게시글의 User Service - 등록 부분을 참고하자.
다음과 같이 총 5개의 서비스를 실행한다.
FIRST-SERVICE를 수정하여, 어느 포트에서 구동 중인 FIRST-SERVICE를 사용한 것인지 확인할 수 있게 만들어보자.
FIRST-SERVICE의 application.yml을 다음과 같이 변경하여 랜덤 포트 부여와 instance의 이름을 부여한다.
# application.yml
server:
port: 0
spring:
application:
name: my-first-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}}
설정 후 Eureka에서 잘 설정되었는지를 확인해 보자.
이제 포트를 직접 확인하기 위해 Controller를 다음과 같이 변경하자.
// FirstServiceController.java
@RestController
@RequestMapping("/first-service")
@Slf4j
public class FirstServiceController {
Environment env; // 환경 변수 정보
// 생성자 주입
@Autowired
public FirstServiceController(Environment env){
this.env = env;
}
@GetMapping("/welcome")
public String welcome(){
return "Welcome to the First Service";
}
@GetMapping("/message")
public String message(@RequestHeader("first-request") String header){
log.info(header);
return "Hello World in First Service";
}
@GetMapping("/check")
public String check(HttpServletRequest request){
log.info("Server Port={}", request.getServerPort());
return String.format("Hi, there. This is a message from First Service on Port %s",
env.getProperty("local.server.port"));
}
}
이제 인스턴스를 2개 실행 후 Postman에서 실행해 보자.
요청할 때마다 라운드 로빈 방식으로 랜덤 포트 두 개가 돌아가면서 실행됨을 알 수 있다.