현재 진행 중인 프로젝트가 MSA 기반의 프로젝트이기 때문에 API Gateway가 필요했다.
클라이언트로부터 들어오는 요청을 라우팅 해주고 특히 사용 중인 마이크로 서비스에서 JWT 토큰을 검증 후 토큰을 파싱해서 사용하는데 동일 로직이 모든 서비스에 들어가는 경우가 생겼다.
API Gateway를 서비스 앞 단에서 구축함으로써 필터를 통한 JWT 토큰의 검증 로직을 공통적으로 추출할 수 있고, 마이크로 서비스 간 RestTemplate이나 Feign Client를 이용한 REST API 호출이 필요할 텐데 이 때 Eureka Server와 API Gateway를 사용한다면 보다 효율적일 것이라고 생각했다.
Spring Cloud Netflix Zuul, Spring Cloud Gateway 등 API Gateway가 많이 존재하지만 Spring Cloud Gateway를 채택하기로 했다.
Spring Cloud Gateway에 대해 간단하게 알아보자.
Spring Cloud Gateway
Spring Cloud Gateway(이하 SCG)는 API Gateway로서 클라이언트로부터 들어오는 요청을 엔드포인트에 따라 알맞은 마이크로 서비스로 라우팅 해준다.
모든 요청들이 SCG를 거치기 때문에 여러 마이크로 서비스에서 사용하는 토큰 값 검증 로직 같은 인증 및 보안 관련 설정을 SCG에서 공통적으로 처리해주는 것도 가능하다.
SCG의 가장 큰 특징은 Tomcat이 아닌 Non-Blocking, Asynchronous 이벤트 기반의 WAS인 Netty를 사용한다.
- Non-Blocking : 네트워크 통신이 완료되는 것을 기다리지 않고 바로 다음 작업을 수행한다. 성능이나 효율성이 뛰어나다.
- Asynchronous : 동시에 일어나지 않는 것을 의미하며, 요청 받은 함수가 작업을 마치면 요청자에게 콜백을 통해 알려준다.
API Gateway는 모든 요청이 게이트웨이를 통해 들어오기 때문에 성능적인 이슈에 민감하다.
SCG는 이러한 성능적인 이슈를 줄이기 위해 복잡하지만 성능은 높일 수 있는 Netty를 채택한 것으로 보인다.
Spring Cloud Gateway 구현
Dependency
현재 진행중인 프로젝트의 버전 정보는 Spring Boot 2.6.7 , Gradle , Java 11을 이용중이다.
dependencies {
...
// api gateway
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
// jwt (JWT 토큰)
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2'
...
}
Spring Cloud Gateway를 구축하고 JWT 토큰 검증 로직을 Gateway Filter에 추가할 것이기 때문에 JWT 토큰 라이브러리까지 추가했다.
application.yml (설정 파일)
현재 프로젝트는 토큰 발급을 위한 “Auth Service”와 “Member Service” 2개의 서비스를 API Gateway에 등록할 것이다.
Auth Service는 JWT 토큰을 발급 받는 서비스이기 때문에 JWT 토큰에 대한 검증이 필요없고, 회원 서비스는 JWT 토큰 검증이 필요하다.
application.yml 파일을 자세히 살펴보자.
spring:
cloud:
gateway:
routes:
- id: nbbang-auth
uri: http://localhost:9100 # 인증 서버 URI
predicates: # 요청 헤더나 Path 조건 비교
- Path=/nbbang-auth/** # 요청 Path
- id: nbbang-user
uri: http://localhost:9000
predicates:
- Path=/nbbang-user/**
filters:
- AuthorizationHeaderFilter # JWT 검증을 위한 Custom GatewayFilter
SCG는 3개의 Main Building Block을 갖는다.
- Routes (경로)
- SCG가 요청이 들어온 Path를 파악해 어떤 서비스로 해당 요청을 라우팅할지 판단하는 블록이다.
- id(식별자), uri(서비스 엔드포인트), predicates(조건부), filters(필터)로 구성된다.
- SCG의 요청 URI가 SCG 엔드포인트 + predicates의 Path 부분이 일치하는 서비스로 라우팅한다.
- Predicates (조건부) : SCG로 들어오는 요청의 헤더나 Path에 대해 판단한다.
- filters : Spring Framework의 WebFilter의 인스턴스로서 Filter를 이용해 요청 및 응답을 변형해서 전달하는 것이 가능하다.
위의 Block을 토대로 다시 yml 파일을 분석해보자.
- 인증 서비스 (nbbang-auth)
- uri : http://localhost:9100 인증 서비스의 엔드포인트는 http://localhost:9100이다.
- predicates : - Path=/nbbang-auth/** SCG의 엔드포인트는 현재 http://localhost:8800이다. 따라서 “http://localhost:8800/nbbang-auth/” 로 SCG에 요청이 들어오면 해당 조건을 판단해서 인증 서비스로 라우팅해준다.
- 회원 서비스 (nbbang-user)
- uri와 predicates의 의미는 인증 서비스와 동일하다.
- filters: AuthorizationHeaderFilter라는 AbstractGatewayFilterFactory를 상속받아 구현한 Custom Filter이다. 해당 Filter에서 마이크로 서비스로 라우팅 되기 전에 JWT 토큰을 검증하여 요청을 변형한 뒤 보내줄 것이다.
AuthorizationHeaderFilter 구현
GatewayFilter는 GatewayFilterFactory를 구현해야하고 추상 클래스인 AbstractGatewayFilterFactory를 상속받아 구현해 사용한다.
GatewayFilter apply 메서드를 오버라이딩하면 exchange라는 요청/응답을 갖는 변수를 받을 수 있다.
우리는 요청이 들어온 후에 헤더의 JWT 토큰 값을 판단해 검증을 진행할 것이고, 성공적으로 검증되었을 경우 토큰 헤더를 실제 필요한 정보 (회원 아이디)를 추출해 마이크로 서비스로 전달해 줄 것이다.
만약 검증에 실패한 경우 ErrorWebExceptionHandler를 이용해 예외를 잡아 에러코드를 던져줄 것이다.
@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
@Autowired
private JwtUtil jwtUtil;
public AuthorizationHeaderFilter() {
super(Config.class);
}
public static class Config {
// application.yml 파일에서 지정한 filer의 Argument값을 받는 부분
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
String token = exchange.getRequest().getHeaders().get("Authorization").get(0).substring(7); // 헤더의 토큰 파싱 (Bearer 제거)
Map<String, Object> userInfo = jwtUtil.getUserParseInfo(token); // 파싱된 토큰의 claim을 추출해 아이디 값을 가져온다.
addAuthorizationHeaders(exchange.getRequest(), userInfo);
return chain.filter(exchange);
};
}
// 성공적으로 검증이 되었기 때문에 인증된 헤더로 요청을 변경해준다. 서비스는 해당 헤더에서 아이디를 가져와 사용한다.
private void addAuthorizationHeaders(ServerHttpRequest request, Map<String, Object> userInfo) {
request.mutate()
.header("X-Authorization-Id", userInfo.get("memberId").toString())
.build();
}
// 토큰 검증 요청을 실행하는 도중 예외가 발생했을 때 예외처리하는 핸들러
@Bean
public ErrorWebExceptionHandler tokenValidation() {
return new JwtTokenExceptionHandler();
}
// 실제 토큰이 null, 만료 등 예외 상황에 따른 예외처리
public class JwtTokenExceptionHandler implements ErrorWebExceptionHandler {
private String getErrorCode(int errorCode) {
return "{\\"errorCode\\":" + errorCode + "}";
}
@Override
public Mono<Void> handle(
ServerWebExchange exchange, Throwable ex) {
int errorCode = 500;
if (ex.getClass() == NullPointerException.class) {
errorCode = 100;
} else if (ex.getClass() == ExpiredJwtException.class) {
errorCode = 200;
}
byte[] bytes = getErrorCode(errorCode).getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
return exchange.getResponse().writeWith(Flux.just(buffer));
}
}
}
SCG를 통한 Request 테스트
간단하게 SCG를 구현했으니 JWT 토큰을 헤더에 담아 SCG를 통해 요청을 보내보자. 현재 회원 API 서비스는 9000번 포트를 사용하고 있다.
테스트는 유효한 JWT 토큰을 담아 사용자 프로필을 조회하는 성공 테스트와 JWT 토큰을 담지 않고 보내는 실패 테스트 두 가지 시나리오로 진행해볼 것이다.
- http://localhost:8800 : 게이트웨이 주소
- /nbbang-user/** : 회원 API 서비스 엔드포인트
- GET /members/profile : 사용자 프로필을 조회할 회원 API 서비스의 URI
- 호출 성공 테스트 예상 시나리오
- 클라이언트가 헤더에 유효한 JWT 토큰을 담는다.
- http://localhost:8800/nbbang-user/members/profile로 호출한다.
- 성공적으로 라우팅이 된다면 사용자 프로필을 담아 응답될 것이다.
- 호출 실패 테스트 예상 시나리오
- 클라이언트가 헤더에 JWT 토큰을 담지 않고 보낸다.
- http://localhost:8800/nbbang-user/members/profile로 호출한다.
- JWT 토큰이 존재하지 않으므로 AuthorizationHeaderFilter에서 NullPointerException이 발생하면서 ErrorCode : 100을 응답할 것이다. 물론 요청은 회원 API 서비스까지 도달하지 않을 것이다.
호출 성공 테스트
헤더에 Bearer 토큰으로 유효한 JWT 토큰을 담았고, http://localhost:8800/nbbang-user/members/profile 주소로 호출했다. 회원 API 서비스의 9000번 포트가 아닌 SCG의 8800번 포트로 보낸 것을 잊지말자.
SCG가 라우팅을 성공적으로 하고, 회원 API 서비스에서 JWT 토큰에 담긴 회원 아이디를 보내준 것을 확인할 수 있다.
호출 실패 테스트
마지막으로 호출 실패 테스트틀 진행해보자.
호출 성공 테스트와 동일한 주소로 JWT 토큰만 담지 않은 채로 요청을 보냈다.
테스트 예상 시나리오에 작성한대로 AuthorizationHeaderFilter에서 토큰을 성공적으로 검증해 지정한 errorCode 100번이 응답된 것을 확인할 수 있다.
📄 References
Spring Cloud Gateway(SCG)를 활용한 API Gateway 구축 : https://twofootdog.tistory.com/64
Spring Cloud Gateway란? : https://upperleaf.tistory.com/7
[API Gateway + Refresh JWT 인증서버 구축하기] Spring boot + Spring Cloud Gateway + Redis + mysql JPA 3편 : https://velog.io/@tlatldms/API-Gateway-Refresh-JWT-인증서버-구축하기-Spring-boot-Spring-Cloud-Gateway-Redis-mysql-JPA-3편
'Backend > Spring Boot' 카테고리의 다른 글
Spring Cloud Config의 설정 파일 비대칭키로 암/복호화 (0) | 2022.06.03 |
---|---|
RestControllerAdvice, ExceptionHandler를 이용한 전역 예외 처리 (0) | 2022.05.27 |
Custom Exception을 이용한 예외 처리 (0) | 2022.05.22 |
Request 스코프와 Proxy (0) | 2022.05.16 |
Spring Cloud Bus와 RabbitMQ를 이용해 설정 정보 한번에 최신화하기! (0) | 2022.05.15 |