TLS/HTTPS 설정
기본 TLS 설정
server:
port: 8443
ssl:
enabled: true
key-store: classpath:keystore.p12 # 키스토어 위치
key-store-password: ${SSL_KEYSTORE_PASSWORD}
key-store-type: PKCS12
key-alias: gateway # 인증서 별칭
# 클라이언트 인증서 검증 (mTLS)
client-auth: none # none | want | need자체 서명 인증서 생성 (개발 환경)
# keytool로 자체 서명 인증서 생성
keytool -genkeypair \
-alias gateway \
-keyalg RSA \
-keysize 2048 \
-validity 365 \
-storetype PKCS12 \
-keystore keystore.p12 \
-storepass changeit \
-dname "CN=localhost, OU=Dev, O=Example, L=Seoul, ST=Seoul, C=KR"
# 인증서 내보내기 (클라이언트 신뢰 설정용)
keytool -exportcert \
-alias gateway \
-keystore keystore.p12 \
-storepass changeit \
-file gateway.crt \
-rfc
# 인증서 정보 확인
keytool -list -v \
-keystore keystore.p12 \
-storepass changeitHTTP → HTTPS 리다이렉트
// HttpsRedirectConfig.kt
import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory
import org.springframework.boot.web.server.WebServerFactoryCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpStatus
import org.springframework.web.server.WebFilter
import reactor.core.publisher.Mono
@Configuration
class HttpsRedirectConfig {
// HTTP(8080)에서 HTTPS(8443)로 리다이렉트
@Bean
fun httpsRedirectFilter(): WebFilter {
return WebFilter { exchange, chain ->
val request = exchange.request
if (request.uri.scheme == "http") {
val httpsUri = request.uri
.toString()
.replace("http://", "https://")
.replace(":8080", ":8443")
exchange.response.apply {
statusCode = HttpStatus.MOVED_PERMANENTLY
headers.location = java.net.URI.create(httpsUri)
}
Mono.empty()
} else {
chain.filter(exchange)
}
}
}
}하위 서비스 SSL 신뢰 설정
spring:
cloud:
gateway:
httpclient:
ssl:
# 개발 환경: 자체 서명 인증서 허용 (운영에서는 절대 사용 금지)
use-insecure-trust-manager: false
# 커스텀 트러스트스토어 설정
trusted-x509-certificates:
- classpath:certs/service-ca.crt
# 핸드셰이크 타임아웃
handshake-timeout: 10s
# 세션 캐시 활성화
session-cache-size: 0
session-timeout: 0mTLS (클라이언트 인증서) 설정
server:
ssl:
enabled: true
key-store: classpath:server-keystore.p12
key-store-password: ${SERVER_KEYSTORE_PASSWORD}
key-store-type: PKCS12
# 클라이언트 인증서 필수 요구
client-auth: need
# 신뢰할 클라이언트 인증서 CA
trust-store: classpath:truststore.p12
trust-store-password: ${TRUST_STORE_PASSWORD}
trust-store-type: PKCS12// mTLS에서 클라이언트 인증서 정보 추출
@Component
class MtlsAuthFilter : GlobalFilter, Ordered {
override fun getOrder(): Int = -100
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
return exchange.request.sslInfo
?.let { sslInfo ->
val peerCertificates = sslInfo.peerCertificates
if (peerCertificates.isNotEmpty()) {
val cert = peerCertificates[0] as java.security.cert.X509Certificate
val clientDn = cert.subjectX500Principal.name
val mutatedRequest = exchange.request.mutate()
.header("X-Client-Cert-DN", clientDn)
.build()
chain.filter(exchange.mutate().request(mutatedRequest).build())
} else {
chain.filter(exchange)
}
} ?: chain.filter(exchange)
}
}HTTP/2
HTTP/2 활성화
server:
http2:
enabled: true
# HTTP/2는 TLS 없이도 h2c(cleartext)로 사용 가능 (내부 통신용)
ssl:
enabled: true # HTTP/2는 TLS 필수 (브라우저 환경)h2c (HTTP/2 Cleartext) — 내부 서비스 간 통신
// H2cConfig.kt — 하위 서비스와 HTTP/2 cleartext로 통신
import io.netty.handler.codec.http2.Http2SecurityUtil
import io.netty.handler.ssl.ApplicationProtocolConfig
import io.netty.handler.ssl.ApplicationProtocolNames
import io.netty.handler.ssl.SslContextBuilder
import io.netty.handler.ssl.SupportedCipherSuiteFilter
import org.springframework.cloud.gateway.config.HttpClientCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import reactor.netty.http.HttpProtocol
@Configuration
class HttpClientConfig {
@Bean
fun http2HttpClientCustomizer(): HttpClientCustomizer {
return HttpClientCustomizer { client ->
client.protocol(
HttpProtocol.H2, // HTTP/2 (TLS 필요)
HttpProtocol.HTTP11 // HTTP/1.1 폴백
)
}
}
}spring:
cloud:
gateway:
httpclient:
# 하위 서비스와 HTTP/2 사용
http2:
enabled: trueHTTP/2 → HTTP/1.1 다운그레이드
하위 서비스가 HTTP/2를 지원하지 않는 경우:
spring:
cloud:
gateway:
routes:
- id: legacy-service
# 명시적으로 http:// 스킴 사용 → HTTP/1.1 강제
uri: http://legacy-service:8080
predicates:
- Path=/api/legacy/**WebSocket 라우팅
Spring Cloud Gateway는 WebSocket을 일반 HTTP 라우트와 동일한 방식으로 설정한다. ws:// 또는 wss:// URI를 사용하면 자동으로 WebSocket 업그레이드를 처리한다.
기본 WebSocket 라우팅
spring:
cloud:
gateway:
routes:
# HTTP와 WebSocket 동시 처리 (같은 서비스의 REST + WS)
- id: chat-service-ws
uri: ws://chat-service:8080
predicates:
- Path=/ws/chat/**
# WSS (WebSocket over TLS)
- id: realtime-service-wss
uri: wss://realtime-service:8443
predicates:
- Path=/ws/realtime/**
# Upgrade 헤더로 WebSocket 구분
- id: notification-ws
uri: ws://notification-service:8080
predicates:
- Path=/api/notifications/stream
- Header=Upgrade, websocketWebSocket 핸드셰이크 헤더 전달
spring:
cloud:
gateway:
routes:
- id: chat-ws
uri: ws://chat-service:8080
predicates:
- Path=/ws/**
filters:
# 인증 토큰을 WebSocket 핸드셰이크 헤더로 전달
- AddRequestHeader=X-Gateway-Forwarded, true
# WebSocket은 쿼리 파라미터로 토큰 전달하는 경우가 많음
# ?token=xxx → Authorization: Bearer xxx 변환
- name: AddRequestHeader
args:
name: Authorization
value: "Bearer #{T(org.springframework.web.util.UriComponentsBuilder).fromUri(exchange.request.uri).build().queryParams.getFirst('token')}"WebSocket 타임아웃 처리
WebSocket은 장시간 연결을 유지하므로 response-timeout을 비활성화해야 한다.
spring:
cloud:
gateway:
routes:
- id: chat-ws
uri: ws://chat-service:8080
predicates:
- Path=/ws/chat/**
metadata:
# WebSocket 연결은 response-timeout 비활성화
response-timeout: -1
# 연결 타임아웃은 유지
connect-timeout: 5000// WebSocket용 라우트 설정 (Java Config)
@Bean
fun webSocketRoute(builder: RouteLocatorBuilder): RouteLocator {
return builder.routes()
.route("chat-ws") { r ->
r.path("/ws/chat/**")
.metadata("response-timeout", -1)
.metadata("connect-timeout", 5000)
.uri("ws://chat-service:8080")
}
.build()
}SSE (Server-Sent Events)
SSE는 서버에서 클라이언트로 실시간 이벤트를 전송하는 단방향 스트리밍 프로토콜이다.
spring:
cloud:
gateway:
routes:
- id: event-stream
uri: http://event-service:8080
predicates:
- Path=/api/events/stream
metadata:
# SSE 스트리밍은 response-timeout 비활성화
response-timeout: -1SSE 응답의 Content-Type은 text/event-stream이며, 게이트웨이는 자동으로 버퍼링하지 않고 청크 단위로 클라이언트에 전달한다. 단, ModifyResponseBody 필터는 SSE와 함께 사용할 수 없다.
ModifyRequestBody / ModifyResponseBody 심화
요청 바디 변환
// RequestBodyRewriteConfig.kt
import org.springframework.cloud.gateway.filter.factory.rewrite.ModifyRequestBodyGatewayFilterFactory
import org.springframework.cloud.gateway.filter.factory.rewrite.RewriteFunction
import org.springframework.cloud.gateway.route.RouteLocator
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import reactor.core.publisher.Mono
@Configuration
class BodyRewriteConfig {
/**
* 요청 바디 변환: 레거시 API 형식 → 신규 형식으로 변환
*/
@Bean
fun requestBodyRewriteRoute(builder: RouteLocatorBuilder): RouteLocator {
return builder.routes()
.route("order-service") { r ->
r.path("/api/orders/**")
.filters { f ->
f.modifyRequestBody(
String::class.java,
String::class.java,
"application/json",
RewriteFunction<String, String> { exchange, originalBody ->
// 레거시 필드명 → 신규 필드명 변환
val transformed = originalBody
?.replace("\"product_id\"", "\"productId\"")
?.replace("\"user_id\"", "\"userId\"")
?: "{}"
Mono.just(transformed)
}
)
}
.uri("lb://order-service")
}
.build()
}
/**
* 응답 바디 변환: JSON에 메타 정보 추가
*/
@Bean
fun responseBodyRewriteRoute(builder: RouteLocatorBuilder): RouteLocator {
return builder.routes()
.route("product-service") { r ->
r.path("/api/products/**")
.filters { f ->
f.modifyResponseBody(
String::class.java,
String::class.java,
RewriteFunction<String, String> { exchange, originalBody ->
if (originalBody.isNullOrBlank()) {
return@RewriteFunction Mono.just("{}")
}
try {
// JSON 파싱 후 필드 추가
val objectMapper = com.fasterxml.jackson.databind.ObjectMapper()
val json = objectMapper.readTree(originalBody) as com.fasterxml.jackson.databind.node.ObjectNode
// 응답에 메타 정보 추가
json.put("_gateway", "spring-cloud-gateway")
json.put("_timestamp", System.currentTimeMillis())
json.put("_version", "v2")
Mono.just(objectMapper.writeValueAsString(json))
} catch (e: Exception) {
// JSON 파싱 실패 시 원본 반환
Mono.just(originalBody)
}
}
)
}
.uri("lb://product-service")
}
.build()
}
}대용량 바디 처리
spring:
codec:
# 기본값 256KB → 바디 크기에 따라 조정
max-in-memory-size: 10MB
cloud:
gateway:
routes:
- id: file-upload
uri: lb://storage-service
predicates:
- Path=/api/files/upload
# ModifyRequestBody 없이 스트리밍으로 전달 (대용량 파일)
# ModifyRequestBody 사용 시 전체 바디를 메모리에 올림 → 주의스트리밍 응답에 ModifyResponseBody 사용 불가
ModifyResponseBody는 전체 응답 바디를 메모리에 버퍼링한 후 변환하는 방식이다. 따라서:
- SSE (text/event-stream): 스트리밍 중에 변환 불가 → 첫 청크만 처리하거나 무한 대기
- 대용량 파일 다운로드:
max-in-memory-size초과 시 오류 - Chunked Transfer-Encoding: 청크가 모두 수신될 때까지 응답 지연
이런 경우에는 ModifyResponseBody 대신 하위 서비스에서 직접 변환하거나, GlobalFilter에서 HttpHeaders만 수정하는 방식을 사용한다.
spring:
cloud:
gateway:
routes:
- id: streaming-service
uri: lb://streaming-service
predicates:
- Path=/api/stream/**
# ModifyResponseBody 사용 금지
# 대신 헤더만 수정
filters:
- AddResponseHeader=X-Gateway-Processed, true
metadata:
response-timeout: -1실무 팁
TLS 인증서 자동 갱신 (Let’s Encrypt)
운영 환경에서는 Let’s Encrypt + Certbot으로 90일마다 자동 갱신한다. Spring Boot에서는 인증서 갱신 후 재시작 없이 적용되지 않으므로, /actuator/refresh 엔드포인트와 연동하거나 Kubernetes cert-manager를 활용한다.
WebSocket 로드밸런싱 주의사항
WebSocket은 장시간 연결을 유지하므로 일반적인 라운드로빈 로드밸런싱이 비효율적일 수 있다. WebSocket 연결이 많은 서비스는 Sticky Session 또는 서비스별 WebSocket 전용 인스턴스를 고려한다.
HTTP/2 Push Promise 미지원
Spring Cloud Gateway는 HTTP/2 Server Push를 지원하지 않는다. Push가 필요한 경우 Nginx나 Envoy 등의 전용 프록시를 앞단에 배치한다.
ModifyResponseBody와 Content-Length
바디를 수정하면 원본 Content-Length 헤더가 틀려진다. Spring Cloud Gateway는 자동으로 Content-Length를 제거하고 Transfer-Encoding: chunked로 전환한다. 클라이언트가 Content-Length를 필수로 요구하는 경우에는 변환 후 직접 헤더를 설정해야 한다.