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
10 changes: 9 additions & 1 deletion rush-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@ Spring Cloud Gateway. 외부 요청을 받는 유일한 입구이며, JWT 검증
| `/live/**` | rush-live (Go, WebSocket) |
| `/chat/**` | rush-chat (Go, WebSocket) |

WebSocket을 제외한 라우트는 서킷브레이커를 거칩니다. 서비스에 연결할 수 없거나 5초 안에 응답이 없으면 `/fallback`이 `503`을 돌려줍니다.
WebSocket을 제외한 라우트는 서킷브레이커를 거칩니다. 서비스에 연결할 수 없으면 `503`, 5초 안에 응답이 없으면 `504`를 돌려줍니다.

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

```json
{ "code": "UNAUTHORIZED", "message": "인증이 필요합니다." }
```

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

## 실행

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.rush.gateway.error

import org.springframework.http.HttpStatus

data class ErrorResponse(
val code: String,
val message: String,
) {
companion object {
fun of(status: HttpStatus) = ErrorResponse(status.name, messageOf(status))

private fun messageOf(status: HttpStatus) =
when (status) {
HttpStatus.UNAUTHORIZED -> "인증이 필요합니다."
HttpStatus.NOT_FOUND -> "요청한 경로를 찾을 수 없습니다."
HttpStatus.SERVICE_UNAVAILABLE -> "일시적으로 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요."
HttpStatus.GATEWAY_TIMEOUT -> "서비스 응답이 지연되고 있습니다. 잠시 후 다시 시도해 주세요."
else -> if (status.is5xxServerError) "서버 오류가 발생했습니다." else status.reasonPhrase
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.rush.gateway.error

import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono

@Component
class ErrorResponseWriter(
private val objectMapper: ObjectMapper,
) {
fun write(
exchange: ServerWebExchange,
status: HttpStatus,
): Mono<Void> {
val response = exchange.response
response.statusCode = status
response.headers.contentType = MediaType.APPLICATION_JSON
val body = objectMapper.writeValueAsBytes(ErrorResponse.of(status))
return response.writeWith(Mono.just(response.bufferFactory().wrap(body)))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.rush.gateway.error

import org.slf4j.LoggerFactory
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler
import org.springframework.core.annotation.Order
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
import java.net.ConnectException
import java.util.concurrent.TimeoutException

@Order(-2)
@Component
class GlobalErrorHandler(
private val writer: ErrorResponseWriter,
) : ErrorWebExceptionHandler {
private val log = LoggerFactory.getLogger(javaClass)

override fun handle(
exchange: ServerWebExchange,
ex: Throwable,
): Mono<Void> {
if (exchange.response.isCommitted) return Mono.error(ex)

val status = statusOf(ex)
if (status.is5xxServerError) {
log.error("요청 처리 실패 {} {}", exchange.request.method, exchange.request.path, ex)
}
return writer.write(exchange, status)
}

private fun statusOf(ex: Throwable) =
when (ex) {
is ResponseStatusException -> HttpStatus.resolve(ex.statusCode.value()) ?: HttpStatus.INTERNAL_SERVER_ERROR
is ConnectException -> HttpStatus.SERVICE_UNAVAILABLE
is TimeoutException -> HttpStatus.GATEWAY_TIMEOUT
else -> HttpStatus.INTERNAL_SERVER_ERROR
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
package com.rush.gateway.fallback

import com.rush.gateway.error.ErrorResponse
import org.slf4j.LoggerFactory
import org.springframework.cloud.gateway.route.Route
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils.CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ServerWebExchange
import java.util.concurrent.TimeoutException

@RestController
class FallbackController {
private val log = LoggerFactory.getLogger(javaClass)

@RequestMapping("/fallback")
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
fun fallback() =
mapOf(
"code" to "SERVICE_UNAVAILABLE",
"message" to "일시적으로 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.",
fun fallback(exchange: ServerWebExchange): ResponseEntity<ErrorResponse> {
val cause = exchange.getAttribute<Throwable>(CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR)
val status = if (cause is TimeoutException) HttpStatus.GATEWAY_TIMEOUT else HttpStatus.SERVICE_UNAVAILABLE
log.warn(
"fallback route={} status={} cause={}",
exchange.getAttribute<Route>(GATEWAY_ROUTE_ATTR)?.id,
status.value(),
cause.toString(),
)
return ResponseEntity.status(status).body(ErrorResponse.of(status))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.rush.gateway.filter

import org.slf4j.LoggerFactory
import org.springframework.core.Ordered
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilter
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono
import java.util.UUID

@Component
class AccessLogFilter :
WebFilter,
Ordered {
private val log = LoggerFactory.getLogger(javaClass)

override fun filter(
exchange: ServerWebExchange,
chain: WebFilterChain,
): Mono<Void> {
val requestId = UUID.randomUUID().toString()
val request =
exchange.request
.mutate()
.headers { it.set(REQUEST_ID_HEADER, requestId) }
.build()
exchange.response.headers.set(REQUEST_ID_HEADER, requestId)

val path = exchange.request.path.value()
if (path.startsWith("/actuator/")) return chain.filter(exchange.mutate().request(request).build())

val start = System.nanoTime()
return chain.filter(exchange.mutate().request(request).build()).doFinally {
log.info(
"{} {} {} {}ms requestId={} userId={}",
exchange.request.method,
path,
exchange.response.statusCode?.value(),
(System.nanoTime() - start) / 1_000_000,
requestId,
exchange.getAttribute<String>(JwtAuthenticationFilter.USER_ID_HEADER) ?: "-",
)
}
}

override fun getOrder() = Ordered.HIGHEST_PRECEDENCE

companion object {
const val REQUEST_ID_HEADER = "X-Request-Id"
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.rush.gateway.filter

import com.rush.gateway.error.ErrorResponseWriter
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
Expand All @@ -18,6 +19,7 @@ import reactor.core.publisher.Mono
@Component
class JwtAuthenticationFilter(
@Value("\${jwt.secret}") secret: String,
private val errorResponseWriter: ErrorResponseWriter,
) : GlobalFilter,
Ordered {
private val key = Keys.hmacShaKeyFor(secret.toByteArray())
Expand Down Expand Up @@ -52,14 +54,12 @@ class JwtAuthenticationFilter(
.payload.subject
}.getOrNull() ?: return unauthorized(exchange)

exchange.attributes[USER_ID_HEADER] = userId
val authed = sanitized.mutate().header(USER_ID_HEADER, userId).build()
return chain.filter(exchange.mutate().request(authed).build())
}

private fun unauthorized(exchange: ServerWebExchange): Mono<Void> {
exchange.response.statusCode = HttpStatus.UNAUTHORIZED
return exchange.response.setComplete()
}
private fun unauthorized(exchange: ServerWebExchange): Mono<Void> = errorResponseWriter.write(exchange, HttpStatus.UNAUTHORIZED)

override fun getOrder() = -1

Expand Down
2 changes: 2 additions & 0 deletions rush-gateway/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ spring:
gateway:
server:
webflux:
httpclient:
connect-timeout: 3000
routes:
# ---- Spring 서비스: Eureka에서 찾아갑니다 (lb://서비스이름) ----
- id: rush-auth
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.rush.gateway.error

import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.test.web.reactive.server.WebTestClient

@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"spring.cloud.config.enabled=false",
"eureka.client.enabled=false",
"jwt.secret=test-secret-key-for-gateway-error-test-0123456789",
],
)
@AutoConfigureWebTestClient
class GatewayErrorTest {
@Autowired
lateinit var client: WebTestClient

@Test
fun `토큰이 없으면 401 공통 응답을 준다`() {
client
.get()
.uri("/shop/items")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.UNAUTHORIZED)
.expectHeader()
.contentType(MediaType.APPLICATION_JSON)
.expectHeader()
.exists("X-Request-Id")
.expectBody()
.jsonPath("$.code")
.isEqualTo("UNAUTHORIZED")
}

@Test
fun `없는 경로는 404 공통 응답을 준다`() {
client
.get()
.uri("/unknown")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND)
.expectBody()
.jsonPath("$.code")
.isEqualTo("NOT_FOUND")
.jsonPath("$.message")
.isEqualTo("요청한 경로를 찾을 수 없습니다.")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import org.springframework.test.web.reactive.server.WebTestClient
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.time.Duration
import java.util.concurrent.Executors

private const val SECRET = "test-secret-key-for-gateway-fallback-test-0123456789"

Expand All @@ -24,6 +25,7 @@ private const val SECRET = "test-secret-key-for-gateway-fallback-test-0123456789
"spring.cloud.config.enabled=false",
"eureka.client.enabled=false",
"jwt.secret=$SECRET",
"resilience4j.timelimiter.configs.default.timeout-duration=3s",
],
)
@AutoConfigureWebTestClient(timeout = "10s")
Expand Down Expand Up @@ -58,6 +60,20 @@ class FallbackTest {
.isEqualTo("SERVICE_UNAVAILABLE")
}

@Test
fun `타임아웃을 넘기면 504 fallback 응답을 준다`() {
client
.get()
.uri("/user/very-slow")
.header("Authorization", "Bearer ${token()}")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.GATEWAY_TIMEOUT)
.expectBody()
.jsonPath("$.code")
.isEqualTo("GATEWAY_TIMEOUT")
}

@Test
fun `타임아웃보다 빠른 느린 응답은 그대로 전달한다`() {
client
Expand All @@ -74,8 +90,10 @@ class FallbackTest {
companion object {
private val slowServer =
HttpServer.create(InetSocketAddress("localhost", 0), 0).apply {
executor = Executors.newCachedThreadPool()
createContext("/") { exchange ->
Thread.sleep(Duration.ofSeconds(2))
val delay = if (exchange.requestURI.path.endsWith("very-slow")) 4L else 2L
Thread.sleep(Duration.ofSeconds(delay))
val body = "slow-ok".toByteArray()
exchange.sendResponseHeaders(200, body.size.toLong())
exchange.responseBody.use { it.write(body) }
Expand Down
Loading