앞서 Eureka로 Client-Side Discovery를 구현해서 각 서비스들의 정보를 관리하는 체계를 만들었습니다.
이번에는, 여러 마이크로 서비스들을 사용하기 위해서 외부에서 애플리케이션에 진입하는 API Gateway를 구현해보겠습니다.
1. API Gateway란?
해당 개념은 Chapter 1에서 다음과 같이 소개했었습니다.
API Gateway는 쉽게 말하면 외부의 요청을 받아서 알맞는 도메인 Application에게 요청을 보내는 MSA 구성 요소입니다.
모놀리식 아키텍쳐에서는 Application이 하나였기 때문에 외부의 요청을 하나의 Application에서 처리하면 됐었습니다.
하지만 MSA에서는 Application이 도메인별로 나뉘기 때문에
외부의 요청이 어떤 도메인 관련 요청인지 판단해서 해당 Application에게 처리하도록 전달하는 단계가 하나 추가되어야 합니다.
이러한 단계에서 작업을 처리하는 구성 요소가 API Gateway입니다.
간략하게 요약하면, MSA 구조에서 외부에서 각 마이크로 서비스로 진입하는 진입점으로 API Gateway를 공통되게 사용하여
외부에서는 각 마이크로 서비스를 몰라도 API Gateway를 통해서 라우팅을 통해 호출할 수 있습니다.
또한 추가적으로 요청에 대한 공통 관심사를 API Gateway에서 처리할 수 있습니다. (인증, 모니터링 등)
2. API Gateway 라이브러리
API Gateway를 구현하기 위한 라이브러리로는 대표적으로 다음과 같은 라이브러리가 존재합니다.
- Netflix Zuul
- Spring Cloud Gateway
차이를 간략하게 말해보면
Netfilx Zuul은 Servlet 기반의 동기 방식이고, Spring Cloud Gateway는 Netty 기반의 비동기/Non-blocking 방식입니다.
그리고 Spring Cloud Gateway는 Spring webFlux 위에서 동작하게 됩니다.
그래서 Spring Cloud Gateway는 Spring Reactive 환경에서 구현된 API Gateway입니다.
따라서 여러 사용자의 동시 요청 환경에서 고성능을 발휘할 수 있지만, webFlux 기반의 라이브러리이므로
추가 기능을 커스텀하기 위해서는 webFlux에 대한 이해가 추가적으로 필요하다는 점이 있었습니다.
하지만, Spring 측에서 Netflix Zuul에 관한 지원(개발 및 유지보수)을 중지하고 Spring Cloud Gateway를 권장하고 있습니다.
따라서 이번 프로젝트에서는 많은 레퍼런스를 참고해서 Spring Cloud Gateway를 사용해보려고 합니다.
3. Spring Cloud Gateway 동작 과정
적용할 Spring Cloud Gateway의 동작 과정을 간략하게 살펴보도록 하겠습니다.
- 클라이언트가 Spring Cloud Gateway로 요청한다.
- Gateway Handler Mapping에서 요청 경로를 파악해서 Gateway Web Mapping(Handler)로 요청을 전달한다.
- 핸들러에서 해당 요청과 관련한 Pre Filter(요청 전 필터) 로직이 실행된된다.
- 프록시된 서비스로 라우팅된다.
- 프록시 서비스가 실행되고 Response를 반환한다.
- 핸들러에서 Response를 수신하고 Post Filter(요청 후 필터) 로직이 실행된다.
이러한 과정으로 요청들을 라우팅, 필터 처리할 수 있습니다.
4. Spring Cloud Gateway 적용하기
이제 본격적으로 Spring Cloud Gateway를 개인 프로젝트에 적용해보겠습니다.
4-1. 기본 설정 & 라우팅 처리
Spring Cloud Gateway의 기본 설정 및 라우팅 처리를 해보겠습니다.
4-1-1. build.gradle 의존성 추가
ext {
set('springCloudVersion', "2023.0.0")
}
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
이렇게 'spring-cloud-starter-gateway' 의존성을 추가하고,
Eureka Client 등록을 위한 Eureka Client 의존성과 유틸성 스프링 액츄에이터 의존성도 추가해줬습니다.
4-1-2. Eureka Client 활성화
@SpringBootApplication
@EnableDiscoveryClient
public class ApiGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ApiGatewayApplication.class, args);
}
}
Eureka Client를 활성화하기 위해 Application에 @EnableDiscoveryClient를 선언하여 활성화해줬습니다.
4-1-3. application.yml 라우팅 설정
server:
port: 9001
spring:
application:
name: api-gateway
cloud:
gateway:
routes:
- id: member-service
uri: lb://member-service
predicates:
- Path=/api/members/**
- id: board-service
uri: lb://board-service
predicates:
- Path=/api/boards/**
eureka:
instance:
prefer-ip-address: true
client:
register-with-eureka: true
fetch-registry: true
serviceUrl:
defaultZone: http://localhost:8761/eureka
management:
endpoints:
web:
exposure:
include: "*"
spring.cloud.gateway 하위에 라우팅 관련 설정과 함께 아래에 Eureka, Actuator 관련 설정을 진행했습니다.
Eureka, Actuator 관련 설정은 이전 Chapter의 Eureka 구현에서 설명했으므로 라우팅 관련 설정만 살펴보겠습니다.
- spring.cloud.gateway.routes.id : 라우팅을 구분하기 위한 route id를 지정합니다.
- spring.cloud.gateway.routes.uri : 요청이 라우팅되는 경로 URI를 의미합니다.
- lb 프로토콜(lb://)의 경로를 지정하면 기본적으로 Eureka Server에서 호스트에 해당하는 서비스를 찾고 로드밸런싱을 수행합니다.
- spring.cloud.gateway.routes.predicates : 해당 라우팅을 진행할 URI 조건으로, Gateway로 들어오는 요청 URI를 지정합니다.
- 예를 들어, /api/members의 요청이 들어오면 해당 라우팅이 진행되어 경로 URI로 지정한 lb://member-service가 호출됩니다.
이렇게 application.yml까지 설정하면 기본 설정과 라우팅 설정이 완료됩니다.
Spring Cloud Gateway의 Port로 설정한 9001번 포트로 마이크로 서비스인 Member-Service의 회원가입 API를 요청보냈을 때
위의 그림처럼 정상적으로 요청이 처리되는 것을 알 수 있었습니다.
4-2. 인증 처리 필터 구현
이제 Spring Cloud Gateway의 기본 설정과 라우팅 처리를 마쳤으니,
각 마이크로 서비스에 들어오는 요청의 인증 처리를 API Gateway에서 한번에 하도록 리팩토링해보겠습니다.
기존 모놀로식 구조에서는 global 패키지에서 전역적으로 처리하고 있었고,
멀티 모듈로 리팩토링하면서 shboard-common 모듈의 AuthInterceptor에 의해 인증 처리가 되었었습니다.
기존의 AuthInteceptor는 Spring MVC의 HandlerInterceptor를 구현한 커스텀 인터셉터였습니다.
이번 Spring Cloud Gateway에서 구현할 인증 필터는 Spring MVC 앞단에 위치하여
API Gateway로 오는 모든 요청에 대한 필터를 담당합니다.
Spring Cloud Gateway의 필터는 일반적으로 커스텀 필터와 Global 필터로 나뉩니다.
커스텀 필터와 Global 필터는 클래스 구현 자체는 동일합니다.
차이점은 Global은 API Gateway 요청 전역적으로 적용되는 필터로, yml 파일에서 전역 설정을 해줍니다.
커스텀 필터같은 경우에는 전역적으로 적용하지 않고 각 마이크로 서비스 단위(라우팅)로 적용하여 적용할 마이크로 서비스 아래에 필터 설정을 추가해줍니다.
저는 인증 필터를 구현할 것이므로 API Gateway로 오는 요청 대부분을 필터 처리해야하므로 Global 필터를 사용했습니다.
4-2-1. Global 인증 필터 구현
제 개인 프로젝트에서는 세션을 Redis 저장소에 저장해서 관리했습니다.
따라서 인증 로직에 Redis 저장소에 있는 세션을 검증하는 로직을 포함하여 필터를 작성했습니다.
@Component
@Slf4j
public class GlobalAuthFilter extends AbstractGatewayFilterFactory<GlobalAuthFilter.Config> {
private static final String SESSION_KEY = "JSESSIONID";
private static final String REDIS_SESSION_KEY = ":sessions:";
private static final List<String> excludeUris = List.of(
"/api/members/register", "/api/members/login"
);
@Value("${spring.session.redis.namespace}")
private String namespace;
private final StringRedisTemplate redisTemplate;
public GlobalAuthFilter(final StringRedisTemplate redisTemplate) {
super(Config.class);
this.redisTemplate = redisTemplate;
}
public static class Config {
// 변수 설정은 application.yml과 연동
// apply 메소드에서 미리 설정한 매개변수를 사용하고 싶다면 application.yml에서 args로 설정 후
// 해당 static class에 변수로 선언해서 사용할 수 있다.
}
@Override
public GatewayFilter apply(final Config config) {
return (exchange, chain) -> {
final ServerHttpRequest request = exchange.getRequest();
final String path = request.getURI().getPath();
for (String excludeUri : excludeUris) {
if (excludeUri.equals(path)) {
return chain.filter(exchange);
}
}
final MultiValueMap<String, HttpCookie> cookies = request.getCookies();
if (cookies == null || cookies.isEmpty() || !cookies.containsKey(SESSION_KEY)) {
log.warn("Session Key Not Exists");
return failAuthenticationResponse(exchange);
}
final HttpCookie httpCookie = cookies.get(SESSION_KEY).get(0);
final String sessionCookie = httpCookie.getValue();
final String decodedSessionId = new String(Base64.getDecoder().decode(sessionCookie.getBytes()));
if (!redisTemplate.hasKey(namespace + REDIS_SESSION_KEY + decodedSessionId)) {
log.warn("Session Cookie exist, but Session in Storage is not exist");
return failAuthenticationResponse(exchange);
}
return chain.filter(exchange);
};
}
private Mono<Void> failAuthenticationResponse(ServerWebExchange exchange) {
final ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
}
위와 같이 AbstractGatewayFilterFactory<C>를 상속하여 apply 메소드를 오버라이딩해서 구현했습니다.
특별한 점은 Spring Cloud Gateway는 앞서 말했듯이 webFlux 기반의 라이브러리이므로
기존 Spring MVC에서 사용했던 객체들(ServletHttpRequest, ServletHttpResponse, ...) 대신 다음과 같은 객체들을 사용합니다.
- ServerHttpRequest : 요청 관련 정보 객체
- ServerHttpResponse : 응답 관련 정보 객체
- Mono<T> : 비동기적으로 단일 결과를 나타내는 객체
간략하게 apply 메소드의 Flow를 설명하면 다음과 같습니다.
- Request URI가 인증을 거치지 않을 ExcludeURL에 포함되는지 판단
- ExcludeURL에는 Flow 상 인증 전 URL인 회원가입, 로그인 API URL을 추가했습니다.
- 포함된다면, chain.filter(exchange)로 아래 필터 로직 수행하지 않고 다음으로 진행
- 포함되지 않는다면 아래의 인증 로직 수행
- Request 쿠키 검증
- null, Empty 검증
- 쿠키 name이 JESSIONID인 쿠키가 존재하는지
- Redis 세션 저장소에서 쿠키 Value에 해당하는 Redis Key가 존재하는지 확인 (세션 확인)
- RedisTemplate의 hasKey() 메소드를 사용해서 확인
- 인증 실패 시 HttpStatus 401 반환하도록 설정 (failAuthenticationResponse 메소드)
4-2-2. application.yml에 Global Filter 선언
spring:
application:
name: api-gateway
cloud:
gateway:
routes:
- id: member-service
uri: lb://member-service
predicates:
- Path=/api/members/**
- id: board-service
uri: lb://board-service
predicates:
- Path=/api/boards/**
default-filters:
- name : GlobalAuthFilter
위의 라우팅 설정 아래에 default-filters를 추가하여 Global Filter임을 선언하여 모든 요청에 적용했습니다.
※ 만약, Global Filter가 아닌 일반 커스텀 필터라면?
이때는 application.yml에서 각 라우팅되는 마이크로 서비스 아래에 아래와 같이 추가해주면 됩니다.
이렇게 하면 인증 요청을 진행하지 않을 서비스에는 필터를 거치지 않게 할 수 있습니다.
spring:
application:
name: api-gateway
cloud:
gateway:
routes:
- id: member-service
uri: lb://member-service
predicates:
- Path=/api/members/**
filters:
- CustomAuthFilter
- id: board-service
uri: lb://board-service
predicates:
- Path=/api/boards/**
filters:
- CustomAuthFilter
이상으로 API Gateway 구현을 마무리하겠습니다.
🎯 Github Repository 링크 (전체 코드)
https://github.com/sh111-coder/sh-board-msa
📘 Monolithic to MSA 전체 목차
[MSA] 개인 프로젝트 Monolithic to MSA 전환기 - (1) MSA란?
[MSA] 개인 프로젝트 Monolithic to MSA 전환기 - (2) 멀티 모듈 구성하기
[MSA] 개인 프로젝트 Monolithic to MSA 전환기 - (3) Service Discovery 패턴 적용하기(feat. Spring Cloud Eureka)
[MSA] 개인 프로젝트 Monolithic to MSA 전환기 - (4) API Gateway 구현(feat. Spring Cloud Gateway)
[MSA] 개인 프로젝트 Monolithic to MSA 전환기 - (5) 서비스 간 통신하기(feat.Spring Cloud OpenFeign)
[MSA] 개인 프로젝트 Monolithic to MSA 전환기 - (6) 각 서비스의 설정 파일 관리하기(feat. Spring Cloud Config)
[MSA] 개인 프로젝트 Monolithic to MSA 전환기 - (7) 서비스 장애 대응 Circuit Breaker 구현(feat. Resilience4J)
[MSA] 개인 프로젝트 Monolithic to MSA 전환기 - (10) MSA 전환 후 비교 및 회고 + 마무리
'아키텍쳐' 카테고리의 다른 글
[MSA] MSA에서 JPA Entity의 연관관계를 어떻게 설정할까? (4) | 2024.02.06 |
---|---|
[MSA] 개인 프로젝트 Monolithic to MSA 전환기 - (5) 서비스 간 통신하기 (feat. Spring Cloud OpenFeign) (1) | 2024.02.06 |
[MSA] 개인 프로젝트 Monolithic to MSA 전환기 - (3) Service Discovery 패턴 적용하기(feat. Spring Cloud Eureka) (2) | 2024.02.03 |
[MSA] 개인 프로젝트 Monolithic to MSA 전환기 - (2) 멀티 모듈 구성하기 (1) | 2024.02.02 |
[MSA] 개인 프로젝트 Monolithic to MSA 전환기 - (1) MSA란? (0) | 2024.02.01 |