Spring Cloud Gateway를 실무 흐름으로 이해하기
Spring Cloud Gateway로 API Gateway, 라우팅, 필터, 인증 연동, 서킷 브레이커, rate limit, 관측성까지 MSA 진입점을 구성하는 방법을 정리합니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.
Spring Cloud Gateway로 API Gateway, 라우팅, 필터, 인증 연동, 서킷 브레이커, rate limit, 관측성까지 MSA 진입점을 구성하는 방법을 정리합니다.
Spring Cloud Gateway로 API Gateway, 라우팅, 필터, 인증 연동, 서킷 브레이커, rate limit, 관측성까지 MSA 진입점을 구성하는 방법을 정리합니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.
문법보다 요청이 들어와 검증, 처리, 저장, 응답으로 이어지는 경계를 먼저 잡습니다.
글로 읽은 내용을 머릿속에 오래 남기려면 먼저 흐름을 그림으로 잡는 편이 좋습니다. 아래 두 그림은 Spring Cloud Gateway를 학습할 때 계속 되돌아볼 수 있는 기준 지도입니다.
Spring Cloud Gateway를 처음 펼칠 때는 세부 명령보다 큰 그림이 먼저입니다. 이 섹션에서는 앞으로 배울 개념들이 어떤 문제를 풀기 위해 등장했는지부터 잡아봅니다.
| Area | Role |
|---|---|
| Route | path, method, host 조건에 따라 upstream service 선택 |
| Predicate | 요청이 어떤 route에 매칭되는지 결정 |
| Filter | 요청/응답 헤더, 인증, 로깅, 재시도 등 공통 처리 |
| Observability | Actuator, Micrometer, Prometheus로 Gateway 상태 관측 |
여기서는 Project setup을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
plugins {
id("java")
id("org.springframework.boot") version "3.3.5"
id("io.spring.dependency-management") version "1.1.6"
}
java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } }
extra["springCloudVersion"] = "2023.0.3"
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-actuator")
implementation("io.micrometer:micrometer-registry-prometheus")
}
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
}
}여기서는 Route config을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
server:
port: 8080
spring:
cloud:
gateway:
routes:
- id: user-service
uri: http://user-service:8081
predicates:
- Path=/api/users/**
filters:
- StripPrefix=1
- AddRequestHeader=X-Gateway, testforge
- id: order-service
uri: http://order-service:8082
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1여기서는 Global filter을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
import java.util.UUID;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class TraceIdFilter {
@Bean
GlobalFilter addTraceId() {
return (exchange, chain) -> {
String traceId = UUID.randomUUID().toString();
var request = exchange.getRequest().mutate()
.header("X-Trace-Id", traceId)
.build();
return chain.filter(exchange.mutate().request(request).build())
.doFinally(signal -> {
exchange.getResponse().getHeaders().add("X-Trace-Id", traceId);
});
};
}
}여기서는 Auth integration을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
@Component
public class AuthGatewayFilter extends AbstractGatewayFilterFactory<AuthGatewayFilter.Config> {
public AuthGatewayFilter() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
String auth = exchange.getRequest().getHeaders().getFirst("Authorization");
if (auth == null || !auth.startsWith("Bearer ")) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
};
}
public static class Config {}
}여기서는 Circuit breaker을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
spring:
cloud:
gateway:
routes:
- id: order-service
uri: http://order-service:8082
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1
- name: CircuitBreaker
args:
name: orderCircuitBreaker
fallbackUri: forward:/fallback/orders
resilience4j:
circuitbreaker:
instances:
orderCircuitBreaker:
slidingWindowType: COUNT_BASED
slidingWindowSize: 20
minimumNumberOfCalls: 10
failureRateThreshold: 50
slowCallRateThreshold: 50
slowCallDurationThreshold: 2s
waitDurationInOpenState: 20s
permittedNumberOfCallsInHalfOpenState: 5
automaticTransitionFromOpenToHalfOpenEnabled: true
timelimiter:
instances:
orderCircuitBreaker:
timeoutDuration: 3simport java.time.Instant;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class FallbackController {
@GetMapping("/fallback/orders")
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public Map<String, Object> orderFallback() {
return Map.of(
"status", 503,
"code", "ORDER_SERVICE_UNAVAILABLE",
"message", "주문 서비스가 일시적으로 불안정합니다. 잠시 후 다시 시도해 주세요.",
"retryable", true,
"timestamp", Instant.now().toString()
);
}
}# API 성격별 권장값
# read API: fallback 가능, timeout 짧게, half-open 빠르게
# write API: fallback보다 명확한 실패 응답 선호, retry 중복 처리 주의
orders-read:
failureRateThreshold: 50
slowCallDurationThreshold: 1500ms
waitDurationInOpenState: 15s
payments-write:
failureRateThreshold: 30
slowCallDurationThreshold: 2500ms
waitDurationInOpenState: 30s
# 운영에서 반드시 같이 볼 지표
# - resilience4j_circuitbreaker_state
# - resilience4j_circuitbreaker_calls
# - gateway route별 5xx 비율
# - fallback 응답 비율
# - upstream service latency p95 / p99| State | Meaning | Gateway behavior |
|---|---|---|
| Closed | 정상 상태. 요청을 upstream 서비스로 전달합니다. | 성공/실패 지표를 계속 기록합니다. |
| Open | 실패율 또는 slow call 비율이 임계치를 넘은 상태입니다. | upstream 호출을 막고 fallback으로 즉시 응답합니다. |
| Half-open | 대기 시간이 지난 뒤 일부 요청만 테스트로 흘려보냅니다. | 성공하면 Closed, 실패하면 다시 Open으로 돌아갑니다. |
여기서는 Rate limit을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
spring:
cloud:
gateway:
routes:
- id: public-api
uri: http://public-api:8083
predicates:
- Path=/api/public/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
key-resolver: "#{@ipKeyResolver}"여기서는 Circuit breaker tuning guide을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
spring:
cloud:
gateway:
routes:
- id: catalog-read
uri: http://catalog-service:8080
predicates: [Path=/api/catalog/**]
filters:
- name: CircuitBreaker
args:
name: catalogReadCircuit
fallbackUri: forward:/fallback/catalog
- id: payment-write
uri: http://payment-service:8080
predicates: [Path=/api/payments/**]
filters:
- name: CircuitBreaker
args:
name: paymentWriteCircuit
fallbackUri: forward:/fallback/payments
resilience4j:
circuitbreaker:
instances:
catalogReadCircuit:
slidingWindowSize: 50
minimumNumberOfCalls: 20
failureRateThreshold: 50
slowCallRateThreshold: 60
slowCallDurationThreshold: 1200ms
waitDurationInOpenState: 15s
paymentWriteCircuit:
slidingWindowSize: 30
minimumNumberOfCalls: 10
failureRateThreshold: 30
slowCallRateThreshold: 40
slowCallDurationThreshold: 2500ms
waitDurationInOpenState: 45sCircuit breaker를 켜기 전 확인할 것
1. fallback 응답이 클라이언트 UX와 맞는가?
2. POST/PUT 요청에서 retry가 중복 생성/중복 결제를 만들지 않는가?
3. timeoutDuration이 클라이언트 timeout보다 짧은가?
4. fallback 비율이 급증할 때 알림이 울리는가?
5. Open 상태가 오래 유지될 때 upstream 장애와 Gateway 설정 오류를 구분할 수 있는가?
권장 알림
- circuit state가 OPEN으로 1분 이상 유지
- fallback 응답 비율 5분 평균 5% 초과
- half-open probe 실패가 연속 발생
- 특정 route의 p95 latency가 slowCallDurationThreshold에 근접| Option | What to tune | Practical guideline |
|---|---|---|
| minimumNumberOfCalls | 통계를 계산하기 전 필요한 최소 호출 수 | 트래픽이 적은 route는 10~20, 많은 route는 50 이상으로 잡아 우발적 장애 판정을 줄입니다. |
| slidingWindowSize | 실패율을 계산하는 표본 크기 | 작을수록 민감하고 클수록 안정적입니다. 핵심 API는 배포 초기 50~100으로 시작합니다. |
| failureRateThreshold | Open 전환 실패율 | 조회 API는 50%, 결제/인증처럼 민감한 API는 30~40%부터 보수적으로 시작합니다. |
| slowCallDurationThreshold | 느린 호출로 볼 기준 시간 | upstream p95보다 약간 높은 값으로 시작하고, 사용자 timeout보다 짧게 둡니다. |
| waitDurationInOpenState | Open 상태 유지 시간 | 장애가 짧은 서비스는 10~20초, 복구가 느린 외부 연동은 30~60초를 검토합니다. |
여기서는 Rate limit advanced을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Mono;
@Configuration
public class RateLimitKeyResolvers {
@Bean
KeyResolver ipKeyResolver() {
return exchange -> {
String forwarded = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
String ip = forwarded != null
? forwarded.split(",")[0].trim()
: exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
return Mono.just("ip:" + ip);
};
}
@Bean
KeyResolver userKeyResolver() {
return exchange -> Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst("X-User-Id"))
.map(userId -> "user:" + userId)
.defaultIfEmpty("anonymous");
}
@Bean
KeyResolver apiKeyResolver() {
return exchange -> Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst("X-Api-Key"))
.map(apiKey -> "api-key:" + apiKey)
.defaultIfEmpty("missing-api-key");
}
}spring:
cloud:
gateway:
routes:
- id: search-free
uri: http://search-service:8080
predicates:
- Path=/api/search/**
- Header=X-Plan, free
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 5
redis-rate-limiter.burstCapacity: 10
key-resolver: "#{@userKeyResolver}"
- id: search-pro
uri: http://search-service:8080
predicates:
- Path=/api/search/**
- Header=X-Plan, pro
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 50
redis-rate-limiter.burstCapacity: 100
key-resolver: "#{@userKeyResolver}"replenishRate
- 초당 새로 채워지는 token 수입니다.
- 안정적으로 허용할 평균 RPS에 맞춥니다.
burstCapacity
- 순간적으로 허용할 최대 token 수입니다.
- 너무 낮으면 정상 사용자의 짧은 burst도 429가 됩니다.
- 너무 높으면 장애 시 보호 효과가 약해집니다.
requestedTokens
- 요청 하나가 소비하는 token 수입니다.
- 비용이 큰 API는 5~10 token처럼 더 비싸게 책정할 수 있습니다.
429 응답 운영 기준
- Retry-After 헤더를 내려 클라이언트가 재시도 시점을 알게 합니다.
- 로그인/결제/쓰기 API는 무한 재시도를 막도록 클라이언트 정책과 같이 설계합니다.
- 429 비율은 사용자 불편 신호이므로 단순 차단 성공 지표로만 보지 않습니다.| Key strategy | Best for | Caution |
|---|---|---|
| IP address | 비로그인 공개 API, 크롤러/봇 방어 | NAT/프록시 뒤의 정상 사용자를 같이 제한할 수 있습니다. |
| User ID | 로그인 사용자별 공정 사용량 제어 | 인증 필터 뒤에서 적용해야 정확합니다. |
| API key | 파트너/외부 연동 API | 키 유출 시 피해가 커서 rotate 정책이 필요합니다. |
| Tenant ID | B2B SaaS 조직 단위 제한 | 조직 내부 사용자가 많으면 user limit과 같이 써야 합니다. |
여기서는 Actuator metrics을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
management:
endpoints:
web:
exposure:
include: health,info,prometheus,gateway
endpoint:
gateway:
enabled: true
metrics:
tags:
application: testforge-gatewaySpring Cloud Gateway 실무 설계은 선택지가 갈리는 지점입니다. 표를 기준으로 각 방법의 쓰임새와 운영상의 차이를 비교해두면 이후 판단이 훨씬 쉬워집니다.
| 결정 지점 | 확인 질문 | 실무 기준 |
|---|---|---|
| 경계 | Spring Cloud Gateway 코드에서 바뀌기 쉬운 부분은 어디인가? | 입출력, 설정, 외부 연동, 핵심 규칙을 분리합니다. |
| 상태 | 상태가 어디서 생성되고 어디서 사라지는가? | 상태 소유자와 수명 주기를 코드로 드러냅니다. |
| 장애 | 실패했을 때 호출자는 무엇을 받는가? | timeout, fallback, error contract를 먼저 정합니다. |
이 섹션은 Spring Cloud Gateway 운영 기준을 실무 관점에서 정리합니다. 개념을 외우기보다, 어떤 상황에서 이 기준을 꺼내 쓸지에 초점을 맞춰보세요.
Spring Cloud Gateway 검증 전략은 선택지가 갈리는 지점입니다. 표를 기준으로 각 방법의 쓰임새와 운영상의 차이를 비교해두면 이후 판단이 훨씬 쉬워집니다.
| 품질 축 | 검증 방법 | 완료 기준 |
|---|---|---|
| 정확성 | 정상/실패 케이스를 자동화합니다. | 핵심 시나리오가 재현 가능하게 통과합니다. |
| 회귀 방지 | 버그 수정 시 동일 케이스를 테스트로 남깁니다. | 같은 장애가 다시 배포되지 않습니다. |
| 운영성 | 로그, 메트릭, 알림을 확인합니다. | 문제가 생겼을 때 원인 추적 경로가 있습니다. |