Spring Cloud Gateway는 왜 Reactive 기반일까
Zuul 1.x의 Thread-per-Request 한계부터 Filter 체인 실행 순서, 동적 라우팅, Circuit Breaker 통합까지 — Gateway의 모든 설계 결정을 관통하는 하나의 원칙을 추적한다.
- 01 Spring Cloud Config는 왜 Git을 설정 저장소로 쓰는가
- 02 Eureka는 왜 AP를 선택했는가
- 03 분산 추적은 어떻게 서비스 경계를 넘는가
- 04 Spring Cloud LoadBalancer는 어떻게 서비스 이름을 IP:Port로 바꾸는가
- 05 Circuit Breaker는 어떻게 연쇄 장애를 막는가
- 06 Spring Cloud Gateway는 왜 Reactive 기반일까
- 07 MSA의 데이터 문제는 어떻게 푸는가
Spring Cloud Gateway는 Netty 위에서 동작한다. Tomcat 기반인 Zuul 1.x와 달리 이벤트 루프 스레드 몇 개로 수만 개의 동시 요청을 처리한다. 왜 이 선택을 했고, 이 선택이 Predicate 평가, Filter 체인, 동적 라우팅, 장애 격리까지 Gateway의 모든 설계 결정에 어떻게 관통하는가?
스레드 모델: Thread-per-Request vs 이벤트 루프
Zuul 1.x의 구조는 단순하다. 요청 1개가 오면 Tomcat 스레드 1개를 점유하고, 업스트림 응답이 올 때까지 그 스레드가 블로킹된다. 기본 스레드 풀이 200개라면 동시에 200개 요청을 처리할 수 있다. 201번째 요청은 큐에서 기다린다.
업스트림 서비스가 느려질수록 상황은 악화된다. 각 스레드가 더 오래 점유되니 더 빨리 고갈된다. “업스트림 서비스 지연 → Gateway 스레드 고갈 → 전체 시스템 응답 불가”라는 연쇄가 발생한다.
Spring Cloud Gateway는 Netty의 이벤트 루프를 사용한다. 요청이 오면 이벤트 루프 스레드가 처리를 시작하고, 업스트림 호출 직후 스레드를 반환한다. 응답이 도착하면 이벤트로 처리를 재개한다. CPU 코어 수 × 2개의 스레드로 수천 개의 동시 연결을 다룰 수 있다.
이벤트 루프 모델의 대가는 명확하다. 이벤트 루프 스레드에서 블로킹 코드를 실행하면 그 스레드가 처리해야 할 모든 요청이 멈춘다. Thread.sleep(), JDBC 쿼리, 동기 HTTP 호출은 절대 금지다. 블로킹이 불가피하다면 Schedulers.boundedElastic()으로 별도 스레드 풀에서 실행해야 한다. 팀이 Reactive 프로그래밍에 익숙하지 않다면 이 제약이 생산성에 상당한 영향을 준다.
Reactive Predicate와 Filter 체인
이벤트 루프 기반이라는 선택이 Predicate와 Filter의 설계를 결정한다. 블로킹이 허용되지 않으니 모든 인터페이스가 Reactive 타입을 반환한다.
RoutePredicateHandlerMapping은 요청이 오면 Flux<Route>를 순회하면서 각 Route의 AsyncPredicate<ServerWebExchange>.apply(exchange)를 호출한다. 반환 타입은 Publisher<Boolean>이다. 표준 Predicate<T>.test()의 boolean이 아니다. AND/OR 조합은 Flux.zip()으로 두 Predicate를 병렬 평가한 후 결합한다.
// AsyncPredicate AND 조합 — 병렬 평가
default AsyncPredicate<T> and(AsyncPredicate<? super T> other) {
return t -> Flux.zip(
Flux.from(apply(t)),
Flux.from(other.apply(t))
).map(tuple -> tuple.getT1() && tuple.getT2());
}
Filter 체인도 같은 원리다. GatewayFilter.filter(exchange, chain)의 반환 타입은 Mono<Void>다. chain.filter(exchange) 이전이 Pre-filter 로직, .then() 이후가 Post-filter 로직이다. Pre-filter는 Order 오름차순으로, Post-filter는 그 역순으로 실행된다.
FilteringWebHandler는 Global Filter와 Route Filter를 병합한 뒤 AnnotationAwareOrderComparator로 정렬해 체인을 구성한다. 핵심 내장 필터의 Order는 건드리면 안 된다. NettyRoutingFilter는 Integer.MIN_VALUE + 1, NettyWriteResponseFilter는 Integer.MIN_VALUE다. 인증 필터는 -100 이하, 로깅 필터는 -50 정도가 실무 권장 범위다.
exchange.mutate() — 불변 객체의 수정 패턴
ServerWebExchange, ServerHttpRequest, ServerHttpResponse는 모두 불변이다. 헤더를 추가하거나 경로를 재작성하려면 mutate()로 복사본을 만들어야 한다.
// JWT 클레임을 헤더로 주입하는 패턴
ServerHttpRequest newRequest = exchange.getRequest().mutate()
.header("X-User-Id", userId)
.header("X-User-Roles", roles)
.headers(headers -> headers.remove(HttpHeaders.AUTHORIZATION))
.build();
return chain.filter(exchange.mutate().request(newRequest).build());
RewritePathGatewayFilterFactory도 같은 패턴이다. 경로를 정규식으로 변환한 뒤 새 Request를 만들고, GATEWAY_REQUEST_URL_ATTR을 갱신한다. NettyRoutingFilter는 exchange.getRequest().getURI()가 아니라 이 속성을 참조해 업스트림 URI를 결정하기 때문에 이 속성 갱신이 누락되면 경로 재작성이 무시된다.
동적 라우팅 — CachingRouteLocator와 RefreshRoutesEvent
정적 YAML 라우트는 재배포 없이 변경할 수 없다. Gateway의 라우팅 계층은 이 문제를 CachingRouteLocator + RefreshRoutesEvent로 해결한다.
CachingRouteLocator는 CompositeRouteLocator(YAML 기반 RouteDefinitionRouteLocator + 커스텀 RouteLocator)의 결과를 메모리에 캐시한다. RefreshRoutesEvent가 발행되면 캐시를 무효화하고 하위 RouteLocator를 재조회한다.
Config Server 연동 시 흐름은 다음과 같다. Config Server에서 라우트 설정을 변경하고 /actuator/busrefresh를 호출하면, Spring Cloud Bus가 모든 Gateway 인스턴스에 EnvironmentChangeEvent를 전파한다. Gateway는 이를 수신해 RefreshRoutesEvent를 발행하고, 수십 초 내에 재배포 없이 트래픽 전환이 완료된다.
장애 격리 — Timeout과 Circuit Breaker
이벤트 루프 기반이어도 연결 자체는 유지된다. 업스트림이 응답 없이 5분을 버티면 Netty 연결이 5분간 점유되고, 이 연결이 쌓이면 연결 수 한계에 도달한다. connect-timeout과 response-timeout 설정이 필수인 이유다.
spring:
cloud:
gateway:
httpclient:
connect-timeout: 1000 # TCP 핸드셰이크 타임아웃 (ms)
response-timeout: 5s # 응답 첫 바이트 수신 타임아웃
라우트별 오버라이드는 metadata.response-timeout으로 설정한다. 글로벌 설정보다 라우트별 설정이 우선한다.
Circuit Breaker는 타임아웃보다 한 단계 위의 장애 격리다. SpringCloudCircuitBreakerFilterFactory가 Resilience4j의 ReactiveCircuitBreaker.run()으로 업스트림 호출을 감싼다. Circuit Breaker가 OPEN 상태면 chain.filter()를 아예 호출하지 않고 즉시 Fallback으로 분기한다. 다운된 서비스에 불필요한 부하를 가하지 않는다.
Retry와 Circuit Breaker를 함께 쓸 때 순서가 중요하다. Retry가 바깥(먼저 실행), Circuit Breaker가 안쪽(나중 실행)이어야 한다. Retry가 3회 재시도를 모두 소진한 후 그 실패를 Circuit Breaker가 하나의 논리적 실패로 카운트해야 Circuit Breaker가 너무 일찍 열리지 않는다.
정리
- Zuul 1.x는 Thread-per-Request, Gateway는 이벤트 루프다. 업스트림 지연이 클수록 차이는 극대화된다.
AsyncPredicate,Mono<Void>반환 Filter,exchange.mutate()패턴은 모두 Non-Blocking 제약의 귀결이다.- Filter 실행 순서는 Order 값으로 결정된다. 인증 필터(
-100)가NettyRoutingFilter(MIN_VALUE+1)보다 반드시 먼저 실행되어야 한다. - 동적 라우팅은
RefreshRoutesEvent로 캐시를 무효화해 재배포 없이 라우트를 변경한다. - Retry는 바깥, Circuit Breaker는 안쪽에 두어야 논리적 실패 단위로 카운트된다.