Spring Cloud Gateway 아키텍처 설계 — 실전 구성 완벽 가이드
Spring Cloud Gateway로 마이크로서비스 API Gateway를 구축하는 방법. 라우팅, 필터, JWT 인증, Rate Limiting, 서킷 브레이커, 로드밸런싱까지 실전 코드 중심으로 정리합니다.
TestForge Team ·
버전 정보
이 포스트에서 사용하는 버전 기준입니다.
| 라이브러리 | 버전 | 비고 |
|---|---|---|
| Java | 21 | LTS |
| Spring Boot | 3.3.x | |
| Spring Cloud | 2023.0.3 (Leyton) | Boot 3.3.x 호환 |
| Spring Cloud Gateway | 4.1.x | Cloud 2023.0.x 포함 |
| Resilience4j | 2.2.x | Cloud 2023.0.x 포함 |
| Reactor Netty | 1.1.x | WebFlux 기본 서버 |
Spring Boot ↔ Spring Cloud 버전 호환표
| Spring Boot | Spring Cloud | 코드명 |
|---|---|---|
| 3.3.x | 2023.0.x | Leyton |
| 3.2.x | 2023.0.x | Leyton |
| 3.1.x | 2022.0.x | Kilburn |
| 3.0.x | 2022.0.x | Kilburn |
| 2.7.x | 2021.0.x | Jubilee |
버전 호환 여부는 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 표준 방식으로 코드로 관리할 수 있어 유지보수성이 높습니다.