Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions rush-config/src/main/resources/config/rush-gateway.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ gateway:
trusted-proxies: ${TRUSTED_PROXIES:}
# CORS를 허용할 Origin(콤마 구분). 비어 있으면 CORS를 허용하지 않습니다.
cors-allowed-origins: ${CORS_ALLOWED_ORIGINS:}
response-wrapper:
# 이보다 큰 서비스 응답은 공통 형식으로 감싸지 않고 원문을 보냅니다.
max-size: 1MB

# 공통 application.yml의 exposure를 덮어씁니다. health 외에는 허용 IP에서만 열립니다.
management:
Expand Down
12 changes: 10 additions & 2 deletions rush-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,20 @@ Spring Cloud Gateway. 외부 요청을 받는 유일한 입구이며, JWT 검증

WebSocket을 제외한 라우트는 서킷브레이커(Resilience4j)를 거칩니다. 연결 실패와 서비스의 500·502·503·504 응답을 실패로 집계합니다. 최근 20건 중 절반이 실패하면 10초 동안 차단합니다. 응답은 원인별로 나뉩니다. 5초 안에 응답이 없으면 `504`, 서비스가 5xx를 돌려주면 `502`, 차단 중이거나 연결할 수 없으면 `503`입니다.

게이트웨이가 만드는 에러는 모두 같은 형식입니다.
## 공통 응답

클라이언트가 받는 JSON 응답은 모두 `{status, code, message, data}` 형식이며, `data`가 없으면 생략합니다.

```json
{ "code": "UNAUTHORIZED", "message": "인증이 필요합니다." }
{ "status": 200, "code": "OK", "message": "요청에 성공했습니다.", "data": { "id": 1 } }
{ "status": 401, "code": "UNAUTHORIZED", "message": "인증이 필요합니다." }
```

- 게이트웨이가 직접 만드는 오류(400·401·403·404·5xx·fallback)는 처음부터 이 형식입니다.
- 서비스의 JSON 응답은 `ApiResponseWrapperFilter`가 감쌉니다. 성공 응답은 원본을 `data`에 넣습니다. 오류 응답은 원본의 `code`, `message`를 쓰고, 없으면 상태 이름과 reason phrase를 씁니다.
- 이미 `status`, `code`, `message`를 모두 가진 응답, JSON이 아닌 응답, 압축된 응답, 204·304, HEAD, WebSocket, actuator·fallback·Swagger 경로는 감싸지 않습니다.
- 1MB(`gateway.response-wrapper.max-size`)를 넘는 응답은 감싸지 않고 원문을 그대로 보냅니다.

모든 요청에 `X-Request-Id`를 새로 붙여 서비스와 응답에 넘기고, 메서드·경로·상태·소요 시간·사용자 ID를 한 줄로 남깁니다.

## 접근 제어
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.rush.gateway.error

import com.fasterxml.jackson.databind.ObjectMapper
import com.rush.gateway.response.CommonApiResponse
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
Expand All @@ -18,7 +19,7 @@ class ErrorResponseWriter(
val response = exchange.response
response.statusCode = status
response.headers.contentType = MediaType.APPLICATION_JSON
val body = objectMapper.writeValueAsBytes(ErrorResponse.of(status))
val body = objectMapper.writeValueAsBytes(CommonApiResponse.error(status))
return response.writeWith(Mono.just(response.bufferFactory().wrap(body)))
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.rush.gateway.fallback

import com.rush.gateway.error.ErrorResponse
import com.rush.gateway.response.CommonApiResponse
import org.slf4j.LoggerFactory
import org.springframework.cloud.gateway.filter.factory.SpringCloudCircuitBreakerFilterFactory.CircuitBreakerStatusCodeException
import org.springframework.cloud.gateway.route.Route
Expand All @@ -18,7 +18,7 @@ class FallbackController {
private val log = LoggerFactory.getLogger(javaClass)

@RequestMapping("/fallback")
fun fallback(exchange: ServerWebExchange): ResponseEntity<ErrorResponse> {
fun fallback(exchange: ServerWebExchange): ResponseEntity<CommonApiResponse> {
val cause = exchange.getAttribute<Throwable>(CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR)
val status = statusOf(cause)
log.warn(
Expand All @@ -27,7 +27,7 @@ class FallbackController {
status.value(),
cause.toString(),
)
return ResponseEntity.status(status).body(ErrorResponse.of(status))
return ResponseEntity.status(status).body(CommonApiResponse.error(status))
}

// 응답 지연은 504, 서비스가 5xx를 돌려줬으면 502, 차단기가 열렸거나 연결에 실패했으면 503입니다.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.rush.gateway.response

import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.cfg.JsonNodeFeature
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component

/**
* 서비스가 돌려준 JSON body를 공통 형식으로 바꿉니다.
*/
@Component
class ApiResponseBodyWrapper(
private val objectMapper: ObjectMapper,
) {
// 소수를 double로 읽으면 금액 같은 값의 자릿수가 바뀌므로 BigDecimal로 읽고, 끝자리 0도 원문대로 둡니다.
private val reader =
objectMapper
.reader()
.with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
.without(JsonNodeFeature.STRIP_TRAILING_BIGDECIMAL_ZEROES)

/** 감싼 body를 돌려줍니다. 이미 공통 형식이거나 JSON으로 읽을 수 없으면 null입니다. */
fun wrap(
status: HttpStatus,
body: ByteArray,
): ByteArray? {
val node = if (body.isEmpty()) null else runCatching { reader.readTree(body) }.getOrElse { return null }
if (node != null && isAlreadyWrapped(node)) return null

val wrapped =
if (status.isError) {
CommonApiResponse.error(
status = status.value(),
code = node.textField("code") ?: status.name,
message = node.textField("message") ?: status.reasonPhrase,
)
} else {
CommonApiResponse.success(status, node)
}
return objectMapper.writeValueAsBytes(wrapped)
}

private fun isAlreadyWrapped(node: JsonNode) = node.isObject && WRAPPED_KEYS.all(node::has)

private fun JsonNode?.textField(name: String): String? = this?.get(name)?.takeIf { it.isTextual }?.asText()

companion object {
private val WRAPPED_KEYS = listOf("status", "code", "message")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.rush.gateway.response

import org.reactivestreams.Publisher
import org.springframework.cloud.gateway.filter.GatewayFilterChain
import org.springframework.cloud.gateway.filter.GlobalFilter
import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter
import org.springframework.core.Ordered
import org.springframework.core.io.buffer.DataBuffer
import org.springframework.core.io.buffer.DataBufferUtils
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.server.reactive.ServerHttpResponseDecorator
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono

/**
* 서비스 응답을 CommonApiResponse로 감쌉니다.
*
* NettyWriteResponseFilter 바로 앞에서 응답을 가로채, 쓰기 직전에 [ApiResponseWrappingPolicy]로 감쌀지 정합니다.
* 감쌀 때는 body를 최대 크기까지 모았다가 바꾸고, 크기를 넘으면 원문을 그대로 흘려보냅니다.
* SSE처럼 writeAndFlushWith로 나가는 스트리밍 응답은 감싸지 않습니다.
*/
@Component
class ApiResponseWrapperFilter(
private val policy: ApiResponseWrappingPolicy,
private val bodyWrapper: ApiResponseBodyWrapper,
properties: ApiResponseWrapperProperties,
) : GlobalFilter,
Ordered {
private val maxBytes = properties.maxSize.toBytes()

override fun filter(
exchange: ServerWebExchange,
chain: GatewayFilterChain,
): Mono<Void> = chain.filter(exchange.mutate().response(WrappingResponse(exchange)).build())

private inner class WrappingResponse(
private val exchange: ServerWebExchange,
) : ServerHttpResponseDecorator(exchange.response) {
override fun writeWith(body: Publisher<out DataBuffer>): Mono<Void> {
val request = exchange.request
val decision = policy.decide(request.path.value(), request.method, request.headers, httpStatus(), headers)
if (decision != ApiResponseWrappingPolicy.Decision.WRAP) return super.writeWith(body)

val collector = BoundedBodyCollector(maxBytes)
val relayed = Flux.from(body).concatMapIterable(collector::accept)
val wrapped = Mono.defer { if (collector.relaying) Mono.empty() else Mono.justOrEmpty(wrappedBody(collector.drain())) }
return super.writeWith(
relayed
.concatWith(wrapped)
.doOnCancel(collector::release)
.doOnError { collector.release() },
)
}

private fun httpStatus(): HttpStatus? = statusCode?.let { HttpStatus.resolve(it.value()) }

// 모은 버퍼를 하나로 합쳐 감싸고, 감쌀 수 없으면 원문 그대로 돌려줍니다.
private fun wrappedBody(buffers: List<DataBuffer>): DataBuffer? {
if (buffers.isEmpty()) return null
val original =
bufferFactory().join(buffers).let { joined ->
ByteArray(joined.readableByteCount()).also {
joined.read(it)
DataBufferUtils.release(joined)
}
}
val status = httpStatus() ?: HttpStatus.OK
val bytes = bodyWrapper.wrap(status, original) ?: return bufferFactory().wrap(original)

headers.remove(HttpHeaders.TRANSFER_ENCODING)
HEADERS_INVALIDATED_BY_WRAPPING.forEach(headers::remove)
headers.contentType = MediaType.APPLICATION_JSON
headers.contentLength = bytes.size.toLong()
return bufferFactory().wrap(bytes)
}
}

override fun getOrder() = NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1

companion object {
// body가 바뀌면 의미가 없어지는 헤더
private val HEADERS_INVALIDATED_BY_WRAPPING = listOf(HttpHeaders.ETAG, "Content-MD5", "Digest")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.rush.gateway.response

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.util.unit.DataSize

/**
* 서비스 응답 래핑 설정입니다.
*/
@ConfigurationProperties("gateway.response-wrapper")
data class ApiResponseWrapperProperties(
// 이보다 큰 응답은 감싸지 않고 원문을 그대로 보냅니다.
val maxSize: DataSize = DataSize.ofMegabytes(1),
// 기본 제외 경로에 더해 감싸지 않을 경로 패턴
val excludedPaths: List<String> = emptyList(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.rush.gateway.response

import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.server.PathContainer
import org.springframework.stereotype.Component
import org.springframework.web.util.pattern.PathPatternParser

/**
* 서비스 응답을 공통 형식으로 감쌀지 정합니다. 응답 상태와 헤더가 정해진 뒤, body를 쓰기 직전에 판단합니다.
*/
@Component
class ApiResponseWrappingPolicy(
properties: ApiResponseWrapperProperties,
) {
private val maxBytes = properties.maxSize.toBytes()
private val excludedPatterns = (DEFAULT_EXCLUDED_PATHS + properties.excludedPaths).map(PathPatternParser.defaultInstance::parse)

fun decide(
path: String,
method: HttpMethod,
requestHeaders: HttpHeaders,
status: HttpStatus?,
responseHeaders: HttpHeaders,
): Decision {
val pathContainer = PathContainer.parsePath(path)
return when {
excludedPatterns.any { it.matches(pathContainer) } -> Decision.SKIP_EXCLUDED_PATH
requestHeaders.upgrade.equals("websocket", ignoreCase = true) -> Decision.SKIP_WEBSOCKET
method == HttpMethod.HEAD -> Decision.SKIP_NO_BODY
status == HttpStatus.NO_CONTENT || status == HttpStatus.NOT_MODIFIED -> Decision.SKIP_NO_BODY

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

206 부분 응답도 여기서 제외하는 게 좋겠습니다. 7바이트짜리 JSON 응답으로 확인해 보니 래핑 후 본문은 96바이트가 됐는데 Content-Range: bytes 0-6/20은 그대로 남았습니다. 범위 정보가 원본 본문 기준이라, 206이나 Content-Range가 있는 응답은 그대로 전달하는 쪽이 맞을 것 같습니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cfcromn b007549 에서 반영했습니다. (근거: RFC 9110 §14.4, Content-Range는 원본 표현 기준) 206 응답이나 Content-Range가 있는 응답은 감싸지 않고 그대로 전달합니다. 정책 단위 테스트와 통합 테스트를 추가했습니다.

// Content-Range는 원본 본문 기준이라 감싸면 범위가 맞지 않습니다.
status == HttpStatus.PARTIAL_CONTENT || responseHeaders.containsKey(HttpHeaders.CONTENT_RANGE) -> Decision.SKIP_PARTIAL
isEncoded(responseHeaders) -> Decision.SKIP_ENCODED
!isJson(responseHeaders.contentType) -> Decision.SKIP_NOT_JSON
responseHeaders.contentLength > maxBytes -> Decision.SKIP_TOO_LARGE
else -> Decision.WRAP
}
}

private fun isEncoded(headers: HttpHeaders): Boolean {
val encoding = headers.getFirst(HttpHeaders.CONTENT_ENCODING) ?: return false
return !encoding.equals("identity", ignoreCase = true)
}

private fun isJson(contentType: MediaType?): Boolean =
contentType != null &&
(contentType.isCompatibleWith(MediaType.APPLICATION_JSON) || contentType.subtype.endsWith("+json"))

enum class Decision {
WRAP,
SKIP_EXCLUDED_PATH,
SKIP_WEBSOCKET,
SKIP_NO_BODY,
SKIP_PARTIAL,
SKIP_ENCODED,
SKIP_NOT_JSON,
SKIP_TOO_LARGE,
}

companion object {
val DEFAULT_EXCLUDED_PATHS =
listOf(
"/actuator/**",
"/fallback",
"/v3/api-docs/**",
"/*/v3/api-docs/**",
"/swagger-ui.html",
"/swagger-ui/**",
"/webjars/**",
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.rush.gateway.response

import org.springframework.core.io.buffer.DataBuffer
import org.springframework.core.io.buffer.DataBufferUtils

/**
* 응답 body를 최대 [maxBytes]까지 모읍니다. 넘는 순간 모은 버퍼를 한꺼번에 내보내고,
* 이후 버퍼는 모으지 않고 그대로 통과시킵니다. 요청 하나에서만 쓰는 상태 객체입니다.
*/
class BoundedBodyCollector(
private val maxBytes: Long,
) {
private val collected = mutableListOf<DataBuffer>()
private var size = 0L

/** 크기를 넘어 원문을 그대로 흘려보내는 중인지 여부입니다. */
var relaying = false
private set

/** 버퍼 하나를 받아, 지금 내보낼 버퍼 목록을 돌려줍니다. 모으는 중이면 빈 목록입니다. */
fun accept(buffer: DataBuffer): List<DataBuffer> {
if (relaying) return listOf(buffer)

collected += buffer
size += buffer.readableByteCount()
if (size <= maxBytes) return emptyList()

relaying = true
return drain()
}

/** 모은 버퍼를 넘겨주고 비웁니다. 넘겨받은 쪽이 해제합니다. */
fun drain(): List<DataBuffer> = collected.toList().also { collected.clear() }

/** 취소나 오류로 끝났을 때 아직 들고 있는 버퍼를 해제합니다. */
fun release() {
drain().forEach(DataBufferUtils::release)
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
package com.rush.gateway.error
package com.rush.gateway.response

import com.fasterxml.jackson.annotation.JsonInclude
import org.springframework.http.HttpStatus

data class ErrorResponse(
/**
* 클라이언트가 받는 공통 응답 형식입니다. 서비스 응답은 ApiResponseWrapperFilter가,
* 게이트웨이가 직접 만드는 오류는 ErrorResponseWriter가 이 형식으로 씁니다. data가 없으면 필드를 생략합니다.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
data class CommonApiResponse(
val status: Int,
val code: String,
val message: String,
val data: Any? = null,
) {
companion object {
fun of(status: HttpStatus) = ErrorResponse(status.name, messageOf(status))
const val SUCCESS_MESSAGE = "요청에 성공했습니다."

fun success(
status: HttpStatus,
data: Any?,
) = CommonApiResponse(status.value(), status.name, SUCCESS_MESSAGE, data)

fun error(status: HttpStatus) = error(status.value(), status.name, messageOf(status))

fun error(
status: Int,
code: String,
message: String,
) = CommonApiResponse(status, code, message)

private fun messageOf(status: HttpStatus) =
when (status) {
Expand Down
Loading
Loading