Spring Cloud Gateway는 마이크로서비스 아키텍처(MSA)에서 단일 진입점 역할을 하는 API Gateway입니다. Netty 기반 비동기-논블로킹 방식으로 동작하며, Route / Predicate / Filter 세 가지 핵심 개념을 중심으로 설계되어 있습니다. 이 글에서는 Spring Cloud Gateway의 필요성부터 아키텍처, 필터, 라우팅, 인증/인가, Rate Limiting, Circuit Breaker, 모니터링, 극한 시나리오, 실무 Best Practice까지 완전히 정리합니다.


1. API Gateway란? 왜 필요한가?

MSA에서의 문제 — 클라이언트가 마이크로서비스를 직접 호출하면

모놀리식 아키텍처에서는 클라이언트가 단일 서버와 통신합니다. 하지만 MSA에서는 수십~수백 개의 서비스가 분산되어 있습니다. 클라이언트가 각 서비스를 직접 호출할 경우 다음 문제가 발생합니다.

[클라이언트]
    |
    |--- HTTP --> [주문 서비스 :8081]
    |--- HTTP --> [상품 서비스 :8082]
    |--- HTTP --> [회원 서비스 :8083]
    |--- HTTP --> [결제 서비스 :8084]
    |--- HTTP --> [배송 서비스 :8085]

문제점 목록:

  1. 클라이언트 복잡도 증가 — 각 서비스의 주소, 포트, 프로토콜을 클라이언트가 모두 알아야 합니다.
  2. 인증/인가 중복 구현 — 각 서비스마다 JWT 검증 로직을 반복합니다.
  3. CORS 처리 분산 — 모든 서비스에서 CORS 설정을 관리해야 합니다.
  4. 로드밸런싱 불가 — 클라이언트는 서비스 인스턴스가 몇 개인지 알 수 없습니다.
  5. 공통 관심사(Cross-Cutting Concern) 중복 — 로깅, 추적, Rate Limiting을 서비스마다 구현합니다.
  6. 서비스 주소 노출 — 내부 서비스 IP/포트가 외부에 노출됩니다.

API Gateway의 역할

[클라이언트]
    |
    v
[API Gateway] <--- 단일 진입점
    |
    |-- 인증/인가 (JWT 검증)
    |-- Rate Limiting (트래픽 제어)
    |-- 로드밸런싱 (lb://order-service)
    |-- 라우팅 (/order/** -> 주문 서비스)
    |-- 로깅/추적 (MDC, Zipkin)
    |-- Circuit Breaker (Resilience4j)
    |
    +---> [주문 서비스] (내부망)
    +---> [상품 서비스] (내부망)
    +---> [회원 서비스] (내부망)
    +---> [결제 서비스] (내부망)

API Gateway는 다음 역할을 담당합니다.

  • 단일 진입점(Single Entry Point): 모든 외부 요청이 Gateway를 통과합니다.
  • 인증/인가: JWT 토큰 검증, OAuth2 처리를 중앙화합니다.
  • 로드밸런싱: 서비스 레지스트리(Eureka)와 연동하여 요청을 분산합니다.
  • 라우팅: URL 패턴, 헤더, 메서드 기반으로 요청을 적절한 서비스로 전달합니다.
  • 공통 필터: 로깅, 추적 ID 주입, 응답 변환을 한 곳에서 처리합니다.
  • 트래픽 제어: Rate Limiting, Circuit Breaker, Retry로 시스템을 보호합니다.

Netflix Zuul → Spring Cloud Gateway 전환 이유

Netflix Zuul 1.x는 서블릿 기반 동기-블로킹 모델입니다. 각 요청마다 스레드를 점유하므로 대규모 동시 연결 처리에 한계가 있습니다.

항목 Netflix Zuul 1.x Netflix Zuul 2.x Spring Cloud Gateway
기반 Servlet (블로킹) Netty (비동기) Netty (비동기)
프로그래밍 모델 동기 비동기 Reactive (WebFlux)
Spring Boot 통합 보통 복잡 완벽
유지보수 Netflix 주도 Netflix 주도 Spring 팀 주도
Spring Cloud 지원 공식 지원 미지원 공식 지원
WebSocket 제한적 지원 지원
성능 낮음 높음 높음

Zuul 2.x는 Netflix에서 공개했지만 Spring Cloud가 공식 통합을 지원하지 않아 실무에서 채택이 어렵습니다. Spring Cloud Gateway는 Spring 팀이 직접 개발하여 Spring Boot/Cloud 생태계와 완벽하게 통합됩니다.


Spring Cloud Gateway vs Netflix Zuul vs Kong vs Nginx 비교

항목 Spring Cloud Gateway Netflix Zuul 1.x Kong Nginx
언어 Java (Spring) Java (Netflix) Lua (OpenResty) C
비동기 완전 비동기 동기 비동기 비동기
프로그래밍 Java/Kotlin 코드 Java 코드 Lua 플러그인 설정 파일
Spring 통합 완벽 보통 없음 없음
서비스 디스커버리 Eureka/Consul Eureka DNS 수동
Rate Limiting Redis 연동 미지원 (직접 구현) 내장 플러그인
Circuit Breaker Resilience4j 미지원 플러그인 없음
성능 높음 낮음 매우 높음 매우 높음
학습 비용 낮음 (Java) 낮음 (Java) 높음 (Lua) 중간
적합 환경 Spring MSA 레거시 Spring 언어 무관 대규모 정적/단순 프록시

선택 기준:

  • Spring Boot 기반 MSA → Spring Cloud Gateway
  • 언어 무관, 플러그인 생태계 필요 → Kong
  • 단순 리버스 프록시, 최고 성능 → Nginx
  • 기존 Netflix OSS 스택 → Zuul (신규 프로젝트에는 비권장)

2. 아키텍처

Netty 기반 비동기/논블로킹

Spring Cloud Gateway는 Spring WebFlux 위에서 동작하며, 이는 곧 Netty 이벤트 루프 모델을 사용한다는 의미입니다.

[클라이언트 요청]
        |
        v
  [Netty Event Loop]
  (CPU 코어 수 스레드)
        |
        v
  [비동기 처리 파이프라인]
  (스레드 점유 없이 I/O 완료 이벤트 대기)
        |
        v
  [업스트림 서비스 응답]
        |
        v
  [클라이언트 응답]

블로킹 vs 논블로킹 비교:

[블로킹 (Zuul 1.x)]
Thread-1: 요청수신 → [DB 대기...........] → 응답  (스레드 점유)
Thread-2: 요청수신 → [API 대기.....] → 응답      (스레드 점유)
Thread-3: 요청수신 → [파일 대기........] → 응답   (스레드 점유)
1만 요청 = 1만 스레드 = ~10GB 메모리

[논블로킹 (Spring Cloud Gateway)]
Event Loop: 요청수신 → I/O 등록 → 다음요청 처리
           [I/O 완료 이벤트] → 응답 전송
4개 스레드로 1만 요청 처리 가능

Route → Predicate → Filter 구조

Spring Cloud Gateway의 핵심 개념 세 가지입니다.

┌─────────────────────────────────────────────────────────┐
│                    Spring Cloud Gateway                  │
│                                                         │
│  Route (라우트)                                          │
│  ┌─────────────────────────────────────────────────┐    │
│  │  ID: order-route                                │    │
│  │  URI: lb://order-service                        │    │
│  │                                                 │    │
│  │  Predicate (조건)          Filter (처리)         │    │
│  │  ┌──────────────────┐    ┌──────────────────┐  │    │
│  │  │ Path=/order/**   │    │ AddRequestHeader  │  │    │
│  │  │ Method=GET,POST  │ +  │ RewritePath      │  │    │
│  │  │ Header=X-Api-Key │    │ CircuitBreaker   │  │    │
│  │  └──────────────────┘    └──────────────────┘  │    │
│  └─────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────┘
  • Route: Gateway의 라우팅 단위. ID, 목적지 URI, Predicate 목록, Filter 목록으로 구성됩니다.
  • Predicate: 요청이 특정 라우트에 해당하는지 판단하는 조건. java.util.function.Predicate<ServerWebExchange> 기반입니다.
  • Filter: 요청/응답을 가로채어 변환하는 컴포넌트. Pre Filter(요청 전)와 Post Filter(응답 후)로 나뉩니다.

HandlerMapping → WebHandler → Filter Chain 처리 흐름

HTTP 요청
    │
    ▼
┌─────────────────────────┐
│   HttpWebHandlerAdapter │  (Netty → WebFlux 진입점)
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│  RoutePredicateHandler  │  (라우트 매칭)
│  Mapping                │  모든 Route의 Predicate를
│                         │  순서대로 평가
└────────────┬────────────┘
             │ 매칭된 Route
             ▼
┌─────────────────────────┐
│  FilteringWebHandler    │  (필터 체인 구성)
│                         │  GlobalFilter + GatewayFilter
│                         │  order 값 기준 정렬
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│   Filter Chain          │
│                         │
│  [Pre Filters]          │  순서대로 실행
│   ↓ GlobalFilter 1      │  (낮은 order 먼저)
│   ↓ GlobalFilter 2      │
│   ↓ GatewayFilter A     │
│   ↓ GatewayFilter B     │
│                         │
│  [Proxied Request]      │  업스트림 서비스 호출
│   ↓                     │
│  [Post Filters]         │  역순으로 실행
│   ↑ GatewayFilter B     │  (높은 order 먼저)
│   ↑ GatewayFilter A     │
│   ↑ GlobalFilter 2      │
│   ↑ GlobalFilter 1      │
└────────────┬────────────┘
             │
             ▼
         HTTP 응답

핵심 클래스:

  • RoutePredicateHandlerMapping: 들어온 요청에 매칭되는 Route를 찾습니다.
  • FilteringWebHandler: 매칭된 Route의 필터 목록과 GlobalFilter를 합쳐 체인을 구성합니다.
  • NettyRoutingFilter: 실제 업스트림 서비스로 HTTP 요청을 프록시합니다.
  • NettyWriteResponseFilter: 업스트림 응답을 클라이언트에 씁니다.

3. Route 설정

의존성 추가

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
// build.gradle
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'

Spring Cloud Gateway는 WebFlux 기반이므로 spring-boot-starter-web(서블릿)과 함께 사용할 수 없습니다. 의존성 충돌이 발생합니다.


application.yml 기반 설정

spring:
  cloud:
    gateway:
      routes:
        # 주문 서비스 라우트
        - id: order-route
          uri: lb://order-service          # 로드밸런서 사용
          predicates:
            - Path=/api/orders/**          # 경로 매칭
            - Method=GET,POST              # HTTP 메서드
          filters:
            - StripPrefix=1               # /api 제거 후 전달
            - AddRequestHeader=X-Gateway-Source, spring-cloud-gateway

        # 상품 서비스 라우트
        - id: product-route
          uri: lb://product-service
          predicates:
            - Path=/api/products/**
            - Header=X-Api-Version, v2    # 헤더 조건
          filters:
            - RewritePath=/api/products/(?<segment>.*), /products/${segment}

        # 회원 서비스 라우트 (인증 불필요 경로)
        - id: member-public-route
          uri: lb://member-service
          predicates:
            - Path=/api/members/login, /api/members/register
            - Method=POST
          order: 1                        # 낮을수록 우선순위 높음

        # 회원 서비스 라우트 (인증 필요 경로)
        - id: member-auth-route
          uri: lb://member-service
          predicates:
            - Path=/api/members/**
          filters:
            - name: CircuitBreaker
              args:
                name: memberCircuitBreaker
                fallbackUri: forward:/fallback/member
          order: 2

      # 전역 기본 필터
      default-filters:
        - AddResponseHeader=X-Gateway-Version, 1.0
        - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin

      # CORS 전역 설정
      globalcors:
        cors-configurations:
          '[/**]':
            allowedOriginPatterns: "*"
            allowedMethods:
              - GET
              - POST
              - PUT
              - DELETE
              - OPTIONS
            allowedHeaders: "*"
            allowCredentials: true
            maxAge: 3600

Java DSL (RouteLocatorBuilder)

코드로 라우트를 정의하면 동적 라우팅, 조건 분기, 재사용이 용이합니다.

@Configuration
public class GatewayConfig {

    @Bean
    public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
        return builder.routes()

            // 주문 서비스 라우트
            .route("order-route", r -> r
                .path("/api/orders/**")
                .and()
                .method(HttpMethod.GET, HttpMethod.POST)
                .filters(f -> f
                    .stripPrefix(1)
                    .addRequestHeader("X-Gateway-Source", "spring-cloud-gateway")
                    .addResponseHeader("X-Route-Id", "order-route")
                    .circuitBreaker(c -> c
                        .setName("orderCB")
                        .setFallbackUri("forward:/fallback/order")
                    )
                )
                .uri("lb://order-service")
            )

            // 상품 서비스 — 경로 재작성
            .route("product-route", r -> r
                .path("/api/products/**")
                .filters(f -> f
                    .rewritePath("/api/products/(?<segment>.*)", "/products/${segment}")
                    .retry(config -> config
                        .setRetries(3)
                        .setMethods(HttpMethod.GET)
                        .setBackoff(Duration.ofMillis(100), Duration.ofSeconds(1), 2, true)
                    )
                )
                .uri("lb://product-service")
            )

            // 정적 파일 — 특정 호스트 기반 라우팅
            .route("static-route", r -> r
                .host("static.example.com")
                .uri("http://cdn.example.com")
            )

            .build();
    }
}

Predicate 종류

Path Predicate

predicates:
  - Path=/api/orders/{orderId}, /api/orders/**
.path("/api/orders/{orderId}", "/api/orders/**")

Host Predicate

predicates:
  - Host=**.example.com, api.example.org

Method Predicate

predicates:
  - Method=GET, POST, PUT

Header Predicate — 정규표현식 지원

predicates:
  - Header=X-Api-Key, \w{32}   # 32자 영숫자

Query Predicate

predicates:
  - Query=page, \d+            # page 파라미터가 숫자
  - Query=version              # version 파라미터 존재 여부만 확인

Cookie Predicate

predicates:
  - Cookie=session-id, .+

Weight Predicate — 카나리 배포

routes:
  - id: service-v1
    uri: lb://service-v1
    predicates:
      - Weight=service-group, 90    # 90% 트래픽

  - id: service-v2
    uri: lb://service-v2
    predicates:
      - Weight=service-group, 10    # 10% 트래픽 (카나리)

Between / Before / After Predicate — 시간 기반

predicates:
  - Between=2024-01-01T00:00:00+09:00[Asia/Seoul], 2024-12-31T23:59:59+09:00[Asia/Seoul]

커스텀 Predicate

@Component
public class CustomHeaderRoutePredicateFactory
        extends AbstractRoutePredicateFactory<CustomHeaderRoutePredicateFactory.Config> {

    public CustomHeaderRoutePredicateFactory() {
        super(Config.class);
    }

    @Override
    public Predicate<ServerWebExchange> apply(Config config) {
        return exchange -> {
            String headerValue = exchange.getRequest()
                    .getHeaders()
                    .getFirst(config.getHeaderName());
            return config.getExpectedValue().equals(headerValue);
        };
    }

    @Override
    public List<String> shortcutFieldOrder() {
        return List.of("headerName", "expectedValue");
    }

    @Data
    public static class Config {
        private String headerName;
        private String expectedValue;
    }
}
predicates:
  - CustomHeader=X-Internal-Token, secret123

4. Filter

필터는 요청과 응답을 가로채어 처리하는 핵심 컴포넌트입니다. 종류는 크게 두 가지입니다.

  • GatewayFilter: 특정 라우트에만 적용됩니다.
  • GlobalFilter: 모든 라우트에 적용됩니다.

Pre / Post Filter 동작 순서

클라이언트 요청
      │
      ▼
[Pre Filter 실행 (order 오름차순)]
  GlobalFilter (order=-1) pre
  GlobalFilter (order=0) pre
  GatewayFilter A (order=1) pre
  GatewayFilter B (order=2) pre
      │
      ▼
[업스트림 서비스 호출]
      │
      ▼
[Post Filter 실행 (order 내림차순)]
  GatewayFilter B (order=2) post
  GatewayFilter A (order=1) post
  GlobalFilter (order=0) post
  GlobalFilter (order=-1) post
      │
      ▼
클라이언트 응답

Pre/Post는 명시적 구분이 아닌, chain.filter(exchange) 호출 전후로 결정됩니다.


내장 필터 (Built-in Filters)

AddRequestHeader / AddResponseHeader

filters:
  - AddRequestHeader=X-Request-Id, {requestId}
  - AddResponseHeader=X-Response-Time, {responseTime}

RemoveRequestHeader / RemoveResponseHeader

filters:
  - RemoveRequestHeader=Cookie
  - RemoveResponseHeader=X-Internal-Header

RewritePath

filters:
  # /api/v1/orders/123 -> /orders/123
  - RewritePath=/api/v1/(?<segment>.*), /${segment}

StripPrefix

filters:
  - StripPrefix=2
  # /api/v1/orders -> /orders (앞 2개 제거)

PrefixPath

filters:
  - PrefixPath=/api
  # /orders -> /api/orders

RedirectTo

filters:
  - RedirectTo=301, https://new.example.com

SetPath

filters:
  - SetPath=/fixed-path/{segment}

RequestRateLimiter

filters:
  - name: RequestRateLimiter
    args:
      redis-rate-limiter.replenishRate: 10    # 초당 토큰 충전
      redis-rate-limiter.burstCapacity: 20    # 최대 버스트
      redis-rate-limiter.requestedTokens: 1   # 요청당 소모 토큰
      key-resolver: "#{@ipKeyResolver}"

CircuitBreaker

filters:
  - name: CircuitBreaker
    args:
      name: orderCircuitBreaker
      fallbackUri: forward:/fallback/order
      statusCodes:
        - 500
        - 503

Retry

filters:
  - name: Retry
    args:
      retries: 3
      statuses: BAD_GATEWAY, SERVICE_UNAVAILABLE
      methods: GET
      backoff:
        firstBackoff: 100ms
        maxBackoff: 500ms
        factor: 2
        basedOnPreviousValue: false

RequestSize

filters:
  - name: RequestSize
    args:
      maxSize: 5MB

SaveSession — Spring Session과 연동 시

filters:
  - SaveSession

커스텀 필터 구현 (AbstractGatewayFilterFactory)

@Component
@Slf4j
public class LoggingGatewayFilterFactory
        extends AbstractGatewayFilterFactory<LoggingGatewayFilterFactory.Config> {

    public LoggingGatewayFilterFactory() {
        super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();

            // Pre Filter: 요청 로깅
            String requestId = UUID.randomUUID().toString();
            long startTime = System.currentTimeMillis();

            log.info("[{}] {} {} - Pre Filter",
                requestId,
                request.getMethod(),
                request.getURI()
            );

            // 요청에 헤더 추가
            ServerHttpRequest modifiedRequest = request.mutate()
                .header("X-Request-Id", requestId)
                .build();

            ServerWebExchange modifiedExchange = exchange.mutate()
                .request(modifiedRequest)
                .build();

            // chain.filter() 이후는 Post Filter
            return chain.filter(modifiedExchange)
                .then(Mono.fromRunnable(() -> {
                    long duration = System.currentTimeMillis() - startTime;
                    ServerHttpResponse response = exchange.getResponse();

                    log.info("[{}] {} {} - Post Filter: status={}, duration={}ms",
                        requestId,
                        request.getMethod(),
                        request.getURI(),
                        response.getStatusCode(),
                        duration
                    );

                    // 응답 헤더 추가
                    response.getHeaders().add("X-Request-Id", requestId);
                    response.getHeaders().add("X-Response-Time", duration + "ms");
                }));
        };
    }

    @Override
    public List<String> shortcutFieldOrder() {
        return List.of("level");
    }

    @Data
    public static class Config {
        private String level = "INFO";  // 로그 레벨 설정
    }
}
filters:
  - name: Logging
    args:
      level: DEBUG

GlobalFilter 구현

GlobalFilter는 모든 라우트에 자동으로 적용됩니다. Ordered 인터페이스로 실행 순서를 지정합니다.

@Component
@Slf4j
public class AuthenticationGlobalFilter implements GlobalFilter, Ordered {

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_PREFIX = "Bearer ";

    // 인증 불필요 경로
    private static final List<String> WHITE_LIST = List.of(
        "/api/members/login",
        "/api/members/register",
        "/actuator/health"
    );

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();

        // 화이트리스트 경로는 통과
        if (isWhiteListed(path)) {
            return chain.filter(exchange);
        }

        String authHeader = exchange.getRequest()
            .getHeaders()
            .getFirst(AUTHORIZATION_HEADER);

        if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
            return unauthorizedResponse(exchange);
        }

        String token = authHeader.substring(BEARER_PREFIX.length());

        return validateToken(token)
            .flatMap(claims -> {
                // 검증된 정보를 헤더에 추가
                ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
                    .header("X-User-Id", claims.getSubject())
                    .header("X-User-Role", claims.get("role", String.class))
                    .build();

                return chain.filter(exchange.mutate().request(mutatedRequest).build());
            })
            .onErrorResume(e -> {
                log.warn("Token validation failed: {}", e.getMessage());
                return unauthorizedResponse(exchange);
            });
    }

    private boolean isWhiteListed(String path) {
        return WHITE_LIST.stream().anyMatch(path::startsWith);
    }

    private Mono<Claims> validateToken(String token) {
        return Mono.fromCallable(() -> {
            // JWT 검증 로직
            return Jwts.parserBuilder()
                .setSigningKey(signingKey)
                .build()
                .parseClaimsJws(token)
                .getBody();
        });
    }

    private Mono<Void> unauthorizedResponse(ServerWebExchange exchange) {
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        exchange.getResponse().getHeaders().add(
            HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE
        );
        String body = "{\"error\": \"Unauthorized\", \"message\": \"Invalid or missing token\"}";
        DataBuffer buffer = exchange.getResponse().bufferFactory()
            .wrap(body.getBytes(StandardCharsets.UTF_8));
        return exchange.getResponse().writeWith(Mono.just(buffer));
    }

    @Override
    public int getOrder() {
        return -100;  // 낮을수록 먼저 실행 (Pre Filter 기준)
    }
}

5. 로드밸런싱

Spring Cloud LoadBalancer 연동

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>

lb:// 프로토콜을 URI에 사용하면 Spring Cloud LoadBalancer가 서비스 레지스트리에서 인스턴스 목록을 가져와 라운드로빈(기본) 방식으로 분산합니다.

spring:
  cloud:
    gateway:
      routes:
        - id: order-route
          uri: lb://order-service     # lb:// → LoadBalancer 사용
          predicates:
            - Path=/api/orders/**
Gateway → LoadBalancer → [order-service:8081]
                       → [order-service:8082]
                       → [order-service:8083]

로드밸런싱 전략 커스터마이징

기본 전략은 RoundRobin입니다. RandomLoadBalancer로 변경할 수 있습니다.

@Configuration
@LoadBalancerClient(name = "order-service", configuration = OrderServiceLoadBalancerConfig.class)
public class LoadBalancerConfig {
}

public class OrderServiceLoadBalancerConfig {

    @Bean
    ReactorLoadBalancer<ServiceInstance> randomLoadBalancer(
            Environment environment,
            LoadBalancerClientFactory loadBalancerClientFactory) {
        String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
        return new RandomLoadBalancer(
            loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class),
            name
        );
    }
}

Eureka 서비스 디스커버리 연동

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
eureka:
  client:
    service-url:
      defaultZone: http://eureka-server:8761/eureka/
  instance:
    prefer-ip-address: true

spring:
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true              # Eureka에서 자동으로 라우트 생성
          lower-case-service-id: true

discovery.locator.enabled: true를 설정하면 Eureka에 등록된 모든 서비스에 대해 /서비스명/** 형태의 라우트가 자동 생성됩니다.

/order-service/** → lb://order-service
/product-service/** → lb://product-service

Consul 서비스 디스커버리 연동

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-consul-discovery</artifactId>
</dependency>
spring:
  cloud:
    consul:
      host: consul-server
      port: 8500
      discovery:
        service-name: api-gateway
        health-check-interval: 10s

6. 인증/인가

JWT 토큰 검증 필터 구현

완성된 JWT 검증 필터 예시입니다.

@Component
@Slf4j
public class JwtAuthenticationFilter implements GlobalFilter, Ordered {

    @Value("${jwt.secret}")
    private String jwtSecret;

    private Key signingKey;

    @PostConstruct
    public void init() {
        byte[] keyBytes = Base64.getDecoder().decode(jwtSecret);
        this.signingKey = Keys.hmacShaKeyFor(keyBytes);
    }

    // 인증 불필요 경로 목록
    private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
    private static final List<String> EXCLUDE_PATHS = List.of(
        "/api/auth/**",
        "/api/public/**",
        "/actuator/health",
        "/actuator/info"
    );

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getPath().value();

        if (isExcluded(path)) {
            return chain.filter(exchange);
        }

        return Mono.justOrEmpty(
                exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION)
            )
            .filter(header -> header.startsWith("Bearer "))
            .map(header -> header.substring(7))
            .flatMap(this::parseToken)
            .flatMap(claims -> {
                ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
                    .header("X-User-Id", claims.getSubject())
                    .header("X-User-Email", claims.get("email", String.class))
                    .header("X-User-Role", claims.get("role", String.class))
                    .build();
                log.debug("Authenticated user: {}", claims.getSubject());
                return chain.filter(exchange.mutate().request(mutatedRequest).build());
            })
            .switchIfEmpty(Mono.defer(() -> sendError(exchange, HttpStatus.UNAUTHORIZED,
                "Missing or invalid Authorization header")))
            .onErrorResume(ExpiredJwtException.class, e ->
                sendError(exchange, HttpStatus.UNAUTHORIZED, "Token expired"))
            .onErrorResume(JwtException.class, e ->
                sendError(exchange, HttpStatus.UNAUTHORIZED, "Invalid token"));
    }

    private Mono<Claims> parseToken(String token) {
        return Mono.fromCallable(() ->
            Jwts.parserBuilder()
                .setSigningKey(signingKey)
                .build()
                .parseClaimsJws(token)
                .getBody()
        ).subscribeOn(Schedulers.boundedElastic());
        // JWT 파싱은 CPU-bound → boundedElastic 스케줄러 사용
    }

    private boolean isExcluded(String path) {
        return EXCLUDE_PATHS.stream()
            .anyMatch(pattern -> PATH_MATCHER.match(pattern, path));
    }

    private Mono<Void> sendError(ServerWebExchange exchange, HttpStatus status, String message) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(status);
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);

        String body = String.format(
            "{\"status\":%d,\"error\":\"%s\",\"message\":\"%s\"}",
            status.value(), status.getReasonPhrase(), message
        );

        DataBuffer buffer = response.bufferFactory()
            .wrap(body.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }

    @Override
    public int getOrder() {
        return -200;
    }
}

Spring Security + Gateway 연동

Spring Cloud Gateway는 WebFlux 기반이므로 Reactive Spring Security를 사용합니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        return http
            .csrf(ServerHttpSecurity.CsrfSpec::disable)
            .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
            .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
            .authorizeExchange(exchanges -> exchanges
                .pathMatchers("/api/auth/**", "/actuator/health").permitAll()
                .pathMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                .pathMatchers("/api/admin/**").hasRole("ADMIN")
                .anyExchange().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtDecoder(jwtDecoder()))
            )
            .build();
    }

    @Bean
    public ReactiveJwtDecoder jwtDecoder() {
        return NimbusReactiveJwtDecoder
            .withSecretKey(secretKey())
            .build();
    }

    private SecretKey secretKey() {
        byte[] keyBytes = Base64.getDecoder().decode(jwtSecret);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

OAuth2 Resource Server

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com
          # jwk-set-uri: https://auth.example.com/.well-known/jwks.json
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
    return http
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt
                .jwtAuthenticationConverter(jwtAuthenticationConverter())
            )
        )
        .build();
}

@Bean
public Converter<Jwt, Mono<AbstractAuthenticationToken>> jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
    authoritiesConverter.setAuthorityPrefix("ROLE_");
    authoritiesConverter.setAuthoritiesClaimName("roles");

    ReactiveJwtAuthenticationConverterAdapter adapter =
        new ReactiveJwtAuthenticationConverterAdapter(
            new JwtAuthenticationConverter() 
        );
    return adapter;
}

7. Rate Limiting

RequestRateLimiter + Redis

Token Bucket 알고리즘 기반으로 Redis를 통해 분산 Rate Limiting을 구현합니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
spring:
  data:
    redis:
      host: redis-server
      port: 6379

  cloud:
    gateway:
      routes:
        - id: order-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개
                redis-rate-limiter.requestedTokens: 1   # 요청당 1개 소모
                key-resolver: "#{@userKeyResolver}"     # KeyResolver 빈 참조

Token Bucket 동작 원리:

시간  → 0s  1s  2s  3s  4s  5s
토큰 충전 → +10  +10  +10  +10  +10  +10   (replenishRate=10)
버스트 용량: 20

버스트: 첫 2초 동안 20개 요청 즉시 허용 (용량 소진)
정상: 이후 초당 10개 요청만 허용
초과: 429 Too Many Requests 반환

KeyResolver (IP별, 사용자별)

@Configuration
public class RateLimiterConfig {

    // IP 기반 Rate Limiting
    @Bean
    public KeyResolver ipKeyResolver() {
        return exchange -> Mono.justOrEmpty(
            exchange.getRequest().getRemoteAddress()
        )
        .map(addr -> addr.getAddress().getHostAddress())
        .defaultIfEmpty("unknown");
    }

    // 사용자 ID 기반 Rate Limiting (JWT에서 추출)
    @Bean
    public KeyResolver userKeyResolver() {
        return exchange -> Mono.justOrEmpty(
            exchange.getRequest().getHeaders().getFirst("X-User-Id")
        )
        .defaultIfEmpty("anonymous");
    }

    // API 키 기반 Rate Limiting
    @Bean
    public KeyResolver apiKeyResolver() {
        return exchange -> Mono.justOrEmpty(
            exchange.getRequest().getHeaders().getFirst("X-Api-Key")
        )
        .switchIfEmpty(
            Mono.justOrEmpty(
                exchange.getRequest().getQueryParams().getFirst("api_key")
            )
        )
        .defaultIfEmpty("anonymous");
    }
}

경로별 다른 Rate Limit 적용:

routes:
  # 일반 API: 초당 10 요청
  - id: api-standard
    uri: lb://api-service
    predicates:
      - Path=/api/standard/**
    filters:
      - name: RequestRateLimiter
        args:
          redis-rate-limiter.replenishRate: 10
          redis-rate-limiter.burstCapacity: 20
          key-resolver: "#{@userKeyResolver}"

  # 프리미엄 API: 초당 100 요청
  - id: api-premium
    uri: lb://api-service
    predicates:
      - Path=/api/premium/**
      - Header=X-Tier, premium
    filters:
      - name: RequestRateLimiter
        args:
          redis-rate-limiter.replenishRate: 100
          redis-rate-limiter.burstCapacity: 200
          key-resolver: "#{@userKeyResolver}"

커스텀 RateLimiter 구현:

@Component
public class CustomRedisRateLimiter extends AbstractRateLimiter<CustomRedisRateLimiter.Config> {

    @Override
    public Mono<Response> isAllowed(String routeId, String id) {
        Config config = getConfig().getOrDefault(routeId, new Config());

        // 커스텀 토큰 버킷 로직
        String key = "rate_limiter:" + routeId + ":" + id;

        return redisTemplate.execute(rateLimiterScript, keys, args)
            .map(results -> {
                boolean allowed = ((Long) results.get(0)) == 1L;
                long remainingTokens = (Long) results.get(1);

                Map<String, String> headers = new HashMap<>();
                headers.put("X-RateLimit-Remaining", String.valueOf(remainingTokens));
                headers.put("X-RateLimit-Limit", String.valueOf(config.getReplenishRate()));

                return new Response(allowed, headers);
            });
    }

    @Data
    public static class Config {
        private int replenishRate = 10;
        private int burstCapacity = 20;
    }
}

8. Circuit Breaker

Resilience4j 연동

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
</dependency>
spring:
  cloud:
    gateway:
      routes:
        - id: order-route
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
          filters:
            - name: CircuitBreaker
              args:
                name: orderCircuitBreaker
                fallbackUri: forward:/fallback/order
                statusCodes:             # 이 상태코드를 실패로 간주
                  - 500
                  - 502
                  - 503
                  - 504

resilience4j:
  circuitbreaker:
    instances:
      orderCircuitBreaker:
        sliding-window-size: 10           # 최근 10번의 호출로 판단
        failure-rate-threshold: 50        # 50% 이상 실패 시 OPEN
        wait-duration-in-open-state: 10s  # OPEN 후 10초 대기
        permitted-number-of-calls-in-half-open-state: 3  # HALF-OPEN에서 3회 테스트
        minimum-number-of-calls: 5        # 최소 5회 호출 후 판단

  timelimiter:
    instances:
      orderCircuitBreaker:
        timeout-duration: 3s             # 3초 초과 시 실패 처리

Circuit Breaker 상태 머신

        실패율 >= threshold
CLOSED ─────────────────────► OPEN
  ▲                              │
  │ 성공                         │ wait-duration 경과
  │                              ▼
  └──────────────────── HALF-OPEN
        성공 >= permitted calls
  • CLOSED: 정상 상태. 모든 요청 허용.
  • OPEN: 장애 상태. 모든 요청 즉시 실패 처리 (Fallback 실행).
  • HALF-OPEN: 회복 시도. 제한된 수의 요청만 허용. 성공 시 CLOSED, 실패 시 OPEN으로 전환.

폴백 라우트 설정

@RestController
@Slf4j
public class FallbackController {

    @GetMapping("/fallback/order")
    public Mono<ResponseEntity<Map<String, Object>>> orderFallback(ServerWebExchange exchange) {
        log.warn("Order service circuit breaker triggered");

        Map<String, Object> response = Map.of(
            "status", "SERVICE_UNAVAILABLE",
            "message", "주문 서비스가 일시적으로 사용 불가합니다. 잠시 후 다시 시도해주세요.",
            "timestamp", Instant.now().toString()
        );

        return Mono.just(ResponseEntity
            .status(HttpStatus.SERVICE_UNAVAILABLE)
            .contentType(MediaType.APPLICATION_JSON)
            .body(response)
        );
    }

    @GetMapping("/fallback/product")
    public Mono<ResponseEntity<Map<String, Object>>> productFallback() {
        Map<String, Object> response = Map.of(
            "status", "SERVICE_UNAVAILABLE",
            "message", "상품 서비스를 현재 이용할 수 없습니다.",
            "data", Collections.emptyList()  // 빈 목록 반환 (Graceful Degradation)
        );

        return Mono.just(ResponseEntity
            .status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(response)
        );
    }
}

Retry + Circuit Breaker 조합

filters:
  # Retry 먼저 시도, 모두 실패하면 Circuit Breaker 동작
  - name: Retry
    args:
      retries: 2
      statuses: BAD_GATEWAY, SERVICE_UNAVAILABLE
      methods: GET
      backoff:
        firstBackoff: 50ms
        maxBackoff: 200ms
        factor: 2

  - name: CircuitBreaker
    args:
      name: orderCircuitBreaker
      fallbackUri: forward:/fallback/order

순서 주의: Retry가 CircuitBreaker보다 먼저 적용되도록 설정합니다. Circuit Breaker가 OPEN 상태일 때는 Retry 없이 즉시 Fallback으로 전환됩니다.


9. 모니터링

Actuator Endpoints

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, gateway, circuitbreakers, ratelimiters
  endpoint:
    health:
      show-details: always
    gateway:
      enabled: true

주요 Gateway Actuator Endpoints:

Endpoint 설명
GET /actuator/gateway/routes 등록된 모든 라우트 목록
GET /actuator/gateway/routes/{id} 특정 라우트 상세 정보
POST /actuator/gateway/routes/{id} 라우트 동적 추가
DELETE /actuator/gateway/routes/{id} 라우트 동적 삭제
POST /actuator/gateway/refresh 라우트 캐시 갱신
GET /actuator/gateway/globalfilters 전역 필터 목록
GET /actuator/gateway/routefilters 라우트 필터 팩토리 목록

Micrometer 메트릭

Spring Cloud Gateway는 Micrometer를 통해 다음 메트릭을 자동으로 노출합니다.

# 라우트별 요청 수
spring.cloud.gateway.requests_total{routeId="order-route", outcome="SUCCESS"}

# 라우트별 응답 시간
spring.cloud.gateway.requests_seconds{routeId="order-route", outcome="SUCCESS"}

# Circuit Breaker 상태
resilience4j.circuitbreaker.state{name="orderCircuitBreaker", state="CLOSED"}

# Rate Limiter
spring.cloud.gateway.requests_total{routeId="order-route", outcome="FORWARD_ERROR"}

Prometheus + Grafana 연동:

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
management:
  metrics:
    export:
      prometheus:
        enabled: true
  endpoints:
    web:
      exposure:
        include: prometheus

요청/응답 로깅 필터

@Component
@Slf4j
public class RequestLoggingGlobalFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String traceId = generateTraceId();

        // MDC에 추적 ID 설정 (WebFlux는 Context 사용)
        return chain.filter(exchange.mutate()
                .request(request.mutate()
                    .header("X-Trace-Id", traceId)
                    .build())
                .build())
            .contextWrite(Context.of("traceId", traceId))
            .doOnSubscribe(s ->
                log.info(">>> {} {} [traceId={}] headers={}",
                    request.getMethod(),
                    request.getURI(),
                    traceId,
                    sanitizeHeaders(request.getHeaders())
                )
            )
            .doOnSuccess(v -> {
                HttpStatus status = exchange.getResponse().getStatusCode();
                log.info("<<< {} {} [traceId={}] status={}",
                    request.getMethod(),
                    request.getURI(),
                    traceId,
                    status
                );
            })
            .doOnError(e ->
                log.error("!!! {} {} [traceId={}] error={}",
                    request.getMethod(),
                    request.getURI(),
                    traceId,
                    e.getMessage()
                )
            );
    }

    private String generateTraceId() {
        return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
    }

    private Map<String, String> sanitizeHeaders(HttpHeaders headers) {
        Map<String, String> sanitized = new LinkedHashMap<>();
        headers.forEach((key, values) -> {
            if (!key.equalsIgnoreCase(HttpHeaders.AUTHORIZATION)) {
                sanitized.put(key, String.join(", ", values));
            } else {
                sanitized.put(key, "[REDACTED]");
            }
        });
        return sanitized;
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

Zipkin 분산 추적 연동:

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
    <groupId>io.zipkin.reporter2</groupId>
    <artifactId>zipkin-reporter-brave</artifactId>
</dependency>
management:
  tracing:
    sampling:
      probability: 1.0    # 100% 샘플링 (프로덕션은 0.1~0.5 권장)
  zipkin:
    tracing:
      endpoint: http://zipkin:9411/api/v2/spans

10. 극한 시나리오

Gateway 자체 장애 시 — 다중 인스턴스, Health Check

API Gateway가 단일 인스턴스라면 SPOF(Single Point of Failure)가 됩니다.

                      [Load Balancer (L4/L7)]
                      /          |           \
              [Gateway-1]  [Gateway-2]  [Gateway-3]
                  ↑              ↑              ↑
              [Health Check: /actuator/health]
              실패 시 자동으로 제외

필요한 설정:

# 상태 확인 상세 설정
management:
  endpoint:
    health:
      show-details: always
      probes:
        enabled: true          # liveness, readiness probe 활성화
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true
    redis:
      enabled: true            # Redis 연결 상태 포함
    circuitbreakers:
      enabled: true

# Kubernetes Liveness/Readiness Probe 예시
# livenessProbe:
#   httpGet:
#     path: /actuator/health/liveness
#     port: 8080
# readinessProbe:
#   httpGet:
#     path: /actuator/health/readiness
#     port: 8080

Graceful Shutdown 설정:

server:
  shutdown: graceful         # 진행 중인 요청 처리 후 종료

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s   # 최대 30초 대기

하위 서비스 지연 전파 — Timeout + Circuit Breaker

하위 서비스가 느려지면 Gateway의 커넥션 풀이 고갈되어 전체 시스템이 마비될 수 있습니다.

상황: product-service 응답 시간 30초 (정상: 100ms)

[클라이언트 요청] → [Gateway] → [product-service: 30초 대기]
                                        │
                   연결이 모두 소진되면  │
                   다른 서비스도 영향    │
                                        ▼
                   [order-service 요청도 실패] ← 커넥션 풀 고갈

방어 설정:

spring:
  cloud:
    gateway:
      httpclient:
        connect-timeout: 1000    # 연결 타임아웃 1초
        response-timeout: 5s     # 응답 타임아웃 5초

      routes:
        - id: product-route
          uri: lb://product-service
          predicates:
            - Path=/api/products/**
          filters:
            # 개별 라우트 타임아웃 (전역 설정 override)
            - name: RequestHeaderSize
            - name: CircuitBreaker
              args:
                name: productCB
                fallbackUri: forward:/fallback/product
            - name: Retry
              args:
                retries: 2
                statuses: GATEWAY_TIMEOUT, SERVICE_UNAVAILABLE

resilience4j:
  timelimiter:
    instances:
      productCB:
        timeout-duration: 3s    # Circuit Breaker 타임아웃

Netty 커넥션 풀 설정:

spring:
  cloud:
    gateway:
      httpclient:
        pool:
          type: elastic           # 동적 커넥션 풀
          max-connections: 1000   # 최대 커넥션 수
          acquire-timeout: 2000   # 커넥션 획득 대기 시간
          max-idle-time: 30s      # 유휴 커넥션 유지 시간
          max-life-time: 60s      # 커넥션 최대 수명

메모리 누수 — WebFlux 스트림 미처리

WebFlux에서 Mono/Flux 스트림을 구독하지 않으면 메모리 누수가 발생합니다.

잘못된 예시:

// 절대 하면 안 됨
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    // Mono를 반환하지 않고 사이드 이펙트만 실행 — subscribe()로 실행 후 잊어버림
    someAsyncOperation().subscribe();  // 메모리 누수 가능성
    return chain.filter(exchange);
}

올바른 예시:

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    return someAsyncOperation()
        .then(chain.filter(exchange))   // 체인으로 연결
        .doOnError(e -> log.error("Filter error", e));
}

요청 바디 읽기 시 주의사항:

// 요청 바디는 한 번만 읽을 수 있음 → 캐싱 필요
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    return DataBufferUtils.join(exchange.getRequest().getBody())
        .flatMap(dataBuffer -> {
            byte[] bytes = new byte[dataBuffer.readableByteCount()];
            dataBuffer.read(bytes);
            DataBufferUtils.release(dataBuffer);  // 반드시 해제!

            // 새로운 요청으로 교체
            ServerHttpRequest mutatedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) {
                @Override
                public Flux<DataBuffer> getBody() {
                    return Flux.just(exchange.getResponse().bufferFactory().wrap(bytes));
                }
            };

            return chain.filter(exchange.mutate().request(mutatedRequest).build());
        });
}

CORS 이슈

Gateway와 각 서비스 모두 CORS를 설정하면 헤더가 중복되어 브라우저가 오류를 발생시킵니다.

증상:

Access-Control-Allow-Origin: *, *    ← 중복 값 → 브라우저 거부

해결책:

# Gateway에서 CORS 통합 관리
spring:
  cloud:
    gateway:
      globalcors:
        cors-configurations:
          '[/**]':
            allowedOriginPatterns:
              - "https://*.example.com"
              - "http://localhost:3000"
            allowedMethods: "*"
            allowedHeaders: "*"
            allowCredentials: true

      # 하위 서비스에서 중복된 CORS 헤더 제거
      default-filters:
        - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin

각 마이크로서비스에서는 CORS 설정을 제거하고, Gateway에서만 관리합니다.


11. 실무 Best Practice + 구성 예제

전체 구성 예제

server:
  port: 8080
  shutdown: graceful

spring:
  application:
    name: api-gateway
  lifecycle:
    timeout-per-shutdown-phase: 30s

  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}

  cloud:
    gateway:
      # 전역 타임아웃
      httpclient:
        connect-timeout: 1000
        response-timeout: 10s
        pool:
          type: elastic
          max-connections: 2000
          acquire-timeout: 3000

      # 전역 필터
      default-filters:
        - AddResponseHeader=X-Gateway-Version, 1.0
        - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin

      # CORS
      globalcors:
        cors-configurations:
          '[/**]':
            allowedOriginPatterns:
              - "https://*.example.com"
            allowedMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
            allowedHeaders: "*"
            allowCredentials: true
            maxAge: 3600

      routes:
        # 인증 서비스 (인증 불필요)
        - id: auth-route
          uri: lb://auth-service
          predicates:
            - Path=/api/auth/**
          filters:
            - StripPrefix=1
          order: 1

        # 주문 서비스
        - id: order-route
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
          filters:
            - StripPrefix=1
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 20
                redis-rate-limiter.burstCapacity: 40
                key-resolver: "#{@userKeyResolver}"
            - name: CircuitBreaker
              args:
                name: orderCB
                fallbackUri: forward:/fallback/order
            - name: Retry
              args:
                retries: 2
                statuses: BAD_GATEWAY, SERVICE_UNAVAILABLE
                methods: GET
          order: 10

        # 상품 서비스 (공개 읽기)
        - id: product-read-route
          uri: lb://product-service
          predicates:
            - Path=/api/products/**
            - Method=GET
          filters:
            - StripPrefix=1
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 50
                redis-rate-limiter.burstCapacity: 100
                key-resolver: "#{@ipKeyResolver}"
            - AddResponseHeader=Cache-Control, public, max-age=60
          order: 10

        # Fallback 라우트
        - id: fallback-route
          uri: no://op
          predicates:
            - Path=/fallback/**
          order: 999

resilience4j:
  circuitbreaker:
    instances:
      orderCB:
        sliding-window-size: 10
        failure-rate-threshold: 50
        wait-duration-in-open-state: 15s
        permitted-number-of-calls-in-half-open-state: 3
        minimum-number-of-calls: 5
      productCB:
        sliding-window-size: 20
        failure-rate-threshold: 40
        wait-duration-in-open-state: 10s
  timelimiter:
    instances:
      orderCB:
        timeout-duration: 5s
      productCB:
        timeout-duration: 3s

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus, gateway
  endpoint:
    health:
      show-details: when-authorized
      probes:
        enabled: true
  metrics:
    tags:
      application: ${spring.application.name}
  tracing:
    sampling:
      probability: 0.1

logging:
  level:
    org.springframework.cloud.gateway: INFO
    reactor.netty: WARN

Best Practice 체크리스트

1. 라우트 설계

  • 라우트 ID는 명확하고 유일하게 설정합니다. (order-route, product-read-route)
  • order 값으로 라우트 우선순위를 명시합니다. 특수 경로(공개 API)는 낮은 order로 먼저 매칭합니다.
  • StripPrefix를 사용하여 Gateway 경로 접두사(/api)를 서비스에 전달하지 않습니다.

2. 필터 설계

  • GlobalFilter에서 모든 인증을 처리하고 검증된 정보를 헤더로 전달합니다.
  • 화이트리스트 경로는 GlobalFilter에서 명시적으로 처리합니다.
  • 필터에서 블로킹 코드(Thread.sleep(), JDBC) 사용을 절대 금지합니다.
  • 요청 바디 읽기 후 반드시 DataBufferUtils.release() 호출합니다.

3. 보안

  • 인증 헤더(Authorization)는 로깅에서 반드시 마스킹합니다.
  • 내부 서비스 정보가 담긴 헤더(X-Internal-*)는 외부 요청에서 제거합니다.
  • HTTPS를 Gateway 앞단(L4/L7 LB)에서 종료하거나 Gateway에서 직접 TLS를 처리합니다.

4. 성능

  • Redis Rate Limiter는 Redis 장애 시 요청을 허용(fail-open) 혹은 거부(fail-closed)할 정책을 결정합니다. 기본은 fail-open입니다.
  • Netty 커넥션 풀은 업스트림 서비스 수와 예상 동시 요청 수를 기반으로 산정합니다.
  • 프로덕션 환경에서 분산 추적 샘플링 비율은 5~10%로 설정합니다.

5. 운영

  • /actuator/gateway/routes API를 통해 동적 라우트 갱신이 가능합니다. 재배포 없이 라우트를 변경할 수 있습니다.
  • Circuit Breaker 상태를 Grafana 대시보드로 실시간 모니터링합니다.
  • 카나리 배포 시 Weight Predicate로 트래픽을 점진적으로 전환합니다.
// 운영 중 라우트 동적 추가 (RouteDefinitionWriter 활용)
@RestController
@RequiredArgsConstructor
public class GatewayAdminController {

    private final RouteDefinitionWriter routeDefinitionWriter;
    private final ApplicationEventPublisher publisher;

    @PostMapping("/admin/routes")
    public Mono<Void> addRoute(@RequestBody RouteDefinition routeDefinition) {
        return routeDefinitionWriter.save(Mono.just(routeDefinition))
            .doOnSuccess(v -> publisher.publishEvent(new RefreshRoutesEvent(this)));
    }

    @DeleteMapping("/admin/routes/{id}")
    public Mono<Void> deleteRoute(@PathVariable String id) {
        return routeDefinitionWriter.delete(Mono.just(id))
            .doOnSuccess(v -> publisher.publishEvent(new RefreshRoutesEvent(this)));
    }
}

Spring Cloud Gateway는 MSA 환경에서 인증, 트래픽 제어, 장애 격리, 라우팅을 한 곳에서 처리하는 강력한 도구입니다. Netty 비동기 모델 위에서 동작하므로 높은 처리량을 유지하면서도 WebFlux, Spring Security, Resilience4j 등 Spring 생태계와 자연스럽게 통합됩니다. 핵심은 GlobalFilter로 공통 관심사를 중앙화하고, Circuit Breaker와 Rate Limiter로 시스템을 보호하며, 다중 인스턴스 배포와 Graceful Shutdown으로 가용성을 확보하는 것입니다.

카테고리:

업데이트: