Spring Cloud Gateway를 이용해 클라이언트 요청을 각 마이크로서비스로 전달하고, 요청과 응답에 Filter를 적용한 뒤 Eureka에 등록된 여러 서비스 인스턴스로 요청을 분산하는 과정을 실습했다.
지난 시간에 Eureka를 이용해 서비스의 위치를 등록했다면, 오늘은 API Gateway가 Eureka에서 서비스를 찾고 적절한 인스턴스로 요청을 전달하도록 연결했다.
MSA에서는 회원, 주문, 상품과 같은 기능이 서로 다른 서비스로 나뉘어 실행된다.
클라이언트가 각 서비스의 IP와 포트를 직접 알아야 한다면 서비스가 추가되거나 주소가 변경될 때마다 클라이언트도 수정해야 한다.
API Gateway는 여러 마이크로서비스 앞에 위치하는 단일 진입점이다.
Client
│
▼
API Gateway
├─ First Service
├─ Second Service
└─ 그 외 Microservice
클라이언트는 API Gateway의 주소만 알고 있으면 된다. Gateway는 요청 경로를 확인해 적절한 서비스로 요청을 전달한다.
API Gateway에는 다음과 같은 공통 기능도 적용할 수 있다.
각 서비스가 인증이나 로깅 같은 공통 기능을 반복해서 구현하지 않고, Gateway에서 먼저 처리할 수 있다는 장점이 있다.
오늘 실습에서는 WebFlux 기반의 Spring Cloud Gateway를 사용했다.
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>
spring-cloud-starter-gateway-server-webflux
</artifactId>
</dependency>
WebFlux 기반 Gateway는 Reactor를 사용하는 비동기 방식으로 요청을 처리한다. 필터 코드에서도 Mono와 chain.filter(exchange)가 사용된 것을 확인할 수 있었다.
기존 Netflix Zuul과 Ribbon은 유지보수 단계에 들어간 기술이며, 오늘 코드는 Spring Cloud Gateway와 Spring Cloud LoadBalancer를 이용하는 방식으로 구성했다.
API Gateway는 8000번 포트에서 실행했다.
server:
port: 8000
클라이언트 요청은 Gateway의 Path Predicate에 따라 First Service 또는 Second Service로 전달된다.
spring:
cloud:
gateway:
server:
webflux:
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
- name: LoggingFilter
예를 들어 다음 요청은 First Service로 전달된다.
http://localhost:8000/first-service/welcome
다음 요청은 Second Service로 전달된다.
http://localhost:8000/second-service/welcome
여기서 Route를 구성하는 주요 요소는 다음과 같다.
id: Route를 구분하는 이름uri: 요청을 전달할 서비스predicates: 어떤 요청을 해당 Route가 처리할지 결정하는 조건filters: 요청과 응답을 전달하는 과정에서 실행할 작업Gateway Filter는 요청이 실제 서비스로 전달되기 전과 응답이 클라이언트에게 돌아가기 전에 공통 작업을 수행할 수 있다.
Client
↓
Pre Filter
↓
Microservice
↓
Post Filter
↓
Client
오늘 작성한 Filter에서는 다음과 같이 요청과 응답 정보를 기록했다.
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> {
log.info(
"response code -> {}",
response.getStatusCode()
);
}));
chain.filter(exchange)를 호출하면 다음 필터 또는 실제 서비스로 요청이 전달된다. 이후 응답이 돌아오면 then() 내부의 Post Filter 로직이 실행된다.
CustomFilter는 특정 Route에만 적용하는 필터다.
First Service와 Second Service Route에 각각 설정했으며 요청이 들어올 때 Request ID를, 응답이 돌아올 때 Response Status를 기록했다.
Custom Pre Filter
→ 실제 서비스 호출
→ Custom Post Filter
Route마다 필요한 인증 검사, 헤더 추가, 로깅 등의 작업을 Custom Filter로 분리할 수 있다.
GlobalFilter는 Gateway를 통과하는 모든 Route에 공통으로 적용했다.
default-filters:
- name: GlobalFilter
args:
baseMessage: Spring Cloud Gateway Webflux Global Filter
preLogger: true
postLogger: true
baseMessage, preLogger, postLogger 값을 설정 객체로 전달하고, 옵션에 따라 요청과 응답 로그를 출력하도록 구성했다.
공통 인증이나 전체 요청 로깅처럼 모든 서비스에 필요한 기능은 Global Filter에 작성할 수 있다.
Second Service에는 별도의 LoggingFilter도 적용했다.
OrderedGatewayFilter와 Ordered.HIGHEST_PRECEDENCE를 이용해 필터의 우선순위를 지정했다.
실행 로그를 통해 필터가 다음과 같은 순서로 동작한다는 것을 확인할 수 있다.
요청
→ Global Pre Filter
→ Custom Pre Filter
→ Logging Pre Filter
→ Second Service
→ Logging Post Filter
→ Custom Post Filter
→ Global Post Filter
→ 응답
요청이 들어갈 때는 설정된 필터 순서대로 실행되고, 응답은 반대 방향으로 필터를 통과한다.
처음에는 Gateway가 다음과 같이 서비스의 주소를 직접 가지고 있었다.
uri: http://localhost:8081
하지만 이 방식은 서비스의 포트나 실행 위치가 변경될 때마다 Gateway 설정도 수정해야 한다.
오늘은 Gateway와 각 서비스를 Eureka Client로 등록하고, 고정 주소 대신 서비스 이름을 사용했다.
uri: lb://MY-FIRST-SERVICE
uri: lb://MY-SECOND-SERVICE
lb://는 Eureka에서 해당 이름의 서비스를 검색하고, 등록된 인스턴스 중 하나를 선택해 요청을 전달하겠다는 의미다.
Client
│
▼
API Gateway
│
├─ Eureka에 MY-FIRST-SERVICE 위치 요청
│
└─ Eureka에 MY-SECOND-SERVICE 위치 요청
│
▼
등록된 Instance로 요청 전달
이제 Gateway는 First Service와 Second Service의 실제 포트를 직접 알 필요가 없다.
First Service와 Second Service는 다음과 같이 랜덤 포트를 사용하도록 설정했다.
server:
port: 0
각 인스턴스를 Eureka에서 구분할 수 있도록 고유한 instance-id도 설정했다.
eureka:
instance:
instance-id: >
${spring.cloud.client.ip-address}:
${spring.application.instance_id:${random.value}}
같은 서비스를 여러 개 실행하면 Eureka에는 같은 서비스 이름 아래 여러 인스턴스가 등록된다.
MY-FIRST-SERVICE
├─ Instance A : Random Port
└─ Instance B : Random Port
MY-SECOND-SERVICE
├─ Instance A : Random Port
└─ Instance B : Random Port
클라이언트가 Gateway에 같은 주소로 반복 요청하면 Gateway는 Eureka에서 인스턴스 목록을 확인하고 요청을 분산한다.
각 서비스의 /check API에서는 실제 실행 포트를 응답하도록 작성해, 요청할 때마다 서로 다른 인스턴스가 선택되는지 확인할 수 있었다.
오늘 실습에서는 큰 오류 없이 API Gateway와 Eureka를 연결했다.
다만 서비스 주소를 localhost:8081과 같이 직접 지정하면 여러 인스턴스를 실행하더라도 특정 인스턴스에만 요청이 전달된다.
이를 해결하기 위해 다음과 같이 구조를 변경했다.
고정 주소
http://localhost:8081
↓
서비스 이름
lb://MY-FIRST-SERVICE
Gateway가 Eureka를 통해 서비스의 위치를 찾도록 변경하면서, 서비스의 실제 포트와 관계없이 요청을 전달하고 여러 인스턴스에 부하를 분산할 수 있게 되었다.