Architecture

API Gateway 패턴 심화 - Spring Cloud Gateway와 Rate Limiting 구현

백엔드 개발자 김승원 2026. 4. 8. 09:49

들어가며

마이크로서비스 아키텍처(MSA)에서 API Gateway는 클라이언트와 백엔드 서비스 사이의 단일 진입점(Single Entry Point) 역할을 합니다. MSA 아키텍처 완벽 가이드에서 기본 개념 확인하기 라우팅, 인증/인가, Rate Limiting, 로드밸런싱, 요청/응답 변환 등을 담당하며, 클라이언트가 개별 마이크로서비스를 직접 호출하지 않게 합니다.

이 글에서는 Spring Cloud Gateway를 중심으로, 실무에서 필요한 라우팅 설정, 필터 체인, Redis 기반 Rate Limiter, Circuit Breaker 통합, 커스텀 필터 작성까지 심층적으로 다루겠습니다.

API Gateway의 역할

역할 설명 구현 방식
라우팅 요청 URL을 적절한 서비스로 전달 Route Predicate
인증/인가 JWT 토큰 검증, 권한 체크 Global Filter
Rate Limiting 과도한 요청 차단 Redis Rate Limiter
로드밸런싱 여러 인스턴스에 요청 분산 Spring Cloud LoadBalancer
Circuit Breaker 장애 서비스 격리 Resilience4j
로깅/모니터링 요청/응답 로깅, 메트릭 수집 Prometheus + Grafana로 Gateway 메트릭 수집하기 Gateway Filter
요청/응답 변환 헤더 추가, 본문 수정 Filter
CORS Cross-Origin 정책 관리 Global CORS Config

Spring Cloud Gateway 기본 설정

의존성 추가

<!-- build.gradle.kts -->
dependencies {
    implementation("org.springframework.cloud:spring-cloud-starter-gateway")
    implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j")
    implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive")
    implementation("org.springframework.cloud:spring-cloud-starter-loadbalancer")
}

dependencyManagement {
    imports {
        mavenBom("org.springframework.cloud:spring-cloud-dependencies:2024.0.0")
    }
}

YAML 기반 라우팅 설정

# application.yml
server:
  port: 8080

spring:
  cloud:
    gateway:
      routes:
        # 주문 서비스 라우팅
        - id: order-service
          uri: lb://ORDER-SERVICE  # 로드밸런싱
          predicates:
            - Path=/api/v1/orders/**
            - Method=GET,POST,PUT,DELETE
          filters:
            - StripPrefix=0
            - AddRequestHeader=X-Gateway-Source, spring-cloud-gateway
            - name: CircuitBreaker
              args:
                name: orderCircuitBreaker
                fallbackUri: forward:/fallback/order

        # 상품 서비스 라우팅
        - id: product-service
          uri: lb://PRODUCT-SERVICE
          predicates:
            - Path=/api/v1/products/**
          filters:
            - StripPrefix=0
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10
                redis-rate-limiter.burstCapacity: 20
                redis-rate-limiter.requestedTokens: 1
                key-resolver: "#{@ipKeyResolver}"

        # 인증 서비스 라우팅 (Rate Limiting 강화)
        - id: auth-service
          uri: lb://AUTH-SERVICE
          predicates:
            - Path=/api/v1/auth/**
          filters:
            - StripPrefix=0
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 3
                redis-rate-limiter.burstCapacity: 5
                key-resolver: "#{@ipKeyResolver}"

      # 글로벌 CORS 설정
      globalcors:
        cors-configurations:
          '[/**]':
            allowed-origins:
              - "https://example.com"
            allowed-methods:
              - GET
              - POST
              - PUT
              - DELETE
            allowed-headers: "*"
            allow-credentials: true
            max-age: 3600

      # 기본 필터
      default-filters:
        - DedupeResponseHeader=Access-Control-Allow-Origin

Route Predicate 상세

Route Predicate는 요청이 특정 Route에 매칭되는 조건을 정의합니다.

# 다양한 Predicate 조합
spring:
  cloud:
    gateway:
      routes:
        - id: advanced-route
          uri: lb://MY-SERVICE
          predicates:
            # 경로 매칭
            - Path=/api/v1/users/{userId}
            # HTTP 메서드
            - Method=GET,POST
            # 헤더 조건
            - Header=X-Request-Channel, mobile|web
            # 쿼리 파라미터
            - Query=version, v[2-9]
            # 시간 기반 (이벤트 라우팅)
            - After=2026-04-01T00:00:00+09:00[Asia/Seoul]
            - Before=2026-04-30T23:59:59+09:00[Asia/Seoul]
            # 호스트 기반
            - Host=api.example.com,api2.example.com
            # 쿠키
            - Cookie=session_type, premium

Java 코드 기반 라우팅 (프로그래매틱)

@Configuration
public class GatewayRoutesConfig {

    @Bean
    public RouteLocator customRoutes(RouteLocatorBuilder builder) {
        return builder.routes()
                // 주문 서비스
                .route("order-service", r -> r
                        .path("/api/v1/orders/**")
                        .and()
                        .method(HttpMethod.GET, HttpMethod.POST)
                        .filters(f -> f
                                .addRequestHeader("X-Gateway", "true")
                                .circuitBreaker(cb -> cb
                                        .setName("orderCB")
                                        .setFallbackUri("forward:/fallback/order"))
                                .retry(retryConfig -> retryConfig
                                        .setRetries(3)
                                        .setStatuses(HttpStatus.SERVICE_UNAVAILABLE)
                                        .setBackoff(Duration.ofMillis(100),
                                                Duration.ofMillis(1000), 2, true)))
                        .uri("lb://ORDER-SERVICE"))

                // 조건부 라우팅: A/B 테스팅
                .route("product-v2", r -> r
                        .path("/api/v1/products/**")
                        .and()
                        .header("X-API-Version", "v2")
                        .filters(f -> f.stripPrefix(0))
                        .uri("lb://PRODUCT-SERVICE-V2"))

                .route("product-v1", r -> r
                        .path("/api/v1/products/**")
                        .filters(f -> f.stripPrefix(0))
                        .uri("lb://PRODUCT-SERVICE-V1"))

                .build();
    }
}

Redis 기반 Rate Limiter

Rate Limiting은 API Gateway의 가장 중요한 기능 중 하나입니다. Spring Cloud Gateway는 Token Bucket 알고리즘 기반의 Redis Rate Limiter를 내장하고 있습니다. Redis 캐시 전략과 Rate Limiter 활용 패턴

Key Resolver 설정

@Configuration
public class RateLimiterConfig {

    // IP 기반 Rate Limiting
    @Bean
    public KeyResolver ipKeyResolver() {
        return exchange -> Mono.just(
                Objects.requireNonNull(
                        exchange.getRequest().getRemoteAddress())
                        .getAddress().getHostAddress());
    }

    // 사용자 기반 Rate Limiting (JWT에서 추출)
    @Bean
    public KeyResolver userKeyResolver() {
        return exchange -> {
            String token = exchange.getRequest().getHeaders()
                    .getFirst("Authorization");
            if (token != null && token.startsWith("Bearer ")) {
                String userId = extractUserIdFromToken(
                        token.substring(7));
                return Mono.just(userId);
            }
            // 미인증 사용자는 IP로 폴백
            return Mono.just(exchange.getRequest()
                    .getRemoteAddress().getAddress().getHostAddress());
        };
    }

    // API 경로 기반 Rate Limiting
    @Bean
    public KeyResolver pathKeyResolver() {
        return exchange -> Mono.just(
                exchange.getRequest().getPath().value());
    }
}

커스텀 Rate Limiter 구현

@Component
@RequiredArgsConstructor
public class CustomRedisRateLimiter implements RateLimiter<CustomRedisRateLimiter.Config> {

    private final ReactiveRedisTemplate<String, String> redisTemplate;

    @Override
    public Mono<Response> isAllowed(String routeId, String id) {
        String key = "rate_limiter:" + routeId + ":" + id;
        String timestampKey = key + ":timestamp";

        return redisTemplate.execute(new ReactiveRedisCallback<Response>() {
            @Override
            public Publisher<Response> doInRedis(
                    ReactiveRedisConnection connection) {

                // Sliding Window Counter 알고리즘
                long now = Instant.now().getEpochSecond();
                long windowStart = now - 60; // 1분 윈도우

                return connection.zSetCommands()
                        // 윈도우 밖의 오래된 요청 제거
                        .zRemRangeByScore(toBuffer(key),
                                Range.closed(0.0, (double) windowStart))
                        // 현재 요청 추가
                        .then(connection.zSetCommands()
                                .zAdd(toBuffer(key),
                                        (double) now,
                                        toBuffer(UUID.randomUUID().toString())))
                        // 윈도우 내 요청 수 카운트
                        .then(connection.zSetCommands()
                                .zCard(toBuffer(key)))
                        .map(count -> {
                            boolean allowed = count <= 100; // 분당 100회
                            Map<String, String> headers = new HashMap<>();
                            headers.put("X-RateLimit-Remaining",
                                    String.valueOf(Math.max(0, 100 - count)));
                            headers.put("X-RateLimit-Limit", "100");
                            headers.put("X-RateLimit-Window", "60s");
                            return new Response(allowed, headers);
                        });
            }
        }).next();
    }

    @Data
    public static class Config {
        private int requestsPerMinute = 100;
        private int windowSeconds = 60;
    }
}

Circuit Breaker 통합 (Resilience4j)

Circuit Breaker는 장애가 발생한 서비스로의 요청을 차단하여 시스템 전체의 안정성을 보호합니다.

설정

# application.yml
resilience4j:
  circuitbreaker:
    configs:
      default:
        slidingWindowSize: 10
        minimumNumberOfCalls: 5
        failureRateThreshold: 50
        waitDurationInOpenState: 10000
        permittedNumberOfCallsInHalfOpenState: 3
        slidingWindowType: COUNT_BASED
        automaticTransitionFromOpenToHalfOpenEnabled: true
    instances:
      orderCircuitBreaker:
        baseConfig: default
        failureRateThreshold: 60
        waitDurationInOpenState: 30000
      paymentCircuitBreaker:
        baseConfig: default
        failureRateThreshold: 30  # 결제는 더 민감하게
        waitDurationInOpenState: 60000

  timelimiter:
    configs:
      default:
        timeoutDuration: 3s
    instances:
      orderCircuitBreaker:
        timeoutDuration: 5s
      paymentCircuitBreaker:
        timeoutDuration: 10s

Fallback Controller

@RestController
@RequestMapping("/fallback")
public class FallbackController {

    @GetMapping("/order")
    public ResponseEntity<ApiResponse<?>> orderFallback() {
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                .body(ApiResponse.error(
                        "ORDER_SERVICE_UNAVAILABLE",
                        "주문 서비스가 일시적으로 불가합니다. 잠시 후 다시 시도해주세요."));
    }

    @GetMapping("/product")
    public ResponseEntity<ApiResponse<?>> productFallback() {
        // 캐시된 데이터 반환 가능
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                .body(ApiResponse.error(
                        "PRODUCT_SERVICE_UNAVAILABLE",
                        "상품 서비스가 일시적으로 불가합니다."));
    }
}

커스텀 필터 작성

Global Filter: JWT 인증

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

    private final JwtTokenProvider tokenProvider;
    private static final List<String> WHITELIST = List.of(
            "/api/v1/auth/login",
            "/api/v1/auth/signup",
            "/api/v1/products",     // 상품 목록 조회는 인증 불필요
            "/health",
            "/actuator"
    );

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

        // 화이트리스트 확인
        if (isWhitelisted(path)) {
            return chain.filter(exchange);
        }

        // Authorization 헤더 확인
        String authHeader = request.getHeaders()
                .getFirst(HttpHeaders.AUTHORIZATION);

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

        String token = authHeader.substring(7);

        try {
            // JWT 검증
            Claims claims = tokenProvider.validateAndGetClaims(token);

            // 사용자 정보를 헤더에 추가하여 하류 서비스에 전달
            ServerHttpRequest modifiedRequest = request.mutate()
                    .header("X-User-Id", claims.getSubject())
                    .header("X-User-Role", claims.get("role", String.class))
                    .header("X-User-Email", claims.get("email", String.class))
                    .build();

            return chain.filter(
                    exchange.mutate().request(modifiedRequest).build());

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

    private boolean isWhitelisted(String path) {
        return WHITELIST.stream().anyMatch(path::startsWith);
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange,
                                     String message) {
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        exchange.getResponse().getHeaders()
                .setContentType(MediaType.APPLICATION_JSON);

        String body = "{\"error\": \"UNAUTHORIZED\", \"message\": \""
                + message + "\"}";

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

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

Gateway Filter: 요청/응답 로깅

@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();
            String requestId = UUID.randomUUID().toString().substring(0, 8);
            long startTime = System.currentTimeMillis();

            // 요청 로깅
            log.info("[{}] {} {} from {}",
                    requestId,
                    request.getMethod(),
                    request.getPath(),
                    request.getRemoteAddress());

            // 응답 로깅
            return chain.filter(exchange).then(Mono.fromRunnable(() -> {
                long duration = System.currentTimeMillis() - startTime;
                HttpStatusCode statusCode = exchange.getResponse().getStatusCode();
                log.info("[{}] Response: {} in {}ms",
                        requestId, statusCode, duration);

                if (duration > config.getSlowRequestThreshold()) {
                    log.warn("[{}] SLOW REQUEST: {} {} took {}ms",
                            requestId, request.getMethod(),
                            request.getPath(), duration);
                }
            }));
        };
    }

    @Data
    public static class Config {
        private long slowRequestThreshold = 3000; // 3초 이상이면 경고
    }
}

Gateway Filter: 요청 본문 수정

@Component
public class RequestBodyModifyFilter
        extends AbstractGatewayFilterFactory<RequestBodyModifyFilter.Config> {

    @Override
    public GatewayFilter apply(Config config) {
        return new OrderedGatewayFilter((exchange, chain) -> {
            return ModifyRequestBodyGatewayFilterFactory
                    .rewriteFunction(exchange, String.class, String.class,
                            (serverWebExchange, body) -> {
                                // 요청 본문에 트레이싱 정보 추가
                                try {
                                    ObjectMapper mapper = new ObjectMapper();
                                    ObjectNode node = (ObjectNode)
                                            mapper.readTree(body);
                                    node.put("gatewayTimestamp",
                                            Instant.now().toString());
                                    node.put("traceId",
                                            UUID.randomUUID().toString());
                                    return Mono.just(
                                            mapper.writeValueAsString(node));
                                } catch (Exception e) {
                                    return Mono.just(body);
                                }
                            })
                    .filter(exchange, chain);
        }, 10);
    }

    public static class Config {}
}

운영 환경 구성 팁

Actuator 연동

# Gateway 라우트 정보 확인
management:
  endpoints:
    web:
      exposure:
        include: gateway, health, metrics, info
  endpoint:
    gateway:
      enabled: true

# GET /actuator/gateway/routes - 전체 라우트 목록 Spring Boot Actuator로 Gateway 운영 모니터링
# GET /actuator/gateway/routes/{id} - 특정 라우트 상세
# POST /actuator/gateway/refresh - 라우트 갱신

Timeout 설정

spring:
  cloud:
    gateway:
      httpclient:
        connect-timeout: 2000      # 연결 타임아웃 2초
        response-timeout: 5s       # 응답 타임아웃 5초
        pool:
          max-connections: 500     # 최대 연결 수
          max-idle-time: 20s       # 유휴 연결 유지 시간
          max-life-time: 60s       # 연결 최대 수명

API Gateway 선택 가이드

제품 장점 단점 적합한 경우
Spring Cloud Gateway Spring 생태계 통합, Java 개발자 친화적 성능은 중간 Spring 기반 MSA
Kong 플러그인 풍부, 고성능 Lua 기반 커스터마이징 대규모 API 관리
AWS API Gateway 관리형, 서버리스 통합 벤더 종속, 커스터마이징 한계 AWS 환경
Envoy + Istio 서비스 메시 통합 학습 곡선 높음 K8s 네이티브
NGINX 고성능, 안정성 동적 설정 제한 단순 라우팅/로드밸런싱

마치며

API Gateway는 MSA의 관문입니다. 잘 설계된 Gateway는 각 마이크로서비스가 비즈니스 로직에만 집중할 수 있게 하고, 잘못 설계된 Gateway는 전체 시스템의 병목이 됩니다. Spring Cloud Gateway는 리액티브 기반으로 높은 처리량을 제공하면서도, Spring 개발자에게 친숙한 프로그래밍 모델을 제공합니다.

핵심은 Gateway에 너무 많은 책임을 부여하지 않는 것입니다. 헥사고날 아키텍처로 각 서비스 내부 구조화하기 라우팅, 인증, Rate Limiting 정도에 집중하고, 복잡한 비즈니스 로직은 각 서비스에 위임하세요. Gateway가 무거워지면 전체 시스템의 단일 장애점(SPOF)이 되므로, 가볍고 빠르게 유지하는 것이 중요합니다.