Spring Cloud Gateway
Spring Cloud Gateway는 Spring 생태계의 API Gateway 솔루션이다. Netflix Zuul(블로킹)의 후계자로, Spring WebFlux(Reactor/Netty) 기반의 비동기 논블로킹 방식으로 동작한다. 라우팅, 필터링, 로드밸런싱, 인증, Rate Limiting, Circuit Breaker를 통합 제공한다.
비유: 대형 빌딩 로비의 안내 데스크와 같다. 방문객(요청)이 오면 신원 확인(인증), 출입 제한(Rate Limiting), 방문 기록(로깅) 후 각 부서(마이크로서비스)로 안내한다. 각 부서가 아닌 로비에서 모든 공통 절차를 처리하므로 각 부서는 자기 업무에만 집중할 수 있다.
API Gateway 역할
마이크로서비스 아키텍처에서 클라이언트가 각 서비스를 직접 호출하면 인증, 로깅, Rate Limiting을 모든 서비스에 중복 구현해야 한다. API Gateway는 이 공통 관심사를 단일 진입점에 집중시킨다.
graph LR
C["클라이언트"] --> GW["API Gateway"]
GW --> R["라우팅"]
R --> US["User Service"]
R --> OS["Order Service"]
R --> PS["Product Service"]
R --> PAY["Payment Service"]
의존성 설정
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
</dependency>
Spring Cloud Gateway는 WebFlux 기반이므로 spring-boot-starter-web과 함께 사용하면 충돌한다. WebFlux 전용으로 구성해야 한다.
Route / Predicate / Filter 핵심 개념
Gateway의 모든 동작은 Route, Predicate, Filter 세 개념으로 설명된다.
- Route: 라우팅 규칙 단위. ID, URI, Predicate 목록, Filter 목록으로 구성
- Predicate: 요청이 이 Route에 해당하는지 판별하는 조건
- Filter: 요청/응답을 변환하거나 횡단 관심사를 처리
graph LR
REQ["요청"] --> P{"Predicate"}
P -->|"YES"| F["Filter 체인 순차 통과"]
P -->|"NO"| NEXT["다음 Route 시도"]
F --> URI["목적지 URI로 전달"]
URI --> RESP["응답 Filter 역순 통과"]
RESP --> C["클라이언트"]
Route 설정 (YAML)
spring:
cloud:
gateway:
routes:
- id: user-service
uri: http://user-service:8081
predicates:
- Path=/api/users/**
filters:
- StripPrefix=1 # /api 제거 후 전달 (/api/users/1 → /users/1)
- id: order-service
uri: lb://order-service # lb:// = 서비스 디스커버리 로드밸런싱
predicates:
- Path=/api/orders/**
- Method=GET,POST
filters:
- StripPrefix=1
- AddRequestHeader=X-Gateway-Source, api-gateway
lb:// 프로토콜을 사용하면 Eureka 등 서비스 디스커버리에서 인스턴스 목록을 가져와 자동으로 로드밸런싱한다.
Route 설정 (Java DSL)
@Configuration
public class GatewayConfig {
@Bean
public RouteLocator routeLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("user-service", r -> r
.path("/api/users/**")
.filters(f -> f
.stripPrefix(1)
.addRequestHeader("X-Gateway-Source", "api-gateway")
.addResponseHeader("X-Response-Time", LocalDateTime.now().toString())
)
.uri("lb://user-service")
)
.route("order-service", r -> r
.path("/api/orders/**")
.and()
.method(HttpMethod.GET, HttpMethod.POST)
.filters(f -> f.stripPrefix(1))
.uri("lb://order-service")
)
.build();
}
}
Predicate
요청이 특정 조건을 만족하는지 검사한다. 여러 조건을 AND로 조합할 수 있다.
predicates:
- Path=/api/** # 경로 패턴
- Method=GET,POST,PUT # HTTP 메서드
- Header=X-Request-Id, \d+ # 헤더 값 (정규식)
- Query=version, v\d+ # 쿼리 파라미터
- Host=**.example.com # 호스트
- Cookie=sessionId, \w+ # 쿠키
- RemoteAddr=192.168.1.1/24 # 원격 IP 대역
- Weight=group1, 80 # 요청 비율 (A/B 테스트)
카나리 배포 예시: Weight Predicate로 트래픽을 비율 분배해 새 버전을 점진적으로 적용한다.
routes:
- id: product-service-v1
uri: lb://product-service-v1
predicates:
- Path=/api/products/**
- Weight=product, 90 # 90% 트래픽 → 안정 버전
- id: product-service-v2
uri: lb://product-service-v2
predicates:
- Path=/api/products/**
- Weight=product, 10 # 10% 트래픽 → 신규 버전 (카나리)
Filter
요청/응답을 변환하거나 횡단 관심사(인증, 로깅 등)를 처리한다. 요청 시 순방향, 응답 시 역방향으로 실행된다.
내장 필터
filters:
- StripPrefix=1 # 경로 앞부분 제거 (/api/users → /users)
- PrefixPath=/v1 # 경로 앞에 추가 (/users → /v1/users)
- RewritePath=/api/(?<seg>.*), /$\{seg} # 정규식 경로 재작성
- AddRequestHeader=X-Source, gateway
- AddResponseHeader=X-Frame-Options, DENY
- RemoveRequestHeader=Cookie
- Retry=3 # 재시도 횟수
- RedirectTo=302, https://example.com
커스텀 글로벌 필터 (요청 로깅)
글로벌 필터는 모든 Route에 적용된다.
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestLoggingFilter implements GlobalFilter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String requestId = UUID.randomUUID().toString();
long startTime = System.currentTimeMillis();
log.info("[{}] {} {}", requestId, request.getMethod(), request.getPath());
// 1️⃣ 요청에 X-Request-Id 헤더를 추가해서 하위 서비스로 전달
return chain.filter(exchange.mutate()
.request(request.mutate()
.header("X-Request-Id", requestId)
.build())
.build()
).then(Mono.fromRunnable(() -> {
// 2️⃣ 응답이 완료된 후 처리 시간 로깅
long duration = System.currentTimeMillis() - startTime;
log.info("[{}] {} {}ms", requestId, exchange.getResponse().getStatusCode(), duration);
}));
}
}
then(Mono.fromRunnable(...)) 패턴은 응답이 클라이언트에 전송된 후에 실행된다. WebFlux의 리액티브 체이닝 방식으로 응답 후 처리를 구현한다.
커스텀 인증 필터
@Component
public class AuthenticationGatewayFilterFactory
extends AbstractGatewayFilterFactory<AuthenticationGatewayFilterFactory.Config> {
private final JwtTokenProvider jwtTokenProvider;
public AuthenticationGatewayFilterFactory(JwtTokenProvider jwtTokenProvider) {
super(Config.class);
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
String token = extractToken(request);
if (token == null) {
return unauthorizedResponse(exchange);
}
try {
Claims claims = jwtTokenProvider.validateAndGetClaims(token);
// JWT 검증 결과를 헤더로 하위 서비스에 전달
// 하위 서비스는 JWT 검증 없이 헤더만 신뢰하면 됨
ServerHttpRequest modifiedRequest = request.mutate()
.header("X-User-Id", claims.getSubject())
.header("X-User-Role", claims.get("role", String.class))
.build();
return chain.filter(exchange.mutate().request(modifiedRequest).build());
} catch (JwtException e) {
return unauthorizedResponse(exchange);
}
};
}
private String extractToken(ServerHttpRequest request) {
String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return null;
}
private Mono<Void> unauthorizedResponse(ServerWebExchange exchange) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
public static class Config {}
}
Gateway에서 JWT를 한 번 검증하고 X-User-Id 헤더로 전달하면, 하위 서비스는 JWT 검증 로직 없이 헤더만 읽어서 사용자 정보를 신뢰할 수 있다.
라우트에 적용
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- Authentication # 커스텀 인증 필터 (이름은 팩토리 클래스에서 "Authentication" 추출)
- StripPrefix=1
요청 처리 전체 흐름
graph LR
C["클라이언트"] --> GW["Gateway"]
GW --> AUTH["JWT검증/RateLimit"]
AUTH --> SVC["서비스"]
SVC --> RESP["응답 반환"]
RESP --> C
Rate Limiting (Redis Token Bucket)
Redis 기반 Token Bucket 알고리즘으로 Rate Limiting을 구현한다. 토큰이 있어야 요청이 통과된다. 토큰은 replenishRate로 초당 보충되고, burstCapacity는 순간 최대 허용량이다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/users/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10 # 초당 10개 토큰 보충
redis-rate-limiter.burstCapacity: 20 # 버스트 최대 20개
redis-rate-limiter.requestedTokens: 1 # 요청당 소모 토큰 수
key-resolver: "#{@userKeyResolver}" # 어떤 단위로 Rate Limit?
@Configuration
public class RateLimitConfig {
// 인증 사용자는 userId 기준, 미인증은 IP 기준
@Bean
public KeyResolver userKeyResolver() {
return exchange -> {
String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id");
if (userId != null) {
return Mono.just(userId);
}
return Mono.just(
Objects.requireNonNull(exchange.getRequest().getRemoteAddress())
.getAddress().getHostAddress()
);
};
}
// API 경로별 Rate Limiting
@Bean
public KeyResolver pathKeyResolver() {
return exchange -> Mono.just(exchange.getRequest().getPath().value());
}
}
Rate Limit 초과 시 429 Too Many Requests를 응답하고 X-RateLimit-Remaining 헤더로 남은 허용 횟수를 알려준다.
Circuit Breaker (Resilience4j 연동)
하위 서비스가 느리거나 오류가 많으면 Circuit Breaker가 요청을 차단하고 fallback을 반환한다.
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- name: CircuitBreaker
args:
name: orderServiceCB
fallbackUri: forward:/fallback/orders
- name: Retry
args:
retries: 3
statuses: BAD_GATEWAY, SERVICE_UNAVAILABLE
methods: GET
backoff:
firstBackoff: 100ms
maxBackoff: 500ms
factor: 2
resilience4j:
circuitbreaker:
instances:
orderServiceCB:
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 50
waitDurationInOpenState: 5s
permittedNumberOfCallsInHalfOpenState: 3
Fallback 컨트롤러
@RestController
@RequestMapping("/fallback")
public class FallbackController {
@GetMapping("/orders")
public ResponseEntity<Map<String, String>> orderFallback(ServerWebExchange exchange) {
// Circuit Breaker가 열려있어 fallback이 호출됨
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of(
"error", "ORDER_SERVICE_UNAVAILABLE",
"message", "주문 서비스가 일시적으로 사용 불가합니다. 잠시 후 다시 시도해주세요."
));
}
}
CORS 전역 설정
spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "https://app.example.com"
allowedMethods: [GET, POST, PUT, DELETE, OPTIONS]
allowedHeaders: "*"
allowCredentials: true
maxAge: 3600
Gateway에서 CORS를 처리하면 각 서비스에서 중복으로 설정할 필요가 없다. 단, allowCredentials: true이면 allowedOrigins에 와일드카드(*)를 사용할 수 없다.
전체 설정 예시
spring:
cloud:
gateway:
default-filters:
- AddResponseHeader=X-Gateway-Version, 1.0
routes:
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/users/**
filters:
- Authentication
- StripPrefix=1
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 20
redis-rate-limiter.burstCapacity: 40
key-resolver: "#{@userKeyResolver}"
- name: CircuitBreaker
args:
name: userServiceCB
fallbackUri: forward:/fallback/users
- id: public-api
uri: lb://public-service
predicates:
- Path=/api/public/**
filters:
- StripPrefix=1
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 5
redis-rate-limiter.burstCapacity: 10
key-resolver: "#{@pathKeyResolver}"
극한 시나리오
시나리오 1: 하위 서비스 전체 다운
Circuit Breaker가 OPEN 상태가 되면 Gateway는 하위 서비스를 호출하지 않고 즉시 fallback을 반환한다. 이를 통해 Gateway 자체가 하위 서비스의 느린 응답에 스레드를 점유당하는 상황을 방지한다.
시나리오 2: Rate Limit과 인증 순서
인증 필터가 Rate Limit 필터보다 먼저 실행되어야 한다. Rate Limit을 먼저 적용하면 인증되지 않은 요청이 토큰을 소모해 정상 사용자의 Rate Limit에 영향을 줄 수 있다.
filters:
- Authentication # 1순위: 먼저 인증 (X-User-Id 설정)
- name: RequestRateLimiter # 2순위: 인증된 userId 기준 Rate Limit
args:
key-resolver: "#{@userKeyResolver}"
시나리오 3: WebFlux 블로킹 코드 혼입
Gateway 필터에서 동기 블로킹 코드(RestTemplate, JDBC 직접 호출 등)를 사용하면 이벤트 루프 스레드를 블로킹해 전체 Gateway 성능이 저하된다.
// 잘못된 예: 필터에서 동기 DB 조회
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
User user = userRepository.findById(userId).orElseThrow(); // 블로킹!
...
}
// 올바른 예: 리액티브 Repository 사용
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return reactiveUserRepository.findById(userId)
.flatMap(user -> chain.filter(exchange));
}
시나리오 4: 카나리 배포 중 설정 변경
Weight Predicate를 사용한 카나리 배포 중 설정을 동적으로 변경하려면 Actuator의 /actuator/gateway/refresh를 호출한다. 단, 설정 변경 중 일시적으로 두 비율의 합이 100%를 벗어날 수 있으므로 주의가 필요하다.
왜 이 기술인가?
| 방식 | 언어 | 성능 | Spring 통합 | 적합한 상황 |
|---|---|---|---|---|
| Spring Cloud Gateway | Java (Reactor Netty) | 높음 (논블로킹) | 완벽 | Spring 기반 마이크로서비스 |
| Nginx | C | 매우 높음 | 없음 | 정적 라우팅, 인프라 레벨 |
| Kong | Lua / Go | 높음 | 플러그인 필요 | 플러그인 생태계 중시 |
| AWS API Gateway | 관리형 | 매우 높음 | 없음 | 서버리스, AWS 종속 허용 |
| Zuul 1.x | Java (블로킹) | 낮음 | 완벽 | 레거시 (신규 도입 비권장) |
결론: Spring 기반 마이크로서비스 환경에서는 Spring Cloud Gateway가 표준이다. Reactor Netty 기반 논블로킹으로 Zuul 대비 처리량이 월등하고, Spring Security·Eureka·Config와 자연스럽게 통합된다.
실무에서 자주 하는 실수
-
Gateway에서 블로킹 코드 실행 — Gateway는 Reactor Netty 기반이므로 이벤트 루프 스레드에서
Thread.sleep(), JDBC 호출 등 블로킹 작업을 실행하면 전체 처리가 멈춘다. 블로킹 작업은 반드시Schedulers.boundedElastic()으로 오프로드해야 한다. -
Route 순서를 잘못 설정 — Gateway는 Route를 위에서 아래로 순서대로 평가한다. 광범위한 패턴(
/api/**)을 좁은 패턴(/api/admin/**)보다 위에 두면 관리자 경로가 일반 라우트로 처리된다. -
Circuit Breaker fallbackUri에 존재하지 않는 경로 지정 —
fallbackUri: forward:/fallback으로 설정했지만 해당 컨트롤러가 없으면 Circuit Breaker 발동 시 500 오류가 반환된다. 폴백 엔드포인트를 반드시 구현해야 한다. -
Rate Limiter 키 해석기(KeyResolver) 없이 배포 —
KeyResolver빈을 정의하지 않으면 기본값으로PrincipalNameKeyResolver가 사용되어 인증되지 않은 요청에서 오류가 발생한다. IP 기반이든 사용자 기반이든 명시적으로 정의해야 한다. -
Gateway 타임아웃을 업스트림 서비스보다 짧게 설정 —
connect-timeout: 1000ms인데 업스트림 서비스 처리 시간이 2초면 타임아웃이 먼저 발생한다. Gateway 타임아웃은 업스트림 최대 응답 시간보다 넉넉하게 설정하고, Circuit Breaker로 장애를 감지해야 한다.
면접 포인트
Q1. Spring Cloud Gateway와 Zuul의 차이는?
Zuul 1.x는 서블릿 기반 동기/블로킹 모델이다. Spring Cloud Gateway는 Reactor Netty 기반 비동기/논블로킹으로 동일한 스레드 수로 훨씬 많은 요청을 처리할 수 있다. Zuul 2는 논블로킹이지만 Spring과의 통합이 부족해 신규 도입 시 Gateway가 표준이다.
Q2. GlobalFilter와 GatewayFilter의 차이는?
GlobalFilter는 모든 Route에 적용되고,GatewayFilter는 특정 Route에만 적용된다. 인증·로깅 같은 공통 관심사는GlobalFilter로, 특정 서비스의 헤더 변환이나 Rate Limiting은GatewayFilter로 구현한다.
Q3. Circuit Breaker를 Gateway에 통합하는 방법은?
spring-cloud-starter-circuitbreaker-reactor-resilience4j의존성 추가 후 Route에CircuitBreakerGatewayFilter를 설정한다.fallbackUri로 폴백 응답을 반환하고, Resilience4j 설정으로 OPEN 임계값과 대기 시간을 조정한다.
Q4. Gateway에서 JWT 인증을 어떻게 구현하는가?
GlobalFilter를 구현해Authorization헤더에서 JWT를 추출하고 검증한다. 검증 성공 시exchange.getRequest().mutate().header("X-User-Id", userId)로 다운스트림에 사용자 정보를 전달한다. Spring Security의ReactiveSecurityContextHolder와 통합하면 더 표준적인 구현이 된다.
Q5. Predicate와 Filter의 역할 구분은?
Predicate는 요청이 Route에 매칭되는지 판단한다(경로, 헤더, 쿼리 파라미터, 시간대 등).Filter는 매칭된 요청/응답을 변환한다(헤더 추가/제거, 인증, Rate Limiting, 경로 재작성). Predicate는 라우팅 결정, Filter는 처리 로직이다.
댓글