diff --git a/gateway-service/build.gradle.kts b/gateway-service/build.gradle.kts index 805e58d5..f6ba0237 100644 --- a/gateway-service/build.gradle.kts +++ b/gateway-service/build.gradle.kts @@ -19,8 +19,12 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-security") testImplementation("org.springframework.security:spring-security-test") - //redis + // Redis implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive") + + // Circuit Breaker + implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j:3.1.0") + } tasks.named("bootJar") { diff --git a/gateway-service/src/main/kotlin/com/grepp/quizy/api/GatewayFallbackController.kt b/gateway-service/src/main/kotlin/com/grepp/quizy/api/GatewayFallbackController.kt new file mode 100644 index 00000000..0f48815a --- /dev/null +++ b/gateway-service/src/main/kotlin/com/grepp/quizy/api/GatewayFallbackController.kt @@ -0,0 +1,52 @@ +package com.grepp.quizy.api + +import com.grepp.quizy.common.api.ApiResponse +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/fallback") +class GatewayFallbackController { + @GetMapping("/user") + fun userServiceFallback(): ResponseEntity> { + return ResponseEntity + .status(HttpStatus.SERVICE_UNAVAILABLE) + .body(ApiResponse.error(HttpStatus.SERVICE_UNAVAILABLE.name, "User service is temporarily unavailable")) + } + + @GetMapping("/quiz") + fun quizServiceFallback(): ResponseEntity> { + return ResponseEntity + .status(HttpStatus.SERVICE_UNAVAILABLE) + .body(ApiResponse.error(HttpStatus.SERVICE_UNAVAILABLE.name, "Quiz service is temporarily unavailable")) + } + + @GetMapping("/game") + fun gameServiceFallback(): ResponseEntity> { + return ResponseEntity + .status(HttpStatus.SERVICE_UNAVAILABLE) + .body(ApiResponse.error(HttpStatus.SERVICE_UNAVAILABLE.name, "Game service is temporarily unavailable")) + } + + @GetMapping("/ws") + fun webSocketServiceFallback(): ResponseEntity> { + return ResponseEntity + .status(HttpStatus.SERVICE_UNAVAILABLE) + .body( + ApiResponse.error( + HttpStatus.SERVICE_UNAVAILABLE.name, + "Game webSocket service is temporarily unavailable" + ) + ) + } + + @GetMapping("/matching") + fun matchingServiceFallback(): ResponseEntity> { + return ResponseEntity + .status(HttpStatus.SERVICE_UNAVAILABLE) + .body(ApiResponse.error(HttpStatus.SERVICE_UNAVAILABLE.name, "Matching service is temporarily unavailable")) + } +} \ No newline at end of file diff --git a/gateway-service/src/main/kotlin/com/grepp/quizy/global/AuthGlobalFilter.kt b/gateway-service/src/main/kotlin/com/grepp/quizy/global/AuthGlobalFilter.kt index 6513ec26..2e8411d2 100644 --- a/gateway-service/src/main/kotlin/com/grepp/quizy/global/AuthGlobalFilter.kt +++ b/gateway-service/src/main/kotlin/com/grepp/quizy/global/AuthGlobalFilter.kt @@ -6,7 +6,7 @@ import com.grepp.quizy.jwt.JwtValidator import com.grepp.quizy.user.RedisTokenRepository import com.grepp.quizy.user.UserId import com.grepp.quizy.user.api.global.util.CookieUtils -import com.grepp.quizy.web.UserClient +import com.grepp.quizy.webclient.UserClient import org.slf4j.LoggerFactory import org.springframework.cloud.gateway.filter.GatewayFilterChain import org.springframework.cloud.gateway.filter.GlobalFilter diff --git a/gateway-service/src/main/kotlin/com/grepp/quizy/webclient/UserClient.kt b/gateway-service/src/main/kotlin/com/grepp/quizy/webclient/UserClient.kt new file mode 100644 index 00000000..9993b5ff --- /dev/null +++ b/gateway-service/src/main/kotlin/com/grepp/quizy/webclient/UserClient.kt @@ -0,0 +1,7 @@ +package com.grepp.quizy.webclient + +import reactor.core.publisher.Mono + +interface UserClient { + fun validateUser(userId: Long): Mono +} \ No newline at end of file diff --git a/gateway-service/src/main/kotlin/com/grepp/quizy/webclient/UserClientImpl.kt b/gateway-service/src/main/kotlin/com/grepp/quizy/webclient/UserClientImpl.kt new file mode 100644 index 00000000..27a624cd --- /dev/null +++ b/gateway-service/src/main/kotlin/com/grepp/quizy/webclient/UserClientImpl.kt @@ -0,0 +1,39 @@ +package com.grepp.quizy.webclient + +import com.grepp.quizy.exception.CustomJwtException +import com.grepp.quizy.user.RedisTokenRepository +import com.grepp.quizy.user.UserId +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono + +@Component +class UserClientImpl( + private val webClient: WebClient, + private val redisTokenRepository: RedisTokenRepository +) : UserClient { + + @Value("\${service.user.url}") + private lateinit var BASE_URL: String + + + override fun validateUser(userId: Long): Mono { + if (redisTokenRepository.isExistUser(UserId(userId))) { + return Mono.just(Unit) + } + + return webClient.get() + .uri("$BASE_URL/api/internal/user/validate/$userId") + .retrieve() + .toEntity(Unit::class.java) + .handle { response, sink -> + when (response.statusCode) { + HttpStatus.OK -> sink.next(Unit) + HttpStatus.UNAUTHORIZED -> sink.error(CustomJwtException.JwtUnknownException) + else -> sink.error(CustomJwtException.NotExistUserException) + } + } + } +} \ No newline at end of file diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index 72a6a5c5..b38c2868 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -4,7 +4,7 @@ management: endpoints: web: exposure: - include: health, info + include: health, info, circuitbreakers springdoc: swagger-ui: use-root-path: true @@ -46,6 +46,46 @@ spring: - "*" allowCredentials: true +resilience4j: + circuitbreaker: + configs: + default: + slidingWindowSize: 10 + failureRateThreshold: 50 + waitDurationInOpenState: 60000 + permittedNumberOfCallsInHalfOpenState: 3 + record-exceptions: + - java.io.IOException + - java.util.concurrent.TimeoutException + instances: + userServiceCircuitBreaker: + baseConfig: default + gameServiceCircuitBreaker: + baseConfig: default + matchingServiceCircuitBreaker: + baseConfig: default + quizServiceCircuitBreaker: + baseConfig: default + wsCircuitBreaker: + baseConfig: default + timelimiter: + configs: + default: + timeoutDuration: 4s + SSE: + timeoutDuration: 86400s # 24시간 또는 적절한 값으로 설정 + instances: + userServiceCircuitBreaker: + baseConfig: default + gameServiceCircuitBreaker: + baseConfig: default + matchingServiceCircuitBreaker: + baseConfig: SSE + quizServiceCircuitBreaker: + baseConfig: default + wsCircuitBreaker: + baseConfig: SSE + --- spring: config: @@ -57,7 +97,23 @@ spring: - id: game uri: http://localhost:8081 predicates: - - Path=/api/game/**, /ws/** + - Path=/api/game/** + filters: + - name: RequestRateLimiter + args: + redis-rate-limiter.replenishRate: 10 + redis-rate-limiter.burstCapacity: 20 + redis-rate-limiter.requestedTokens: 1 + key-resolver: "#{@userKeyResolver}" + - name: CircuitBreaker + args: + name: gameServiceCircuitBreaker + fallbackUri: forward:/fallback/game + + - id: webSocket + uri: http://localhost:8081 + predicates: + - Path=/ws/** filters: - name: RequestRateLimiter args: @@ -65,14 +121,18 @@ spring: redis-rate-limiter.burstCapacity: 20 redis-rate-limiter.requestedTokens: 1 key-resolver: "#{@userKeyResolver}" + - name: CircuitBreaker + args: + name: wsCircuitBreaker + fallbackUri: forward:/fallback/ws - id: matching uri: http://localhost:8082 predicates: - Path=/api/matching/** metadata: - response-timeout: 300000 - connect-timeout: 300000 + response-timeout: 330000 + connect-timeout: 330000 filters: - name: RequestRateLimiter args: @@ -80,6 +140,10 @@ spring: redis-rate-limiter.burstCapacity: 20 redis-rate-limiter.requestedTokens: 1 key-resolver: "#{@userKeyResolver}" + - name: CircuitBreaker + args: + name: matchingServiceCircuitBreaker + fallbackUri: forward:/fallback/matching - id: quiz uri: http://localhost:8083 @@ -92,6 +156,10 @@ spring: redis-rate-limiter.burstCapacity: 20 redis-rate-limiter.requestedTokens: 1 key-resolver: "#{@userKeyResolver}" + - name: CircuitBreaker + args: + name: quizServiceCircuitBreaker + fallbackUri: forward:/fallback/quiz - id: user uri: http://localhost:8085 @@ -104,6 +172,10 @@ spring: redis-rate-limiter.burstCapacity: 20 redis-rate-limiter.requestedTokens: 1 key-resolver: "#{@userKeyResolver}" + - name: CircuitBreaker + args: + name: userServiceCircuitBreaker + fallbackUri: forward:/fallback/user data: redis: @@ -151,7 +223,7 @@ spring: - id: game uri: http://dev-game-service:8080 predicates: - - Path=/api/game/**, /ws/** + - Path=/api/game/** filters: - name: RequestRateLimiter args: @@ -159,14 +231,34 @@ spring: redis-rate-limiter.burstCapacity: 20 redis-rate-limiter.requestedTokens: 1 key-resolver: "#{@userKeyResolver}" + - name: CircuitBreaker + args: + name: gameServiceCircuitBreaker + fallbackUri: forward:/fallback/game + + - id: webSocket + uri: http://dev-game-service:8080 + predicates: + - Path=/ws/** + filters: + - name: RequestRateLimiter + args: + redis-rate-limiter.replenishRate: 10 + redis-rate-limiter.burstCapacity: 20 + redis-rate-limiter.requestedTokens: 1 + key-resolver: "#{@userKeyResolver}" + - name: CircuitBreaker + args: + name: wsCircuitBreaker + fallbackUri: forward:/fallback/ws - id: matching uri: http://dev-matching-service:8080 predicates: - Path=/api/matching/** metadata: - response-timeout: 300000 - connect-timeout: 300000 + response-timeout: 330000 + connect-timeout: 330000 filters: - name: RequestRateLimiter args: @@ -174,6 +266,10 @@ spring: redis-rate-limiter.burstCapacity: 20 redis-rate-limiter.requestedTokens: 1 key-resolver: "#{@userKeyResolver}" + - name: CircuitBreaker + args: + name: matchingServiceCircuitBreaker + fallbackUri: forward:/fallback/matching - id: quiz uri: http://dev-quiz-service:8080 @@ -186,6 +282,10 @@ spring: redis-rate-limiter.burstCapacity: 20 redis-rate-limiter.requestedTokens: 1 key-resolver: "#{@userKeyResolver}" + - name: CircuitBreaker + args: + name: quizServiceCircuitBreaker + fallbackUri: forward:/fallback/quiz - id: user uri: http://dev-user-service:8080 @@ -198,6 +298,10 @@ spring: redis-rate-limiter.burstCapacity: 20 redis-rate-limiter.requestedTokens: 1 key-resolver: "#{@userKeyResolver}" + - name: CircuitBreaker + args: + name: userServiceCircuitBreaker + fallbackUri: forward:/fallback/user kubernetes: discovery: