TestForge Blog
← 전체 포스트

Spring Cloud Gateway 아키텍처 설계 — 실전 구성 완벽 가이드

Spring Cloud Gateway로 마이크로서비스 API Gateway를 구축하는 방법. 라우팅, 필터, JWT 인증, Rate Limiting, 서킷 브레이커, 로드밸런싱까지 실전 코드 중심으로 정리합니다.

TestForge Team ·

버전 정보

이 포스트에서 사용하는 버전 기준입니다.

라이브러리버전비고
Java21LTS
Spring Boot3.3.x
Spring Cloud2023.0.3 (Leyton)Boot 3.3.x 호환
Spring Cloud Gateway4.1.xCloud 2023.0.x 포함
Resilience4j2.2.xCloud 2023.0.x 포함
Reactor Netty1.1.xWebFlux 기본 서버

Spring Boot ↔ Spring Cloud 버전 호환표

Spring BootSpring Cloud코드명
3.3.x2023.0.xLeyton
3.2.x2023.0.xLeyton
3.1.x2022.0.xKilburn
3.0.x2022.0.xKilburn
2.7.x2021.0.xJubilee

버전 호환 여부는 Spring Cloud 공식 릴리즈 페이지 에서 항상 확인하세요.
Boot 버전과 Cloud 버전을 맞추지 않으면 빈 충돌, 자동 설정 오류가 발생합니다.


Spring Cloud Gateway란

Netflix Zuul의 후계자. Spring WebFlux(Reactor) 기반 논블로킹 비동기 API Gateway입니다.

기존 Zuul 1.x   → 서블릿 기반, 블로킹 I/O
Spring Cloud Gateway → WebFlux 기반, 논블로킹 I/O (처리량 2~3배)

핵심 개념 3가지

Route  : 요청을 어디로 보낼지 (id, uri, predicate, filter)
Predicate : 요청 조건 판별 (Path, Header, Method, Query...)
Filter : 요청/응답 가공 (인증, 로깅, Rate Limit, 변환...)

흐름:

Client Request

Gateway Handler Mapping (Predicate 매칭)

Gateway Web Handler

Pre Filters → Proxied Service → Post Filters

Client Response

1. 프로젝트 설정

<!-- pom.xml -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.5</version>  <!-- Spring Boot 버전 명시 -->
</parent>

<properties>
    <java.version>21</java.version>
    <spring-cloud.version>2023.0.3</spring-cloud.version>  <!-- Cloud 버전 명시 -->
</properties>

<dependencies>
    <!-- Spring Cloud Gateway (WebFlux 기반, spring-boot-starter-web 제거 필수) -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway</artifactId>
        <!-- 버전은 BOM에서 관리: 4.1.x -->
    </dependency>

    <!-- Eureka Client — 서비스 디스커버리 (lb://SERVICE-NAME 사용 시 필수) -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        <!-- 버전은 BOM에서 관리: 4.1.x -->
    </dependency>

    <!-- Resilience4j 서킷 브레이커 (Reactor 기반) -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
        <!-- 버전은 BOM에서 관리: 3.1.x -->
    </dependency>

    <!-- Redis Rate Limiter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
        <!-- 버전은 Boot BOM에서 관리: 3.3.x → Lettuce 6.3.x -->
    </dependency>

    <!-- JWT 파싱 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.6</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.6</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.6</version>
        <scope>runtime</scope>
    </dependency>

    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

<!-- Spring Cloud BOM — 버전을 여기서 일괄 관리 -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>  <!-- 2023.0.3 -->
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

주의: Spring Cloud Gateway는 Spring MVC(spring-boot-starter-web)와 함께 사용할 수 없습니다. WebFlux 기반이므로 spring-boot-starter-web 의존성을 제거하세요.


2. 라우팅 설정

YAML 방식 (권장)

# application.yml
spring:
  cloud:
    gateway:
      routes:
        # 사용자 서비스
        - id: user-service
          uri: lb://USER-SERVICE          # Eureka 로드밸런싱
          predicates:
            - Path=/api/v1/users/**
          filters:
            - StripPrefix=2               # /api/v1 제거 후 전달
            - name: CircuitBreaker
              args:
                name: userServiceCB
                fallbackUri: forward:/fallback/user

        # 주문 서비스
        - id: order-service
          uri: lb://ORDER-SERVICE
          predicates:
            - Path=/api/v1/orders/**
            - Method=GET,POST
          filters:
            - StripPrefix=2
            - AddRequestHeader=X-Gateway-Source, spring-cloud-gateway

        # 결제 서비스 (HTTPS 강제 + 헤더 검증)
        - id: payment-service
          uri: lb://PAYMENT-SERVICE
          predicates:
            - Path=/api/v1/pay/**
            - Header=X-Request-ID, .+     # X-Request-ID 헤더 필수
          filters:
            - StripPrefix=2
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10
                redis-rate-limiter.burstCapacity: 20
                key-resolver: "#{@userKeyResolver}"

        # 내부 전용 라우트 (외부 차단)
        - id: internal-block
          uri: no://op
          predicates:
            - Path=/api/internal/**
          filters:
            - name: SetStatus
              args:
                status: 403

Java Config 방식

@Configuration
public class GatewayConfig {

    @Bean
    public RouteLocator routeLocator(RouteLocatorBuilder builder) {
        return builder.routes()
            .route("user-service", r -> r
                .path("/api/v1/users/**")
                .filters(f -> f
                    .stripPrefix(2)
                    .addRequestHeader("X-Gateway-Source", "spring-cloud-gateway")
                    .circuitBreaker(c -> c
                        .setName("userServiceCB")
                        .setFallbackUri("forward:/fallback/user")
                    )
                )
                .uri("lb://USER-SERVICE")
            )
            .build();
    }
}

3. JWT 인증 필터

Gateway에서 JWT를 검증해 백엔드 서비스의 인증 로직 중복을 제거합니다.

@Component
@RequiredArgsConstructor
public class JwtAuthFilter implements GlobalFilter, Ordered {

    private final JwtTokenProvider jwtTokenProvider;

    // 인증 불필요 경로
    private static final List<String> WHITELIST = List.of(
        "/api/v1/auth/login",
        "/api/v1/auth/signup",
        "/api/v1/auth/refresh",
        "/actuator/health"
    );

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

        // 화이트리스트 통과
        if (WHITELIST.stream().anyMatch(path::startsWith)) {
            return chain.filter(exchange);
        }

        String authHeader = exchange.getRequest()
            .getHeaders()
            .getFirst(HttpHeaders.AUTHORIZATION);

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            return unauthorized(exchange, "Authorization header missing");
        }

        String token = authHeader.substring(7);

        try {
            Claims claims = jwtTokenProvider.validateAndGetClaims(token);

            // 검증된 사용자 정보를 헤더로 전달
            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());

        } catch (ExpiredJwtException e) {
            return unauthorized(exchange, "Token expired");
        } catch (JwtException e) {
            return unauthorized(exchange, "Invalid token");
        }
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange, String message) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        String body = """
            {"error": "UNAUTHORIZED", "message": "%s"}
            """.formatted(message);
        DataBuffer buffer = response.bufferFactory()
            .wrap(body.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }

    @Override
    public int getOrder() {
        return -100;  // 가장 먼저 실행
    }
}
@Component
public class JwtTokenProvider {

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

    public Claims validateAndGetClaims(String token) {
        return Jwts.parserBuilder()
            .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes()))
            .build()
            .parseClaimsJws(token)
            .getBody();
    }
}

4. Rate Limiting (Redis 기반)

spring:
  data:
    redis:
      host: redis
      port: 6379
  cloud:
    gateway:
      routes:
        - id: api-route
          uri: lb://API-SERVICE
          predicates:
            - Path=/api/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 100    # 초당 100 요청 보충
                redis-rate-limiter.burstCapacity: 200    # 최대 버스트 200
                redis-rate-limiter.requestedTokens: 1    # 요청당 소비 토큰
                key-resolver: "#{@userKeyResolver}"      # 사용자별 제한
@Configuration
public class RateLimiterConfig {

    // 사용자 ID 기반 제한 (인증된 요청)
    @Bean
    public KeyResolver userKeyResolver() {
        return exchange -> {
            String userId = exchange.getRequest()
                .getHeaders()
                .getFirst("X-User-Id");
            return Mono.just(userId != null ? userId : "anonymous");
        };
    }

    // IP 기반 제한 (미인증 요청)
    @Bean
    public KeyResolver ipKeyResolver() {
        return exchange -> Mono.just(
            Objects.requireNonNull(
                exchange.getRequest().getRemoteAddress()
            ).getAddress().getHostAddress()
        );
    }
}

Rate Limit 초과 시 응답:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Remaining: 0
X-RateLimit-Replenish-Rate: 100
X-RateLimit-Burst-Capacity: 200

5. 서킷 브레이커 (Resilience4j)

# application.yml
resilience4j:
  circuitbreaker:
    instances:
      userServiceCB:
        slidingWindowSize: 10              # 최근 10개 요청 기준
        failureRateThreshold: 50           # 50% 실패 시 OPEN
        waitDurationInOpenState: 10s       # 10초 후 HALF_OPEN
        permittedNumberOfCallsInHalfOpenState: 3
        minimumNumberOfCalls: 5
      orderServiceCB:
        slidingWindowSize: 20
        failureRateThreshold: 30
        waitDurationInOpenState: 30s
  timelimiter:
    instances:
      userServiceCB:
        timeoutDuration: 3s               # 3초 타임아웃
      orderServiceCB:
        timeoutDuration: 5s
// Fallback 컨트롤러
@RestController
@RequestMapping("/fallback")
public class FallbackController {

    @GetMapping("/user")
    public ResponseEntity<Map<String, String>> userFallback() {
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(Map.of(
                "error", "USER_SERVICE_UNAVAILABLE",
                "message", "사용자 서비스가 일시적으로 사용 불가합니다. 잠시 후 다시 시도해주세요."
            ));
    }

    @GetMapping("/order")
    public ResponseEntity<Map<String, String>> orderFallback() {
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(Map.of(
                "error", "ORDER_SERVICE_UNAVAILABLE",
                "message", "주문 서비스가 일시적으로 사용 불가합니다."
            ));
    }
}

6. 요청/응답 로깅 필터

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

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String requestId = UUID.randomUUID().toString().substring(0, 8);
        long startTime = System.currentTimeMillis();

        // 요청 로그
        log.info("[{}] {} {} | IP: {} | User: {}",
            requestId,
            request.getMethod(),
            request.getPath(),
            getClientIp(request),
            request.getHeaders().getFirst("X-User-Id")
        );

        // 응답 로그 (완료 후)
        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            long duration = System.currentTimeMillis() - startTime;
            log.info("[{}] {} {} → {} ({}ms)",
                requestId,
                request.getMethod(),
                request.getPath(),
                exchange.getResponse().getStatusCode(),
                duration
            );
        }));
    }

    private String getClientIp(ServerHttpRequest request) {
        String xForwardedFor = request.getHeaders().getFirst("X-Forwarded-For");
        if (xForwardedFor != null) return xForwardedFor.split(",")[0].trim();
        return Objects.requireNonNull(request.getRemoteAddress())
            .getAddress().getHostAddress();
    }

    @Override
    public int getOrder() {
        return -200;  // JWT 필터보다 먼저 실행
    }
}

7. CORS 설정

spring:
  cloud:
    gateway:
      globalcors:
        cors-configurations:
          '[/**]':
            allowedOriginPatterns:
              - "https://testforge.kr"
              - "https://*.testforge.kr"
              - "http://localhost:3000"    # 개발 환경
            allowedMethods:
              - GET
              - POST
              - PUT
              - DELETE
              - OPTIONS
            allowedHeaders:
              - Authorization
              - Content-Type
              - X-Request-ID
            exposedHeaders:
              - X-RateLimit-Remaining
            allowCredentials: true
            maxAge: 3600

8. 헬스체크 및 모니터링

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,gateway
  endpoint:
    health:
      show-details: always
    gateway:
      enabled: true
# 등록된 라우트 목록 조회
GET /actuator/gateway/routes

# 특정 라우트 상세
GET /actuator/gateway/routes/user-service

# 라우트 캐시 초기화 (동적 라우팅 적용)
POST /actuator/gateway/refresh

9. 전체 아키텍처 구성도

                    ┌─────────────────────────────────────┐
                    │        Spring Cloud Gateway          │
                    │                                      │
  Client ──────────│──→ LoggingFilter (order: -200)       │
                    │──→ JwtAuthFilter (order: -100)       │
                    │──→ RateLimitFilter                   │
                    │──→ CircuitBreakerFilter               │
                    │──→ Route Matching                    │
                    └──────────────┬──────────────────────┘
                                   │ lb://SERVICE-NAME
                          ┌────────┴────────┐
                          │   Eureka / K8s   │  서비스 디스커버리
                          └────────┬────────┘
                 ┌─────────────────┼──────────────────┐
                 ↓                 ↓                  ↓
         [USER-SERVICE]    [ORDER-SERVICE]    [PAYMENT-SERVICE]
                                              (Rate Limited)

10. 로컬 개발 환경 application.yml 전체 예시

server:
  port: 8080

spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      globalcors:
        cors-configurations:
          '[/**]':
            allowedOriginPatterns: ["*"]
            allowedMethods: ["*"]
            allowedHeaders: ["*"]
            allowCredentials: true
      routes:
        - id: user-service
          uri: lb://USER-SERVICE
          predicates: [Path=/api/v1/users/**]
          filters:
            - StripPrefix=2
            - name: CircuitBreaker
              args: {name: userServiceCB, fallbackUri: "forward:/fallback/user"}

        - id: order-service
          uri: lb://ORDER-SERVICE
          predicates: [Path=/api/v1/orders/**]
          filters:
            - StripPrefix=2
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 50
                redis-rate-limiter.burstCapacity: 100
                key-resolver: "#{@userKeyResolver}"

  data:
    redis:
      host: localhost
      port: 6379

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

jwt:
  secret: ${JWT_SECRET:local-dev-secret-key-minimum-32-chars}

resilience4j:
  circuitbreaker:
    instances:
      userServiceCB:
        slidingWindowSize: 10
        failureRateThreshold: 50
        waitDurationInOpenState: 10s
  timelimiter:
    instances:
      userServiceCB:
        timeoutDuration: 3s

management:
  endpoints:
    web:
      exposure:
        include: health,gateway,metrics

Kong vs Spring Cloud Gateway 선택 기준

상황추천
Spring Boot 마이크로서비스 팀Spring Cloud Gateway
언어/프레임워크 혼합 환경Kong / Nginx
플러그인 생태계 필요Kong
Reactive 스트리밍 처리Spring Cloud Gateway
코드 레벨 커스터마이징Spring Cloud Gateway
인프라팀이 별도 관리Kong / AWS API Gateway

Spring 생태계 내에서 개발한다면 Spring Cloud Gateway가 가장 자연스러운 선택입니다.
JWT 필터, Rate Limiter, 서킷 브레이커 모두 Spring 표준 방식으로 코드로 관리할 수 있어 유지보수성이 높습니다.