프로젝트 개선 사항
1. 사용자 요청 Route 필요
- 프로젝트에 MSA 아키텍처를 적용하면서 사용자가 각 Service EndPoint에 직접 요청을 해야함.
- Service 트래픽 증가를 Scale-Out 해결할 계획. 따라서 Service의 인스턴스가 증가하면 해당 Service의 EndPoint를 전부 알고 있어야 하고 변동 내역을 설정에 추가해야 하는 불편한 부분 발생.
2. JWT 유효성 검증 로직 중복
- Service가 분리되면서 로그인 사용자를 User Service에서 JWT로 관리 하는 중. User Service 외 인증이 필요한 부분에 Token을 검증해야 하는 로직이 필요함.
결론
Spring Gateway를 선택한 이유
- Spring 생태계에 적합: Spring Cloud Gateway는 Spring 생태계와 자연스럽게 통합되어 기존 프로젝트와의 호환성이 높다.
- Netflix Eureka와의 통합: Eureka를 사용하면 서비스 인스턴스의 증감에 따라 API Gateway에서 자동으로 라우팅 및 로드 밸런싱을 관리할 수 있다.
- 중앙 집중식 JWT 검증: 모든 요청이 API Gateway를 통해 처리되므로 별도의 인증 서버를 구축할 필요 없이 Gateway에서 JWT를 검증할 수 있다.
- Nginx도 로드밸런싱 기능을 제공하지만 Cloud Gateway의 동적 라우팅 및 유연한 Filter Chain의 장점으로 Spring Gateway를 선택했다.
해결 방안
1. Service Route 필요
a. Nginx
- 특징: 웹 서버로 시작하여 현재는 리버스 프록시, 로드 밸런서, HTTP 캐시 등의 역할을 수행한다.
- 장점: 높은 성능과 낮은 리소스 사용량을 자랑하며, 다양한 로드 밸런싱 알고리즘을 제공한다.
- 단점: 복잡한 라우팅 및 서비스 디스커버리 기능이 부족하며, 설정 변경 시 수동으로 업데이트해야 한다.
b. Gateway

- Cloud Gateway 동작 과정
- Client가 Spring Cloud Gateway에 Request함.
- Gateway Handler Mapping에서 Request에 대한 Path가 일치한다고 판단하면 Gateway Web Handler로 전달
- Gateway Web Handler는 Request에 특정 FilterChain을 통해 Request를 처리한다.
- (FIlter가 점선으로 구분된 이유는 pre filter, post filter로 나누어 지는데 즉, Filter가 Request에 대해 Proxy요청 전 후 모두 실행될 수 있기 때문)
- pre filter가 실행되고 proxy 요청이 수행되며 이후 post filter가 작동한다.
- 특징: 모든 요청을 중앙 집중식으로 관리하며, 각 서비스로 라우팅한다.
- 장점: 클라이언트는 단일 진입점으로 모든 서비스에 접근할 수 있으며, 로드 밸런싱, 인증, 모니터링, 로깅 등의 기능을 제공한다. 서비스 인스턴스 수가 변해도 클라이언트 설정을 변경할 필요가 없다.
- 단점: 성능 병목이 발생할 수 있으며, 고가용성을 위해 추가적인 설정이 필요하다. 단일 실패 지점(Single Point of Failure)이 될 수 있다.
단일 실패 지점
정의 : 단일 실패 지점은 시스템 내의 특정 요소가 고장나면 전체 시스템의 기능이 중단되는 경우를 의미. 즉, 이 구성 요소가 없거나 정상적으로 작동하지 않으면 다른 모든 구성 요소가 영향을 받아 서비스가 중단될 수 있다.
만약 Gateway에서 request를 중앙 처리하는데 Gateway의 Server에 문제가 발생하면 Route를 진행하던 Service가 중단될 수 있다.
따라서 모든 request를 처리하는 Gateway는 여러 서버를 클러스터로 구성하여, 하나의 서버가 실패하더라도 다른 서버가 요청을 처리할 수 있도록 하는 방식을 고려해야 한다.
2. JWT 유효성 검사 중복
a. 별도 인증 서버 구축
- 특징: 인증을 전담하는 서버를 구축하여 각 서비스가 이를 통해 인증한다.
- 장점: 인증 로직이 중앙 집중화되어 관리가 용이하며, 보안이 강화되고 인증 관련 로직의 재사용이 가능하다.
- 단점: 추가적인 서버 유지보수 및 관리가 필요하며, 인증 서버가 성능 병목이 될 수 있다.
b. API Gateway에서 JWT 검증
- 특징: API Gateway가 모든 요청의 JWT를 검증하여 각 서비스로 전달한다.
- 장점: 각 서비스에서 JWT 검증 로직이 중복되지 않으며, API Gateway에서 필요한 Filter를 추가하여 중앙 집중적으로 인증을 관리할 수 있다.
- 단점: Gateway에 트래픽이 과도하게 증가하면 Gateway의 요청을 분산해야 하며, JWT 검증 로직이 복잡해지면 Gateway의 성능에 영향을 미칠 수 있다.
구현
의존성 추가
ext {
set('springCloudVersion', "2023.0.2")
}
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
application.yml 수정
spring:
application:
name: gateway
cloud:
gateway:
discovery:
# eureka에 service 등록
locator:
enabled: true
# service id를 소문자로 변환
lower-case-service-id: true
# Route 설정
routes:
# route를 구분하기 위한 고유 id
- id: user_service
# Eureka에 등록된 Service Application Name을 uri로 한다.
uri: lb://user-service
# Route 할 경로를 입력
predicates:
- Path=/api/v1/user/**, /api/v1/cart/**
# 적용할 Filter 명시
filters:
- name: JWTFilter
- id: product_service
uri: lb://product-service
predicates:
- Path=/api/v1/product/**,/api/v1/stock/**
- id: order_service
uri: lb://order-service
predicates:
- Path=/api/v1/order/**
filters:
- name: JWTFilter
# 기본적으로 적용할 Filter 설정
default-filters:
- name: GlobalFilter
args:
baseMessage: "Spring Cloud Gateway Global Filter"
preLogger: true
postLogger: true
Global Filter 구현
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@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 Filter default Message : {}", 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 {
private String baseMessage;
private boolean preLogger;
private boolean postLogger;
}
}
JWT 필터 구현
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.util.Map;
@Component
@Slf4j
public class JWTFilter extends AbstractGatewayFilterFactory<JWTFilter.Config> {
@Value("${jwt.secret.key}")
private String jwtSecretKey;
@Data
public static class Config {
private boolean preLogger;
private boolean postLogger;
}
public JWTFilter() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)){
return chain.filter(exchange);
}
String header = request.getHeaders().getOrEmpty(HttpHeaders.AUTHORIZATION).getFirst();
if (!header.startsWith("Bearer ")){
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
String token = header.substring(7);
try {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecretKey)
.parseClaimsJws(token)
.getBody();
ServerHttpRequest.Builder mutatedRequest = request.mutate();
for (Map.Entry<String, Object> entry : claims.entrySet()) {
String claimKey = "X-Header" + entry.getKey();
String claimValue = String.valueOf(entry.getValue());
mutatedRequest.header(claimKey, claimValue);
}
request = mutatedRequest.build();
exchange = exchange.mutate().request(request).build();
} catch (Exception e){
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
log.info("Custom PRE filter: request id -> {}", request.getId());
log.info("Custom PRE filter: request uri -> {}", request.getURI());
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
log.info("Custom POST filter: response code -> {}", response.getStatusCode());
}));
};
}
}
'Spring Boot' 카테고리의 다른 글
| 상품 재고 감소의 동시성 문제 (0) | 2024.07.22 |
|---|