diff --git a/rush-gateway/README.md b/rush-gateway/README.md index 08f1180..917c6cb 100644 --- a/rush-gateway/README.md +++ b/rush-gateway/README.md @@ -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를 한 줄로 남깁니다. ## 실행 diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponse.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponse.kt new file mode 100644 index 0000000..fbafc7e --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponse.kt @@ -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 + } + } +} diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponseWriter.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponseWriter.kt new file mode 100644 index 0000000..975667b --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponseWriter.kt @@ -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 { + 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))) + } +} diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/error/GlobalErrorHandler.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/error/GlobalErrorHandler.kt new file mode 100644 index 0000000..d8a08d4 --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/error/GlobalErrorHandler.kt @@ -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 { + 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 + } +} diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/fallback/FallbackController.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/fallback/FallbackController.kt index f11cea7..913316f 100644 --- a/rush-gateway/src/main/kotlin/com/rush/gateway/fallback/FallbackController.kt +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/fallback/FallbackController.kt @@ -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 { + val cause = exchange.getAttribute(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(GATEWAY_ROUTE_ATTR)?.id, + status.value(), + cause.toString(), ) + return ResponseEntity.status(status).body(ErrorResponse.of(status)) + } } diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/filter/AccessLogFilter.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/filter/AccessLogFilter.kt new file mode 100644 index 0000000..16ac557 --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/filter/AccessLogFilter.kt @@ -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 { + 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(JwtAuthenticationFilter.USER_ID_HEADER) ?: "-", + ) + } + } + + override fun getOrder() = Ordered.HIGHEST_PRECEDENCE + + companion object { + const val REQUEST_ID_HEADER = "X-Request-Id" + } +} diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/filter/JwtAuthenticationFilter.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/filter/JwtAuthenticationFilter.kt index 0eb5382..12d8aa8 100644 --- a/rush-gateway/src/main/kotlin/com/rush/gateway/filter/JwtAuthenticationFilter.kt +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/filter/JwtAuthenticationFilter.kt @@ -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 @@ -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()) @@ -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 { - exchange.response.statusCode = HttpStatus.UNAUTHORIZED - return exchange.response.setComplete() - } + private fun unauthorized(exchange: ServerWebExchange): Mono = errorResponseWriter.write(exchange, HttpStatus.UNAUTHORIZED) override fun getOrder() = -1 diff --git a/rush-gateway/src/main/resources/application.yml b/rush-gateway/src/main/resources/application.yml index 9dc3988..e88a912 100644 --- a/rush-gateway/src/main/resources/application.yml +++ b/rush-gateway/src/main/resources/application.yml @@ -10,6 +10,8 @@ spring: gateway: server: webflux: + httpclient: + connect-timeout: 3000 routes: # ---- Spring 서비스: Eureka에서 찾아갑니다 (lb://서비스이름) ---- - id: rush-auth diff --git a/rush-gateway/src/test/kotlin/com/rush/gateway/error/GatewayErrorTest.kt b/rush-gateway/src/test/kotlin/com/rush/gateway/error/GatewayErrorTest.kt new file mode 100644 index 0000000..c6e902d --- /dev/null +++ b/rush-gateway/src/test/kotlin/com/rush/gateway/error/GatewayErrorTest.kt @@ -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("요청한 경로를 찾을 수 없습니다.") + } +} diff --git a/rush-gateway/src/test/kotlin/com/rush/gateway/fallback/FallbackTest.kt b/rush-gateway/src/test/kotlin/com/rush/gateway/fallback/FallbackTest.kt index 40333e2..aad997b 100644 --- a/rush-gateway/src/test/kotlin/com/rush/gateway/fallback/FallbackTest.kt +++ b/rush-gateway/src/test/kotlin/com/rush/gateway/fallback/FallbackTest.kt @@ -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" @@ -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") @@ -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 @@ -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) }