Spring Cloud Gateway — API Gateway 패턴의 구현과 활용
클라이언트가 10개의 마이크로서비스를 각각 직접 호출해야 한다면, 서비스 주소가 바뀔 때마다 클라이언트를 수정해야 할까요?
API Gateway는 클라이언트와 마이크로서비스 사이의 단일 진입점입니다. 라우팅, 인증, Rate Limiting, 로깅 같은 공통 관심사를 한곳에서 처리하여 각 서비스가 비즈니스 로직에만 집중할 수 있게 합니다.
Spring Cloud Gateway란
Spring Cloud Gateway는 Spring WebFlux 기반의 API Gateway 프레임워크입니다. 비동기/논블로킹으로 동작하여 높은 성능을 제공합니다.
핵심 개념
- Route: 요청을 어디로 보낼지 정의 (ID + URI + Predicate + Filter)
- Predicate: 요청이 이 라우트에 매칭되는지 판단하는 조건
- Filter: 요청/응답을 가공하는 로직
Client → Gateway → [Predicate 매칭] → [Filter 체인] → Backend Service
기본 설정
의존성
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
YAML로 라우트 설정
spring:
cloud:
gateway:
routes:
- id: order-service
uri: http://localhost:8081
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1
- id: user-service
uri: http://localhost:8082
predicates:
- Path=/api/users/**
filters:
- StripPrefix=1
서비스 디스커버리와 연동하면 lb:// 프리픽스로 동적 라우팅이 가능합니다.
- id: product-service
uri: lb://product-service # 서비스 디스커버리 사용
predicates:
- Path=/api/products/**
- Method=GET,POST
filters:
- StripPrefix=1
- AddRequestHeader=X-Gateway, true
Java 코드로 라우트 설정
@Configuration
public class GatewayConfig {
@Bean
public RouteLocator customRoutes(RouteLocatorBuilder builder) {
return builder.routes()
.route("order-service", r -> r
.path("/api/orders/**")
.filters(f -> f
.stripPrefix(1)
.addRequestHeader("X-Gateway", "true"))
.uri("lb://order-service"))
여러 Predicate를 and()로 조합하면 경로와 HTTP 메서드를 동시에 매칭할 수 있습니다.
.route("user-service", r -> r
.path("/api/users/**")
.and()
.method(HttpMethod.GET, HttpMethod.POST)
.filters(f -> f.stripPrefix(1))
.uri("lb://user-service"))
.build();
}
}
Predicate — 라우트 매칭 조건
Spring Cloud Gateway는 다양한 내장 Predicate를 제공합니다.
| Predicate | 설명 | 예시 |
|---|---|---|
Path | URL 경로 패턴 | Path=/api/orders/** |
Method | HTTP 메서드 | Method=GET,POST |
Header | 요청 헤더 | Header=X-Request-Id, \\d+ |
Query | 쿼리 파라미터 | Query=category, IT |
Host | 호스트 이름 | Host=**.example.com |
After/Before/Between | 시간 기반 | After=2026-03-01T00:00:00+09:00 |
여러 Predicate를 조합하면 세밀한 라우팅 규칙을 만들 수 있습니다.
Filter — 요청/응답 가공
내장 Filter
filters:
- StripPrefix=1 # 경로 앞부분 제거 (/api/orders → /orders)
- AddRequestHeader=X-Custom, value
- AddResponseHeader=X-Response, value
- RewritePath=/api/(?<segment>.*), /$\{segment}
- SetStatus=200
- Retry=3 # 3번 재시도
커스텀 Global Filter
모든 라우트에 적용되는 필터입니다. 로깅, 인증 같은 공통 관심사에 사용합니다.
@Component
public class LoggingFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String requestId = UUID.randomUUID().toString();
log.info("[{}] {} {} from {}",
requestId,
request.getMethod(),
request.getURI().getPath(),
request.getRemoteAddress());
long startTime = System.currentTimeMillis();
// 요청 헤더에 Request ID 추가
ServerHttpRequest mutatedRequest = request.mutate()
.header("X-Request-Id", requestId)
.build();
필터 체인 실행 후 then()으로 응답 시간과 상태 코드를 로깅합니다.
return chain.filter(exchange.mutate().request(mutatedRequest).build())
.then(Mono.fromRunnable(() -> {
long duration = System.currentTimeMillis() - startTime;
log.info("[{}] 응답 {} — {}ms",
requestId,
exchange.getResponse().getStatusCode(),
duration);
}));
}
@Override
public int getOrder() {
return -1; // 가장 먼저 실행
}
}
인증 Filter
@Component
public class AuthFilter implements GlobalFilter, Ordered {
private final JwtValidator jwtValidator;
private static final List<String> PUBLIC_PATHS = List.of(
"/api/auth/login",
"/api/auth/register",
"/health"
);
공개 경로는 인증을 건너뛰고, 나머지 경로는 토큰을 검증하여 유효하지 않으면 401을 반환합니다.
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// 공개 경로는 인증 건너뛰기
if (PUBLIC_PATHS.stream().anyMatch(path::startsWith)) {
return chain.filter(exchange);
}
String token = extractToken(exchange.getRequest());
if (token == null || !jwtValidator.isValid(token)) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
토큰 검증에 성공하면 사용자 정보를 내부 헤더로 전달하여 백엔드 서비스에서 활용할 수 있게 합니다.
// 인증된 사용자 정보를 헤더로 전달
String userId = jwtValidator.getUserId(token);
ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.header("X-User-Id", userId)
.build();
return chain.filter(exchange.mutate().request(mutatedRequest).build());
}
토큰 추출 헬퍼와 필터 실행 순서를 지정하는 getOrder() 메서드입니다.
private String extractToken(ServerHttpRequest request) {
String auth = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (auth != null && auth.startsWith("Bearer ")) {
return auth.substring(7);
}
return null;
}
@Override
public int getOrder() {
return 0;
}
}
Rate Limiting
Redis 기반의 요청 속도 제한을 설정할 수 있습니다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
spring:
cloud:
gateway:
routes:
- id: rate-limited-route
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10 # 초당 10개 허용
redis-rate-limiter.burstCapacity: 20 # 최대 버스트 20개
key-resolver: "#{@ipKeyResolver}"
@Configuration
public class RateLimiterConfig {
@Bean
public KeyResolver ipKeyResolver() {
// IP 주소 기반 Rate Limiting
return exchange -> Mono.just(
exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
}
@Bean
public KeyResolver userKeyResolver() {
// 사용자 ID 기반 Rate Limiting
return exchange -> Mono.just(
exchange.getRequest().getHeaders().getFirst("X-User-Id"));
}
}
Rate Limit을 초과하면 429 Too Many Requests가 반환됩니다.
Circuit Breaker 통합
Gateway에서 서킷 브레이커를 설정하면 백엔드 장애 시 Fallback 응답을 반환합니다.
spring:
cloud:
gateway:
routes:
- id: order-with-cb
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- name: CircuitBreaker
args:
name: orderCB
fallbackUri: forward:/fallback/orders
@RestController
public class FallbackController {
@GetMapping("/fallback/orders")
public ResponseEntity<Map<String, String>> orderFallback() {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of(
"message", "주문 서비스가 일시적으로 이용 불가합니다",
"fallback", "true"
));
}
}
서비스 디스커버리 연동
Eureka나 Kubernetes와 연동하면 서비스 주소를 하드코딩하지 않아도 됩니다.
spring:
cloud:
gateway:
discovery:
locator:
enabled: true # 서비스 디스커버리 자동 라우트
lower-case-service-id: true
이 설정으로 lb://ORDER-SERVICE처럼 서비스 이름으로 라우팅할 수 있습니다.
CORS 설정
spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "https://my-frontend.com"
allowedMethods:
- GET
- POST
- PUT
- DELETE
allowedHeaders: "*"
allowCredentials: true
실무 팁
- Gateway는 단일 진입점 이므로 고가용성과 성능 이 최우선입니다
- Gateway에서 비즈니스 로직은 최소화 하고, 인증/라우팅/로깅 같은 횡단 관심사만 처리하세요
- Health Check 엔드포인트로 Gateway 자체의 상태를 모니터링하세요
- Rate Limiting의
burstCapacity를 적절히 설정하여 정상 트래픽 급증에 대응하세요
주의할 점
1. Gateway에서 블로킹 코드를 실행하면 전체 처리량이 급락한다
Spring Cloud Gateway는 WebFlux(Netty) 기반이므로 이벤트 루프 스레드에서 JDBC 호출이나 Thread.sleep() 같은 블로킹 연산을 수행하면 전체 요청 처리가 멈춥니다. 인증 필터에서 DB를 조회해야 한다면 R2DBC나 WebClient 같은 논블로킹 API를 사용해야 합니다.
2. Gateway는 단일 진입점이므로 장애 시 전체 서비스가 접근 불가가 된다
모든 트래픽이 Gateway를 경유하므로 Gateway가 다운되면 백엔드 서비스가 정상이어도 클라이언트가 접근할 수 없습니다. 반드시 Gateway를 2대 이상 배포하고, 앞단에 로드밸런서(L4/L7)를 두어 고가용성을 확보하세요.
3. Rate Limiter 설정에서 Redis가 다운되면 모든 요청이 거부될 수 있다
RequestRateLimiter 필터가 Redis를 사용하는데, Redis에 장애가 발생하면 Rate Limit 판단 자체가 불가능하여 모든 요청이 429로 거부될 수 있습니다. Redis 장애 시 Rate Limiting을 우회하는 Fallback 전략을 미리 설계해야 합니다.
정리
- Spring Cloud Gateway는 WebFlux 기반 비동기/논블로킹 API Gateway입니다
- Route + Predicate + Filter 구조로 라우팅 규칙을 정의합니다
- Global Filter 로 인증, 로깅 등 공통 관심사를 처리하고, Rate Limiter 로 트래픽을 제어합니다
- 서비스 디스커버리와 연동하면
lb://서비스명으로 동적 라우팅이 가능합니다