이 글은 인프런 Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) 강의를 듣고 쓴 글입니다.
API Gateway란?
클라이언트가 각 마이크로 서비스들을 모두 알아서 불러야 한다면, 서비스에 추가, 변경, 삭제 등이 일어날 경우 수정 소요가 너무 많아진다.
따라서 클라이언트는 API를 서버에 직접 요청하지 않고 "단일 진입점"에만 요청할 수 있도록 하는 API Gateway가 생겨나게 된다. 이로써 서비스를 직접 공개하지 않을 수도 있다. 이 외에 API Gateway Service는 다음 것들을 할 수 있다.
- 인증 및 권한 부여, 서비스 검색 통합, 응답 캐싱, 정책/회로 차단기 및 QoS 다시 시도, 속도 제한, 부하 분산(즉, Load Balancing), 로깅/추적/상관관계(마이크로서비스의 호출 등의 추적과 로깅은 중요하다), 헤더/쿼리 문자열 및 청구 변환, IP 허용 목록에 추가
Netflix Ribbon
Spring Cloud에서 서비스간 통신을 하는 방법은 크게 2가지가 있다. 첫 번째는 RestTemplate이고, 두 번째는 Feign Client이다. Feign Client 는 마이크로 서비스의 이름을 등록해서 호출하면, 직접적인 서버의 주소, 포트 번호를 쓰지 않고도 호출할 수 있다.
문제는 "Load Balancer를 어디에 위치시킬것인가"이다. Ribbon은 서비스 이름으로 호출할 수 있게 하고, Health Check를 할 수 있도록 하는 Load Balancer이다. 이 Ribbon은 따로 서비스를 구현하지 않고 Client Side에서 수행한다. 현재는 비동기 처리의 불편함 등으로 잘 쓰지 않는다. 스프링 부트에서 maintenance 상태, 즉 사용을 권장하지 않는 상태이다. 대신 Spring Cloud Loadbalancer를 권장한다.
Netflix Zuul 구현
Ribbon과 같이 Spring Cloud Gateway를 사용할 것을 권장하고, maintenance 상태이다. 스프링부트 2.4버전의 하위 버전을 선택해야 한다. Spring Cloud Gateway를 사용하기 전, 학습 용도로 낮은 버전의 스프링부트를 사용해서 직접 구현해보자.
Gateway 구현
우선, Netflix Zuul을 통해 Gateway 서비스를 받을 마이크로 서비스들을 간단하게 만들어보자. fisrt-service 와 second-service 라는 이름으로 만든다. 각 스프링부트 버전은 2.4 이하여야 하고, 의존성은 Lombok, Spring Web, Eureka Discovery Client 만 추가한다.
그 후, /welcome 경로로 접근 시 간단한 문자열을 반환하는 컨트롤러를 만든다.
@RestController
@RequestMapping("/")
public class FirstServiceController {
@GetMapping("/welcome")
public String welcome() {
return "Welcome to the first service.";
}
} //second-service는 First를 모두 Second로 바꿔주면 된다
또한, application.yml 파일에 다음과 같이 추가한다. 각각이 무슨 의미인지 모르면 여기 있으면 안된다. 돌아가자..
server:
port: 8081
spring:
application:
name: my-first-service
eureka:
client:
fetch-registry: false
register-with-eureka: false
이제 실제 Gateway 역할을 할 zuul-service 프로젝트를 생성한다. 역시 스프링부트 버전은 2.4이하여야 하고, 의존성은 Lombok, Spring Web, Zuul을 추가한다. 만약 Zuul 선택이 불가능하다면 구글링을 통해 추가하는 법을 찾아보자.
프로젝트를 만들었으면 메인함수가 있는 클래스 위에 @EnableZuulProxy 어노테이션을 추가한다.
또한 application.yml 파일에 다음과 같이 추가한다.
server:
port: 8000
spring:
application:
name: my-zuul-service
zuul:
routes:
first-service:
path: /first-service/**
url: http://localhost:8081
second-service:
path: /second-service/**
url: http://localhost:8082
이제 8081포트나 8082포트와 같은 실제 서비스의 주소가 아닌 8000포트라는 Gateway의 주소와 실제 서비스의 이름만으로 서비스를 호출할 수 있다.
기본적인 라우팅이 작동되는 것을 확인했다. 이제 필터링 기능을 살펴보자.
Filter 구현
@Slf4j
@Component
public class ZuulLoggingFilter extends ZuulFilter {
@Override
public Object run() throws ZuulException {
log.info("****************************** printing logs");
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
log.info("****************************** " +request.getRequestURI());
return null;
}
@Override
public String filterType() {
return "pre"; // 사전필터
}
@Override
public int filterOrder() {
return 1; // 필터의 순서. 하나밖에 없으니 1
}
@Override
public boolean shouldFilter() {
return true; // 필터로 쓰겠다
}
}
우선 ZuulFilter는 추상 클래스이므로 4개를 모두 구현해야 한다. implement 단축키를 활용해 구현하자.
filterType은 사전필터이므로 pre라고 지정한다. Gateway가 호출되면 이 필터가 동작할 것이다.
run() 함수에 필터가 할 일을 적어주면 된다. 간단하게 요청의 최상위객체인 RequestContext 로부터 requestURI를 받아와 로깅해주는 코드이다.
이제 실제 Gateway를 통해 서비스를 호출하면 다음과 같이 로깅된다.
즉, Gateway는 사용자의 요청을 한 군데로 모아, 로깅/추적 등을 할 수 있도록 Filter 역할을 한다는 것이다.
Spring Cloud Gateway
Zuul과 달리 비동기 처리가 가능하므로 Spring에서 자체적으로 만든 Gateway가 Zuul을 대신한다.
이전에 생성했던 first-service와 second-service의 Gateway인 apigateway-service 프로젝트를 만들어보자.
프로젝트의 의존성으로는 Lombok, Eureka Discovery Client, Gateway 를 넣는다.
프로젝트 생성 후 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/**
맨 밑에 spring.cloud.grateway.routes 에 여러 라우팅 정보를 입력하는 것이다. 각 라우팅 정보를 적어주면 된다.
predicates는 조건절이라고 생각하면 된다. 즉 localhost:8000/first-service/** 와 같이 요청이 들어오면 그걸 그대로 라우팅해준다는 것이다.
하지만, 라우팅된 최종 요청이 localhost:8081/first-service/** 과 같이 들어오게 된다. 이 문제를 해결하려면 실제 요청에도 /first-service 라는 경로를 넣어야 한다. 어떤 서비스에 내 요청이 가는지 모르는 게 Gateway의 목적 중 하나인데 이러면 안된다...
결론적으로, 이것은 나중에 알아보기로 하고 일단 first-service와 second-service의 라우팅이 작동되도록 first-service와 second-service의 RequestMapping 조건을 바꿔주도록 하자. (@RequestMapping("/first-service/")로 변경!)
또한, Zuul을 포함한 여태까지 했던 것들과는 다르게 내장 서버가 Tomcat이 아니라 Netty 인 것이 보일 것이다. 비동기 처리를 하기 위한 내장 서버이다.
이제 Filter 적용을 해보자.
Filter 적용
요청이 들어오면 predicate 를 통해서 pre filter를 거치고, 요청을 끝낸 다음에 post filter를 거쳐 응답을 한다.
filter는 지금까지 application.yml 파일에서만 작업했지만, 이제 실제 java code 로도 작업을 해볼 것이다.
Gateway Handler Mapping 부분에서 헤더를 조작한 다음 실제 서비스 로직에서 잘 반영되는가를 실습해보자.
JAVA 코드에 직접 구현
apigateway-service 의 application.yml 의 spring cloud 설정 부분을 모두 주석처리하자.
그리고 다음 클래스를 만들면 된다.
@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();
}
}
이 코드는 RouteLocater 라는 bean을 등록하는 것이다. 이 bean은 지금까지 했던 것과 마찬가지의 라우팅기능을 해준다.
.path() 에서 요청되는 경로, .filters에서 request와 response에 헤더를 추가하는 필터를 만들었다. 그 후 .uri() 에 있는 주소로 넘겨준다.
request header가 잘 추가되는지 보기 위해 first-service와 second-service의 컨트롤러에 다음과 같은 함수를 작성하자.
@GetMapping("/message")
public String message(@RequestHeader("first-request") String header) {
log.info(header);
return "Hello World in First Service";
}
이제 브라우저에서 http://127.0.0.1:8000/first-service/message 로 요청을 보내자.
first-service의 콘솔에서 다음과 같은 로그가 뜬다.
response header는 브라우저의 개발자 도구 -> Network 에서 우리가 보낸 메시지를 뜯어보면 알 수 있다.
application.yml 파일에 구현
아까 java 코드의 어노테이션들을 주석처리하고, 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
이전에 했던 것들에 filters 를 추가했다. 두 서비스에 각각 request/response header를 추가했고, (키, 값) 형태로 적으면 된다.
이렇게 하고 이번엔 브라우저가 아니라 postman으로 잘 되는지 검증해보자!
역시나 http://127.0.0.1:8000/first-service/message 로 요청을 보내면 로그에 잘 출력되고, 포스트맨의 헤더에도 잘 출력된다.
Custom Filter 적용
헤더 추가와 같이 기본적으로 적용되는 기능 뿐 아니라 사용자 정의에 의해서 만들어진 기능(로그인 인증, 에러 코드 반환 등)도 쓸 수 있도록 Custom Filter를 구현해보자.
apigateway-service 에 다음 클래스를 추가하자.
@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) -> {
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 code -> {}", response.getStatusCode());
}));
};
}
public static class Config {
// Put the configuration properties
}
}
- CustomFilter는 AbstractGatewayFilterFactory<CustomFilter.Config> 라는 클래스를 상속받는다. Config라는 클래스가 없어서 에러가 뜬다면 간단하게 static으로 선언해주도록 하자.
- 이제 pre filter와 post filter를 구현하자. implement 단축키를 통해 위와 같은 apply 함수를 구현한다. 여기서 pre/post filter를 구현할 것이고, 간단하게 로그에 출력하는 것으로 filter를 구현할 것이다. (이 함수의 자세한 내용은 밑의 Logging Filter 부분 참고)
- 우선, pre filter를 구현한다. 매개변수 중 exchange 를 pre filter를 사용한다. 또한 기존의 Tomcat 처럼 동기식이 아니라 비동기로 진행하므로 ServletRequest나 ServletResponse 가 아니라 ServerHttpRequest, ServerHttpResponse 를 사용한다.
- 그 다음, post filter를 구현한다. 이 때, Spring 5부터 지원되는 Webflux 를 사용한다면, 하나만 반환하는 Mono 클래스를 사용할 수 있다.
이제 이 필터를 다음과 같이 application.yml 파일에 등록하고 이전의 헤더 추가 필터는 주석처리하자.
filters:
# - AddRequestHeader=first-request, first-request-header2
# - AddResponseHeader=first-response, first-response-header2
- CustomFilter
이 필터를 테스트하기 위해 first-service와 second-service의 컨트롤러에 다음을 추가한다.
@GetMapping("/check")
public String check() {
return "Hi, there. This is a message from First Service";
}
이제 http://localhost:8000/first-service/check로 요청을 보내면 요청의 결과도 잘 들어오고, 콘솔 창에도 다음과 같은 결과가 잘 나오는 것을 볼 수 있다.
Global Filter 적용
Custom Filter 중에서, 모든 서비스에 적용되는 필터이다. 이전 예제에서 application.yml 파일에 서비스마다 직접 Custom Filter 등록을 해주었는데, Global Filter 는 그럴 필요가 없다는 것이다.
즉, 기본적으로 Custom Filter와 같다.
다음과 같은 클래스를 만들자. CustomFilter와 다른 점은 거의 없다.
@Component
@Slf4j
public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> {
public GlobalFilter() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
// Custom Pre Filter
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
log.info("Global Filter baseMessage: {}", config.getBaseMessage());
if (config.isPreLogger()) {
log.info("Global Filter Start: request id -> {}", request.getId());
}
//Custom Post Filter
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
if (config.isPostLogger()) {
log.info("Global Filter End: response code -> {}", response.getStatusCode());
}
}));
};
}
@Data
public static class Config {
private String baseMessage;
private boolean preLogger;
private boolean postLogger;
}
}
- 이번엔 Config 클래스에서 받아오는 정보들을 사용했다는 것이다. 이건 설정정보들로, 말 그대로 코드 안에서가 아니라 외부에서 필터에 넣어주는 설정정보들이다. 이 값들을 이용해서 필터를 구현할 수 있다. 우린 baseMessage, preLogger, postLogger 를 쓴다.
- apply 함수의 변경점은 단순한 출력문 혹은 if 문의 변경이다.
그리고 application.yml 파일에 다음과 같은 설정을 한다. 각 서비스의 하위가 아니라, spring.cloud.gateway 의 바로 하위에 넣는다.
default-filters:
- name: GlobalFilter
args:
baseMessage: Spring Cloud Gateway Global Filter
preLogger: true
postLogger: true
이제 http://localhost:8000/first-service/check로 요청을 보내자.
(중간에 Unable to load 뭐시기 뜨는 건 오류인데,,,,, macOS에서만 이상하게 발생하는 오류라고 하는데 해결도 잘 안되고, 작동하는 데도 이상이 없으니 일단 넘어가.... 나중에 고칩시다..ㅠㅠ)
서순을 보면 알겠지만, 모든 필터중에 가장 먼저, 그리고 가장 나중에 실행되는 것이 Global Filter이다.
Logging Filter
로그인과 관련한 Custom Filter를 만들면서 위에서 했던 apply 함수와 그 반환형인 GatewayFilter에 대해서 더 알아보자.
모든 역할은 Global, Custom Filter와 거의 같다.
다음과 같은 클래스를 만들자.
@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) -> {
// Custom Pre Filter
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());
}
//Custom Post Filter
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
if (config.isPostLogger()) {
log.info("Logging POST Filter: response code -> {}", response.getStatusCode());
}
}));
}, Ordered.LOWEST_PRECEDENCE);
return filter;
}
@Data
public static class Config {
private String baseMessage;
private boolean preLogger;
private boolean postLogger;
}
}
- GatewayFilter는 두 가지의 파라미터를 가지고 있다. 두 번째 파라미터는 필터의 우선순위를 정하는 것이고, 실행 결과를 보면서 자세히 보자.
- 첫 번째 파라미터는 ServerWebExchange, GatewayFilterChain 를 매개변수로 가지는 filter이다. exchange는 비동기를 지원하는 WebFlux에서 서블릿의 역할을 하는 것이라고 생각하면 된다. chain은 두 개 이상의 필터를 엮고 싶을 때 연결해준다.
이제 application.yml 파일의 second-service 부분을 다음과 같이 바꾸자. (비교를 위해 first는 안 한다.)
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
특히, 필터를 2개 이상 쓸 때는 name: 을 붙여줘야 한다는 것에 유념하자.
이제 http://localhost:8000/second-service/check 로 요청을 날려보자.
각 필터의 동작 순서는 Global -> Custom -> Logging 순이다. 이건, GatewayFilter 생성 시 두 번째 파라미터로 Ordered.LOWEST_PRECEDENCE 라는 제일 낮은 우선순위를 부여해서 그렇다.
Ordered.HIGHEST_PRECEDENCE 라는 제일 높은 우선순위를 부여하면 Logging Filter가 제일 먼저 실행된다.
Eureka와 연동
이제 이전에 했던 Eureka Discovery Service와 연동해보자.
8000 포트 번호로 서비스를 호출하면 Eureka 에서 어느 인스턴스로 가야할 지 탐색해서 실제 서비스로 이동하게 만들 것이다.
apigateway-service, first-service, second-service 세 서비스의 application.yml 파일의 eureka 부분을 다음과 같이 수정하거나 넣는다.
eureka:
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://localhost:8761/eureka
또, apigateway-service의 application.yml 에서 각 서비스의 라우팅하는 부분의 uri를 다음과 같이 바꿔준다.
uri: lb://MY-FIRST-SERVICE
lb는 Load Balancer를 말하고, Load Balancer에서 MY-FIRST-SERVICE를 찾아 포워딩해준다는 것이다.
이제 Eureka 서비스를 실행하고 나머지 서비스도 전부 실행하면 다음과 같이 대시보드에 잘 나온다.
또한, 직전에 했던 요청들을 보내보면 정상 작동하는 것을 알 수 있다. API Gateway를 거쳐 Eureka에서 서비스를 찾아 요청을 보내는 것이다.
여기까지 하고 만족했다면 당신은 지금까지 내용을 이해하지 못했다...!
계속 들었던 의문,,,, Load Balancer 의 진짜 목적, 부하 분산은 하지 않는 것인가!
바로 가보쟈
이전에 했던 것처럼 first-service를 다른 포트번호로 두 번 실행시켜보자. 3가지 방법으로 했었는데, 마음에 드는 것으로 하면 된다. (단, 모두 알고 있자)
대시보드에 뜨는 부분을 랜덤값으로 바꾸는 등의 내용은 이전과 같으니 생략한다.
난 요청을 보냈을 때 실제 어떤 포트번호의 인스턴스에서 실행되는지 알고 싶다. first-service의 컨트롤러 중 /check 부분을 다음과 같이 바꾸자.
@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"));
}
이제 요청을 보낼 때마다 요청 받은 실제 포트 번호를 반환해줄 것이다.
요청의 결과는 다음과 같다.
두 인스턴스가 계속 번갈아가며 요청을 처리하는 것을 알 수 있다. Eureka의 기본적인 부하 분산의 방법이 Round Robin이라는 것까지 모두 알아봤다!
'[MSA] Spring Cloud로 개발하는 마이크로서비스 애플리케이션' 카테고리의 다른 글
섹션 5 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (0) | 2023.07.28 |
---|---|
섹션 4 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (0) | 2023.07.28 |
섹션 3 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (0) | 2023.07.21 |
섹션 1 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (0) | 2023.07.20 |
섹션 0 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (0) | 2023.07.19 |