Skip to content

Commit 176b8b9

Browse files
authored
Merge pull request #74 from prgrms-web-devcourse-final-project/QUZ-122-gateway-rate-limit
[QUZ-122] gateway rate limit, circuit breaker 패턴 적용
2 parents 72fa259 + eb68c43 commit 176b8b9

File tree

9 files changed

+383
-45
lines changed

9 files changed

+383
-45
lines changed

gateway-service/build.gradle.kts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@ dependencies {
1919
implementation("org.springframework.boot:spring-boot-starter-security")
2020
testImplementation("org.springframework.security:spring-security-test")
2121

22-
//redis
23-
implementation("org.springframework.boot:spring-boot-starter-data-redis")
22+
// Redis
23+
implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive")
24+
25+
// Circuit Breaker
26+
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j:3.1.0")
27+
2428
}
2529

2630
tasks.named<BootJar>("bootJar") {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.grepp.quizy.api
2+
3+
import com.grepp.quizy.exception.CustomCircuitBreakerException
4+
import org.springframework.web.bind.annotation.GetMapping
5+
import org.springframework.web.bind.annotation.RequestMapping
6+
import org.springframework.web.bind.annotation.RestController
7+
8+
@RestController
9+
@RequestMapping("/fallback")
10+
class GatewayFallbackController {
11+
@GetMapping("/user")
12+
fun userServiceFallback() {
13+
throw CustomCircuitBreakerException.UserServiceUnavailableException
14+
}
15+
16+
@GetMapping("/quiz")
17+
fun quizServiceFallback() {
18+
throw CustomCircuitBreakerException.QuizServiceUnavailableException
19+
}
20+
21+
@GetMapping("/game")
22+
fun gameServiceFallback() {
23+
throw CustomCircuitBreakerException.GameServiceUnavailableException
24+
}
25+
26+
@GetMapping("/ws")
27+
fun webSocketServiceFallback() {
28+
throw CustomCircuitBreakerException.WsUnavailableException
29+
}
30+
31+
@GetMapping("/matching")
32+
fun matchingServiceFallback() {
33+
throw CustomCircuitBreakerException.MatchingServiceUnavailableException
34+
}
35+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.grepp.quizy.config
2+
3+
import com.grepp.quizy.jwt.JwtProvider
4+
import com.grepp.quizy.user.api.global.util.CookieUtils
5+
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver
6+
import org.springframework.context.annotation.Bean
7+
import org.springframework.context.annotation.Configuration
8+
import org.springframework.http.HttpHeaders
9+
import org.springframework.http.server.reactive.ServerHttpRequest
10+
import reactor.core.publisher.Mono
11+
12+
@Configuration
13+
class RateLimiterConfig(
14+
private val jwtProvider: JwtProvider,
15+
) {
16+
17+
@Bean
18+
fun userKeyResolver(): KeyResolver {
19+
return KeyResolver { exchange ->
20+
// JWT 토큰에서 사용자 ID를 추출하여 rate limit key로 사용
21+
val token = extractToken(exchange.request)
22+
23+
if (token != null) {
24+
try {
25+
val userId = jwtProvider.getUserIdFromToken(token)
26+
Mono.just(userId.value.toString())
27+
} catch (e: Exception) {
28+
// 토큰이 유효하지 않은 경우 IP 주소를 key로 사용
29+
Mono.just(exchange.request.remoteAddress?.address?.hostAddress ?: "anonymous")
30+
}
31+
32+
} else {
33+
// 토큰이 없는 경우 IP 주소를 key로 사용
34+
Mono.just(exchange.request.remoteAddress?.address?.hostAddress ?: "anonymous")
35+
}
36+
}
37+
}
38+
39+
private fun extractToken(request: ServerHttpRequest): String? {
40+
return if (request.headers.containsKey(HttpHeaders.AUTHORIZATION)) {
41+
resolveToken(request)
42+
} else {
43+
CookieUtils.getCookieValue(request, "refreshToken") ?: ""
44+
}
45+
}
46+
47+
private fun resolveToken(request: ServerHttpRequest): String? {
48+
val authHeader = request.headers[HttpHeaders.AUTHORIZATION]?.get(0) ?: ""
49+
return if (authHeader.startsWith("Bearer ")) {
50+
authHeader.substring(7)
51+
} else {
52+
null
53+
}
54+
}
55+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.grepp.quizy.exception
2+
3+
import com.grepp.quizy.common.exception.BaseErrorCode
4+
import com.grepp.quizy.common.exception.ErrorReason
5+
import org.springframework.http.HttpStatus
6+
7+
enum class CircuitBreakerErrorCode(
8+
private val status: Int,
9+
private val errorCode: String,
10+
private val message: String,
11+
) : BaseErrorCode {
12+
13+
GAME_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE.value(), "CIRCUIT_BREAKER_503", "게임 서비스를 이용할 수 없습니다."),
14+
MATCHING_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE.value(), "CIRCUIT_BREAKER_503", "매칭 서비스를 이용할 수 없습니다."),
15+
QUIZ_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE.value(), "CIRCUIT_BREAKER_503", "퀴즈 서비스를 이용할 수 없습니다."),
16+
USER_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE.value(), "CIRCUIT_BREAKER_503", "사용자 서비스를 이용할 수 없습니다."),
17+
WS_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE.value(), "CIRCUIT_BREAKER_503", "웹소켓 서비스를 이용할 수 없습니다."),
18+
;
19+
20+
override val errorReason: ErrorReason
21+
get() = ErrorReason(status, errorCode, message)
22+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.grepp.quizy.exception
2+
3+
import com.grepp.quizy.common.exception.WebException
4+
5+
sealed class CustomCircuitBreakerException(errorCode: CircuitBreakerErrorCode) : WebException(errorCode) {
6+
data object GameServiceUnavailableException :
7+
CustomCircuitBreakerException(CircuitBreakerErrorCode.GAME_UNAVAILABLE) {
8+
private fun readResolve(): Any = GameServiceUnavailableException
9+
10+
val EXCEPTION: CustomCircuitBreakerException = GameServiceUnavailableException
11+
}
12+
13+
data object UserServiceUnavailableException :
14+
CustomCircuitBreakerException(CircuitBreakerErrorCode.USER_UNAVAILABLE) {
15+
private fun readResolve(): Any = UserServiceUnavailableException
16+
17+
val EXCEPTION: CustomCircuitBreakerException = UserServiceUnavailableException
18+
}
19+
20+
data object QuizServiceUnavailableException :
21+
CustomCircuitBreakerException(CircuitBreakerErrorCode.QUIZ_UNAVAILABLE) {
22+
private fun readResolve(): Any = QuizServiceUnavailableException
23+
24+
val EXCEPTION: CustomCircuitBreakerException = QuizServiceUnavailableException
25+
}
26+
27+
data object MatchingServiceUnavailableException :
28+
CustomCircuitBreakerException(CircuitBreakerErrorCode.MATCHING_UNAVAILABLE) {
29+
private fun readResolve(): Any = MatchingServiceUnavailableException
30+
31+
val EXCEPTION: CustomCircuitBreakerException = MatchingServiceUnavailableException
32+
}
33+
34+
data object WsUnavailableException :
35+
CustomCircuitBreakerException(CircuitBreakerErrorCode.WS_UNAVAILABLE) {
36+
private fun readResolve(): Any = WsUnavailableException
37+
38+
val EXCEPTION: CustomCircuitBreakerException = WsUnavailableException
39+
}
40+
}
41+
42+

gateway-service/src/main/kotlin/com/grepp/quizy/global/AuthGlobalFilter.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import com.grepp.quizy.jwt.JwtValidator
66
import com.grepp.quizy.user.RedisTokenRepository
77
import com.grepp.quizy.user.UserId
88
import com.grepp.quizy.user.api.global.util.CookieUtils
9-
import com.grepp.quizy.web.UserClient
9+
import com.grepp.quizy.webclient.UserClient
1010
import org.slf4j.LoggerFactory
1111
import org.springframework.cloud.gateway.filter.GatewayFilterChain
1212
import org.springframework.cloud.gateway.filter.GlobalFilter
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.grepp.quizy.webclient
2+
3+
import reactor.core.publisher.Mono
4+
5+
interface UserClient {
6+
fun validateUser(userId: Long): Mono<Unit>
7+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.grepp.quizy.webclient
2+
3+
import com.grepp.quizy.exception.CustomJwtException
4+
import com.grepp.quizy.user.RedisTokenRepository
5+
import com.grepp.quizy.user.UserId
6+
import org.springframework.beans.factory.annotation.Value
7+
import org.springframework.http.HttpStatus
8+
import org.springframework.stereotype.Component
9+
import org.springframework.web.reactive.function.client.WebClient
10+
import reactor.core.publisher.Mono
11+
12+
@Component
13+
class UserClientImpl(
14+
private val webClient: WebClient,
15+
private val redisTokenRepository: RedisTokenRepository
16+
) : UserClient {
17+
18+
@Value("\${service.user.url}")
19+
private lateinit var BASE_URL: String
20+
21+
22+
override fun validateUser(userId: Long): Mono<Unit> {
23+
if (redisTokenRepository.isExistUser(UserId(userId))) {
24+
return Mono.just(Unit)
25+
}
26+
27+
return webClient.get()
28+
.uri("$BASE_URL/api/internal/user/validate/$userId")
29+
.retrieve()
30+
.toEntity(Unit::class.java)
31+
.handle<Unit> { response, sink ->
32+
when (response.statusCode) {
33+
HttpStatus.OK -> sink.next(Unit)
34+
HttpStatus.UNAUTHORIZED -> sink.error(CustomJwtException.JwtUnknownException)
35+
else -> sink.error(CustomJwtException.NotExistUserException)
36+
}
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)