이전에는 Eureka 서버가 직접 서비스에 주소를 알려주고 통신하는 방법을 사용했다. 이 과정에서 gateway를 추가해서 들어오는 요청의 전후처리와 인증/인가를 수행할 수 있도록 설정해 보려고 한다.
API Gateway의 기능
- 라우팅
- 인증/인가
- 로드 밸런싱
- 모니터링 및 로깅
- 요청 및 응답 변환
Spring Cloud Gateway 란?
SpringCloud Neflix 패키지의 일부로 msa 환경에서 널리 사용된다. 클라우드의 요청을 적절한 서비스로 라우팅 시켜주고, 다양한 필터링 기능을 제공한다.
Gateway 또한 EurekaClient로 취급하며 Server와 통신한다.
내부에는 Globalfilter, GatewayFilter라는 추상체가 존재하고 해당 추상체를 통해 filterChain메서드를 실행시킬 수 있다.
SpringBoot2 버전에서는 Zuul이라는 프레임워크를 사용했었다.
필터의 주요 객체
- Mono
- 리액티브 프로그래밍에서 0 또는 1개의 데이터를 비동기적으로 처리한다.
- 비동기적으로 처리하는 방식은 매우 빠른 처리라는 장점이 있지만 동시에 여러 작업을 처리하게 되면 고려해야 하는 부분이 많다.
- 여러개의 비동기 처리중 어떤 시점에 유저에게 상태를 반환할 것인지, 비동기처리중 발생한 문제에 대해서는 어떻게 확인하고 처리할 것인지 고려가 필요하다.
- ServerWebExchange
- HTTP 요청과 응답을 캡슐화한 객체이다.
- getRequest , get Response로 HTTP응답을 가져올 수 있고, mutate메서드로 내부에 데이터를 추가할 수있다.
변경된 아키텍처
이전 포스팅 과 달라진 아키텍처는 이렇다!
들어오는 모든 요청을 gateway의 포트를 통해 요청을 보내고 적절한 서비스로 반환해준다.
이 과정에서 Prefilter를 통해서 들어오는 요청에 대한 정보를 로그로 출력하거나, Authentication 필터를 통해 해당 유저가 인증을 한 유저인지, 해당 요청에 대한 권한이 있는 유저인지 체크할 수 있다.
Gateway 서버 개발하기
1. 의존성 추가
인증 인가에 필요한 jjwt와 client로 인식시키기 위한 netflix-eureka-client, gateway로 사용하기 위한 gateway 의존성을 추가했다.
implementation 'io.jsonwebtoken:jjwt:0.12.6'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64'
2. yml 파일 작성
기존에 설명과 중복되는 부분은 넘어가겠다. 설명이 필요하다면 댓글에 요청해주기 바란다!
spring.cloud.gateway의 routes속성을 통해 라우팅을 설정할 수 있다. 설명은 주석에 달아두었으니 확인 해보시길!
server:
port: 19080 # 서버가 실행될 포트 번호 설정
spring:
main:
web-application-type: reactive # Spring 애플리케이션을 리액티브(비동기) 방식으로 실행
application:
name: gateway-service # 애플리케이션의 이름을 'gateway-service'로 설정
cloud:
gateway:
routes: # Spring Cloud Gateway의 라우팅 설정
- id: user-service # 사용자 서비스 라우트 ID
uri: lb://user-service # 'user-service'로 요청을 로드 밸런싱하여 전달
predicates:
- Path=/users/** # /users/** 경로로 들어오는 요청을 user-service로 라우팅
- id: auth-service # 인증 서비스 라우트 ID
uri: lb://auth-service # 'auth-service'로 요청을 로드 밸런싱하여 전달
predicates:
- Path=/auth/** # /auth/** 경로로 들어오는 요청을 auth-service로 라우팅
- id: movie-server # 영화 서비스 라우트 ID
uri: lb://movie-server # 'movie-server'로 요청을 로드 밸런싱하여 전달
predicates:
- Path=/movie/** # /movie/** 경로로 들어오는 요청을 movie-server로 라우팅
discovery:
locator:
enabled: true # Eureka 서비스 등록 시 자동으로 인식하여 라우트 설정
service:
jwt:
secret-key: "401b09eab3c013d4ca54922bb802bec8fd5318192b0a75f201d8b3727429080fb337591abd3e44453b954555b7a0812e1081c39b740293f765eae731f5a65ed1"
# JWT 인증에 사용할 비밀 키
eureka:
instance:
prefer-ip-address: true # 서비스 등록 시 호스트명이 아닌 IP 주소를 우선 사용
client:
service-url:
defaultZone: http://localhost:19090/eureka/ # Eureka 서버의 기본 URL
register-with-eureka: true # 해당 서비스(gateway-service)를 Eureka에 등록
fetch-registry: true # Eureka에서 다른 서비스 목록을 가져옴
logging:
level:
org.springframework.cloud.gateway: INFO # Spring Cloud Gateway의 로깅 레벨 설정
com.sparta.cloud.movie_reservation_gateway: INFO # 커스텀 패키지의 로깅 레벨 설정
3. PreFilter 작성해보기
들어오는 요청의 자원을 확인할 수 있다. 만약 body에 민감한 요청이 들어온다면 이는 별도의 메서드를 통해 로깅에 보이지 않도록 처리해야 한다. 들어오는 요청에 대해 전처리가 필요하다면 이렇게 필터를 만들어서 사용하며 Ordered를 통해 필터 작동 순서를 정할 수 있다. Prefilter 이기에 가장 낮은 번호를 부여하거나 순서를 직접 지정해주면 된다.
ServerWebExchange를 통해 들어오는 요청에 대한 작업이 가능하며 체이닝메서드인 chain.filter를 통해 다음 필터가 작동하도록 체이닝을 실행할 수 있다.
이 filter 메서드는 GlobalFilter의 구현체이다.
@Component
@Slf4j
public class CustomPreFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("request Url = {}", exchange.getRequest().getURI());
log.info("request method= {}", exchange.getRequest().getMethod());
log.info("request data = {}", exchange.getRequest().getBody());
return chain.filter(exchange);
}
@Override
public int getOrder() {
return -1;
}
}
4. AuthenticationFilter 작성해보기
로직은 주석을 통해 설명을 해두었다. 이 필터를 통해 유저의 토큰유효성 검사를 진행하며 만약 유효한 토큰일 경우 토큰의 클레임에서 유저의 role과 id값을 추출하여 exchange의 mutate 메서드를 통해 전달할 값을 헤더에 추가한다.
이 헤더의 값을 통해 다른 서비스에서는 적절한 처리를 통해 유저의 정보를 가져오거나 인가 처리를 진행할 수도 있다.
@Component
@Slf4j
public class LocalJwtAuthenticationFilter implements GlobalFilter, Ordered {
@Value("${service.jwt.secret-key}")
private String secretKey; // JWT 서명 검증을 위한 비밀 키
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// 회원가입과 로그인 요청은 필터를 통과하도록 예외 처리
if (request.getURI().toString().contains("/users/signUp") || request.getURI().toString().contains("/users/signIn")) {
return chain.filter(exchange);
}
String authHeader = request.getHeaders().getFirst("Authorization"); // 요청 헤더에서 Authorization 값 가져오기
// 토큰 추출
String accessToken = extractToken(authHeader);
// 토큰 검증
if (accessToken == null || !validateToken(exchange, accessToken)) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); // 검증 실패 시 401 응답
return exchange.getResponse().setComplete();
} else {
return chain.filter(customSeverWebExchange(exchange, getClaims(accessToken))); // 검증 성공 시 사용자 정보를 요청에 추가
}
}
/**
* JWT 토큰을 검증하고 유효한 경우 true 반환
*/
private boolean validateToken(ServerWebExchange exchange, String token) {
try {
Claims claims = getClaims(token); // 토큰에서 클레임(사용자 정보) 추출
customSeverWebExchange(exchange, claims); // 요청에 사용자 정보를 추가
return true;
} catch (Exception e) {
log.info(e.getMessage()); // 검증 실패 시 로그 출력
return false;
}
}
/**
* 요청 객체에 사용자 ID와 역할(Role) 정보를 추가하여 반환
*/
private static ServerWebExchange customSeverWebExchange(ServerWebExchange exchange, Claims claims) {
ServerHttpRequest serverHttpRequest = exchange.getRequest().mutate()
.header("X-User-Id", claims.get("user_id").toString()) // 사용자 ID 추가
.header("X-Role", claims.get("role").toString()) // 사용자 역할 추가
.build();
exchange = exchange.mutate().request(serverHttpRequest).build();
return exchange;
}
/**
* JWT 토큰에서 클레임을 추출
*/
private Claims getClaims(String token) {
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey)); // 비밀 키를 이용하여 토큰 서명 검증 키 생성
Jws<Claims> claimsJws = Jwts.parser()
.verifyWith(key) // 서명 검증
.build()
.parseSignedClaims(token); // 토큰 파싱 및 검증
Claims claims = claimsJws.getBody();
log.info("claims :: " + claims); // 디버깅을 위해 클레임 정보 로그 출력
return claims;
}
/**
* Authorization 헤더에서 Bearer 토큰을 추출
*/
private String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return null;
}
return authHeader.substring(7); // "Bearer " 이후의 문자열을 반환하여 토큰 값만 추출
}
@Override
public int getOrder() {
return 0; // 필터 실행 순서를 가장 높은 우선순위(0)로 설정
}
}
5. PostFilter 작성해보기
PostFilter는 후처리가 필요한 과정이 어떤게 있을까 생각해봤지만 아직은 적절한 로직이 떠오르지 않아 statusCode를 통해 어떻게 처리됐는지 확인하는 방식으로만 사용했다.
@Slf4j
@Component
public class CustomProFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
ServerHttpResponse response = exchange.getResponse();
log.info("Post Filter: Response status code is " + response.getStatusCode());
// Add any custom logic here
}));
}
@Override
public int getOrder() {
return 2;
}
}
결과 확인해보기
정상적으로 Eureka Server에 등록된것을 볼 수 있다.
이제 19080포트로 user의 api주소를 사용해서 로드밸런싱과 라우팅이 잘 진행되는지 확인해보자.
아래 요청을 날려보자
원래라면 19091포트로 유저서비스에 요청을 보내야하지만 현재는 gateway가 라우팅을 하기에 모든 요청을 해당 포트로 진행한다.
우측 사진처럼 정상적인 응답을 받아오는 것을 확인할 수 있다.
이번엔 gateway에서 설정한 필터가 잘 작동하는지 로그를 살펴보자.
로그를 통해 Prefilter, AuthenticationFilter, CustomProfilter순으로 order 메서드를 통해 지정한 순서대로 잘 작동하는 것을 볼 수 있다.
다음에는 이 프로젝트를 모두 완성시키고 Ribbon, CircuitBraker를 통한 서비스 상태관리 및 장애 대처 방법에 대해 공부하고 포스팅해보겠다!
'Spring > Spring Cloud' 카테고리의 다른 글
[MSA] MSA에서 JPA Entity 연관관계를 어떻게 풀어낼까? (0) | 2025.02.11 |
---|---|
[MSA - Resilienc4j] CircuitBreaker, fallback 메서드 개발하기 (0) | 2025.02.11 |
[MSA] 멀티 모듈에서 중복되는 코드를 서브모듈끼리 공유하는 방법 (0) | 2025.02.08 |
[MSA - Spring Cloud] Eureka Client 개발하기(feat. FeignClient) (0) | 2025.02.07 |
[MSA - Spring Cloud] Eureka Server 개발하기(feat. Service Discovery란? 서버/클라이언트 사이드 디스커버리 전략) (0) | 2025.02.07 |