
Spring Framwork가 제공하는 라이브러리로 Spring WebFlux 또는 Spring WebMVC에 API Gateway를 구축하기 위해 사용됩니다.
Spring Cloud Gateway는 API로 라우팅하고 보안, 모니터링/메트릭, 복원성과 같은 횡단적 관심사를 제공합니다.
이미지 출처 : https://kyhslam.tistory.com/entry/Spring-Cloud-Gateway-Load-Balancer
클라이언트가 서비스를 이용하게 될 때 해당 유저가 인증된 유저인지 또는 특정 서비스를 이용할 권한을 가졌는지 판단을 하고 그 이후 서비스로 연결해주게 됩니다.
그래서 저는 Filter를 구현하고 Cookie로 내려보낸 Access Token을 가져와 유효성 검사를 실시 후 정상인 경우 memberId를 추출합니다. 추출한 memberId는 header에 member-id라는 이름으로 저장해 회원 정보가 필요한 서비스에서 해당 member-id를 이용할 수 있게 했습니다.
Gateway service에서 작성할 내용은 다음과 같습니다.
필터 관련
Cookie & JWT 관련
예외 관련
API Limiter 관련
build.gradle
ext {
set('springCloudVersion', "2023.0.3")
}
dependencies {
// 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 config
implementation 'org.springframework.cloud:spring-cloud-starter-config'
// spring cloud gateway
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
// spring cloud eureka
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
// dev
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
tasks.register("prepareKotlinBuildScriptModel") {}
bootJar {
enabled = true
}
jar {
enabled = false
}
spring:
application:
name: ${GATEWAY_APP_NAME}
profiles:
active: ${APP_PROFILE}
config:
import: optional:configserver:${CONFIG_SERVER_URI}
솔직히 Global Filter의 경우는 이번 프로젝트에선 없어도 되는 기능이었지만 전체적인 기능을 파악하자는 의미로 억지로 추가해보았습니다. 그래서 특별히 무언가를 한다기 보단 어떠한 리퀘스트가 발생했는지 리스폰스의 스테터스는 어땠는지 로그로 남기는 정도로만 사용했습니다.
@Slf4j
@Component
public class GlobalFilter extends AbstractGatewayFilterFactory<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 Base Message: {}", config.getBaseMessage());
if (config.isPreLogger()) {
log.info("Global Filter Start. request id : {}, request path : {}", request.getId(),
request.getPath());
}
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
if (config.isPostLogger()) {
log.info("Global Filter End: response status code -> {}",
response.getStatusCode());
}
}));
};
}
@AllArgsConstructor
@NoArgsConstructor
@Data
public static class Config {
private String baseMessage;
private boolean preLogger;
private boolean postLogger;
}
}
@Component
public class AuthorizationFilter extends AbstractGatewayFilterFactory<Config> {
private final CookieProvider cookieProvider;
private final JwtProvider jwtProvider;
@Autowired
public AuthorizationFilter(CookieProvider cookieProvider, JwtProvider jwtProvider) {
super(Config.class);
this.cookieProvider = cookieProvider;
this.jwtProvider = jwtProvider;
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
String token = cookieProvider.getTokenFromCookies(request.getCookies());
jwtProvider.validateToken(token, TokenType.ACCESS);
Long memberId = jwtProvider.getMemberIdByToken(token, TokenType.ACCESS);
ServerHttpRequest newRequest = request.mutate()
.header("member-id", String.valueOf(memberId)).build();
return chain.filter(exchange.mutate().request(newRequest).build());
};
}
public static class Config {
}
}
request가 가지고 있는 Cookie에서 token을 추출해 해당 token에 문제 없는지 체크를 합니다.
문제가 없다면 token에 저장된 memberId를 추출해 header에 저장하고 request로 재생성합니다.ㅣ
이후 다음 filter에 재생성한 request를 담아 보내줍니다.
@Component
public class CookieProvider {
public String getTokenFromCookies(MultiValueMap<String, HttpCookie> cookies) {
if (cookies.isEmpty()) {
return null;
}
return cookies.get(TokenType.ACCESS.getName()).stream().map(HttpCookie::getValue)
.findAny()
.orElse(null);
}
}
CookieProvider에서는 Cookie의 이름을 특정해 해당 토큰을 반환하는 로직만 작성했습니다.
@Component
@Slf4j
@RequiredArgsConstructor
public class JwtProvider {
@Value("${jwt.access-secret}")
private String ACCESS_SECRET;
@Value("${jwt.refresh-secret}")
private String REFRESH_SECRET;
private Key accessKey;
private Key refreshKey;
@PostConstruct
public void init() {
byte[] accessKeyBytes = Decoders.BASE64.decode(ACCESS_SECRET);
byte[] refreshKeyBytes = Decoders.BASE64.decode(REFRESH_SECRET);
this.accessKey = Keys.hmacShaKeyFor(accessKeyBytes);
this.refreshKey = Keys.hmacShaKeyFor(refreshKeyBytes);
}
public void validateToken(String token, TokenType type) {
Key key = type.equals(TokenType.ACCESS) ? accessKey : refreshKey;
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
} catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException |
SignatureException | IllegalArgumentException ex) {
throw new AuthException(ErrorType.TOKEN_AUTHORIZATION_FAIL);
}
validateTokenExpired(token, type);
}
private void validateTokenExpired(String token, TokenType type) {
Date expiredDate = getExpired(token, type);
if (expiredDate.before(new Date())) {
throw new AuthException(ErrorType.TOKEN_EXPIRED);
}
}
public Date getExpired(String token, TokenType type) {
return getClaimsFromJwtToken(token, type).getExpiration();
}
public Long getMemberIdByToken(String token, TokenType type) {
return getClaimsFromJwtToken(token, type).get("memberId", Long.class);
}
private Claims getClaimsFromJwtToken(String token, TokenType type) {
Key key = type.equals(TokenType.ACCESS) ? accessKey : refreshKey;
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}
JwtProvider에서는 Token의 내용을 가져오거나 Token의 유효성 검사를 실시합니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> responseBody = new HashMap<>();
if (ex instanceof HttpStatusCodeException statusEx) {
exchange.getResponse().setStatusCode(statusEx.getStatusCode());
responseBody.put("status", statusEx.getStatusCode());
responseBody.put("message", statusEx.getMessage());
} else {
exchange.getResponse()
.setStatusCode(ErrorType.TOKEN_AUTHORIZATION_FAIL.getStatusCode());
responseBody.put("status", ErrorType.TOKEN_AUTHORIZATION_FAIL.getStatusCode());
responseBody.put("message", ex.getMessage());
}
DataBuffer wrap = null;
try {
byte[] bytes = objectMapper.writeValueAsBytes(responseBody);
wrap = exchange.getResponse().bufferFactory().wrap(bytes);
} catch (JsonProcessingException e) {
log.error("fatal error : {}", e.getMessage());
}
return exchange.getResponse().writeWith(Flux.just(Objects.requireNonNull(wrap)));
}
}
GlobalExceptionHandler에서는 예외가 발생한 경우 responseBody를 생성해 결과를 반환하게 했습니다.
@Configuration
public class ExceptionHandlerConfig {
@Bean
public ErrorWebExceptionHandler errorWebExceptionHandler() {
return new GlobalExceptionHandler();
}
}
5-1에서 작성한 GlobalExceptionHandler를 Configuration에 Bean으로 등록했습니다. 필터링중 예외가 발생하게 된다면 Bean으로 등록한 GlobalExceptionHandler가 해당 예외를 캐치해 ResponseBody를 생성해 클라이언트로 반환하게 됩니다.
public class AuthException extends HttpStatusCodeException {
public AuthException(ErrorType errorType) {
super(errorType.getStatusCode(), errorType.getMessage());
}
@Override
public String getMessage() {
return getStatusText();
}
}
커스터마이징한 Exception입니다. 의도적인 예외가 발생한 경우 AuthException으로 throw되고 GlobalExceptionHandler를 통해 ResponseBody로 생성됩니다.
@Configuration
public class TokenKeyConfig {
@Bean
public KeyResolver tokenKeyResolver() {
return exchange -> {
var cookies = exchange.getRequest().getCookies().getFirst(TokenType.ACCESS.getName());
if (cookies != null) {
return Mono.just(cookies.getValue());
} else {
String clientIp = Objects.requireNonNull(exchange.getRequest().getRemoteAddress())
.getAddress()
.getHostAddress();
String hashedIp = DigestUtils.sha256Hex(clientIp);
return Mono.just(hashedIp);
}
};
}
}
쿠키가 있는 경우 쿠키값을 Mono로 감싸서 반환하고 없는 경우 클라이언트의 IP를 해싱처리해 Mono로 감싸 반환했습니다. 이를 통해 API Limiter에서 중복된 요청 등 과도한 트래픽을 일정 부분 제한할 수 있도록 합니다.
@EnableDiscoveryClient
@SpringBootApplication
public class GatewayServiceApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayServiceApplication.class, args);
}
}
Eureka Client로 등록하기 위해 @EnableDiscoveryClient 어노테이션을 붙여줍니다.
gateway-dev.yml
server:
port: 8080
spring:
cloud:
gateway:
default-filters:
- name: GlobalFilter
args:
baseMessage: Spring Cloud Gateway Global Filter
preLogger: true
postLogger: true
- name: RequestRateLimiter
args:
key-resolver: "#{@tokenKeyResolver}"
redis-rate-limiter.replenishRate: 5
redis-rate-limiter.burstCapacity: 30
redis-rate-limiter.requestedTokens: 3
globalcors:
cors-configurations:
'[/**]':
allowedOrigins:
- 'http://localhost:8081'
- 'http://localhost:8082'
- 'http://localhost:8083'
- 'http://localhost:8084'
allowedMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowedHeaders: '*'
allow-credentials: true
routes:
- id: accommodation
uri: lb://ACCOMMODATION
predicates:
- Path=/api/accommodations/**
- id: member
uri: lb://MEMBER
predicates:
- Path=/api/auth/**
- id: reservation
uri: lb://RESERVATION
predicates:
- Path=/api/reservation/**
filters:
- name: AuthorizationFilter
- id: room
uri: lb://room
predicates:
- Path=/api/accommodation/{accommodationId}/rooms/**
jwt:
access-secret: # access secret key
refresh-secret: # refresh secret key
issuer: test
access-token-expired-time: 86400000
refresh-token-expired-time: 604800000
eureka:
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://${eureka-username}:${eureka-password}@localhost:8761/eureka
이
위에서부터 하나씩 설명을 하자면 다음과 같습니다.
default-filters:
- name: GlobalFilter
args:
baseMessage: Spring Cloud Gateway Global Filter
preLogger: true
postLogger: true
- name: RequestRateLimiter
args:
key-resolver: "#{@tokenKeyResolver}"
redis-rate-limiter.replenishRate: 5
redis-rate-limiter.burstCapacity: 30
redis-rate-limiter.requestedTokens: 1
GlobalFilter 라는 클래스를 디폴트로 등록하고, arguments로써 baseMessage와, preLogger, postLogger라는 변수에 각각의 값을 지정합니다.
RequestRateLimiter는 서버가 클라이언트의 시간당 요청 횟수를 제한하는 것으로 서버 과부하를 막기위해 사용했습니다.
replenisRate : 초당 채워지는 Token의 수
burstCapacity : 최대 허용되는 버스트 요청 수
requestedTokens : 요청을 처리할 때 소모되는 토큰의 수
globalcors:
cors-configurations:
'[/**]':
allowedOrigins:
- 'http://localhost:8081'
- 'http://localhost:8082'
- 'http://localhost:8083'
- 'http://localhost:8084'
allowedMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowedHeaders: '*'
allow-credentials: true
이 부분은 CORS에 해당되는 이야기로, localhost의 8081~8084에에서 GET, POST, PUT등 메서드들의 요청을 허가 하겠다는 의미입니다.
routes:
- id: accommodation
uri: lb://ACCOMMODATION
predicates:
- Path=/api/accommodations/**
- id: member
uri: lb://MEMBER
predicates:
- Path=/api/auth/**
- id: reservation
uri: lb://RESERVATION
predicates:
- Path=/api/reservation/**
filters:
- name: AuthorizationFilter
- id: room
uri: lb://room
predicates:
- Path=/api/accommodation/{accommodationId}/rooms/**
각 predicates의 값 이하의 path가 들어오면 해당 id의 uri로 라우팅해준다는 의미입니다.
그리고 reservation의 경우 filters가 추가되어 있는데 특정 서비스에만 filter를 추가하는 의미로 인증/인가 filter를 추가했습니다.
jwt와 eureka는 생략하겠습니다.
먼저 Config Server, Eureka Server를 실행한 뒤 Gateway Service를 실행합니다
Gateway Service가 정상 실행하고 Eureka Server에 등록이 되어있다면
http://localhost:8761 에 접속해 Eureka Server의 username과 password를 입력해 로그인합니다.
그러면 아래와 같이 GATEWAY 서비스가 등록이 되어 있는 것을 볼 수 있습니다.

이제 라우팅이 정상적으로 동작하는지 확인해보겠습니다.
저는 현재 Member Service의 port를 8082로 설정한 상태입니다.
Gateway가 없다면 Member Service를 이용하기 위해 localhost:8082로 요청을 해야하지만 Gateway가 라우팅을 해주므로 localhost:8080으로 요청을 보내면 됩니다.

다음은 OpenFeign에 대해 포스팅하겠습니다.