티스토리 뷰
1. Redis와 LuaScript(레디스와 루아스크립트)를 활용한
슬라이딩 윈도우 구현하기
분산 환경에서 5분 동안 특정 요청의 성공률이 50% 미만으로 떨어진 경우, fallback 로직을 수행해야 하는 요구사항이 있다고 하자. 이를 위한 다양한 구현 방식이 존재하는데, 레디스 기반으로 슬라이딩 윈도우를 구현해 해결할 수 있다.
[ 요구사항 구현하기 ]
먼저 현재 시간을 기준으로, 5분 동안의 성공률을 갱신해야 한다. 이를 위해 슬라이딩 윈도우 알고리즘을 사용할 수 있다.
슬라이딩 윈도우 알고리즘을 구현하는 다양한 방법이 있는데, 레디스의 Sorted Set을 사용하면 손쉽게 구현할 수 있다. 이는 다음과 같은 내용들을 바탕으로 구현하면 된다.
- 성공과 실패를 저장하는 Sorted Set을 관리함
- Sorted Set에는 현재 시간 데이터가 저장됨
- Sorted Set에 존재하는 원소의 개수가 성공/실패 횟수에 해당함
Sorted Set에 데이터를 저장하고 개수를 바탕으로 성공률을 계산하는 작업은 하나의 원자성 있는 작업으로 보장되어야 하는데, 이를 위해 루아스크립트(LuaScript)를 활용할 수 있다. 루아스크립트란 Lua 프로그래밍 언어로 작성된 사용자 지정 스크립트로, 복잡한 작업을 레디스 내에서 Atomic 하게 수행해준다.
다음과 같은 로직을 스크립트로 작성할 수 있다.
- 성공/실패 Sorted Set에 현재 시간 데이터를 추가함
- 추가한 데이터의 만료 시간을 설정함
- 성공/실패 Sorted Set에 존재하는 원소의 개수로 성공률을 계산하여 반환함
private fun updateSuccessRateScript(): DefaultRedisScript<Long> {
val luaScript = """
local currentMs = tonumber(ARGV[1])
local maxScoreMs = tonumber(ARGV[2])
local windowInSecond = tonumber(ARGV[3])
local resultKey = KEYS[1]
local successKey = KEYS[2]
local failKey = KEYS[3]
-- 키에 0 ~ (현재 시간 - 슬라이딩 윈도우 초)에 해당하는 요소들을 모두 제거함
redis.call('ZREMRANGEBYSCORE', resultKey, '-inf', maxScoreMs)
-- 키에 현재 시간 요소를 추가함
redis.call('ZADD', resultKey, currentMs, currentMs .. "-" .. math.random())
-- 키에 만료 시간을 설정함
redis.call('EXPIRE', resultKey, windowInSecond)
-- 성공 횟수와 실패 횟수를 가지고 비율을 계산하여 성공률을 반환함
local successCount = redis.call('ZCARD', successKey)
local failCount = redis.call('ZCARD', failKey)
local totalCards = successCount + failCount
local successRate = 0
if totalCards > 0 then
successRate = successCount / totalCards
end
return successRate * 100
""".trimIndent()
return createScript(luaScript)
}
private fun createScript(luaScript: String): DefaultRedisScript<Long> {
val script = DefaultRedisScript<Long>()
script.setScriptText(luaScript)
script.resultType = Long::class.java
return script
}
해당 스크립트의 호출은 redisTemplate을 통해 가능하다. 여기서 주의할 점은 루아스크립트의 인자로 전달할 key를 생성하는 부분인데, key에 따라 동일한 slot으로 요청이 전달되도록 해야 한다. 루아스크립트는 slot을 지정하는 hashkey를 ${}로 지정해줄 수 있다. 만약 하나의 요청이 동일한 slot으로 전달되지 않는다면 예외가 발생할 것이므로 key를 반드시 지정해주어야 한다.
fun updateSuccessRate(
key: TargetServer,
currentTime: Long,
boolean isSuccess,
windowInSecond: Int = WINDOWS_IN_SECOND,
): Long {
// LuaScript에서 동일한 slot으로 요청이 가도록 해야 함
// slot을 지정하는 hashkey를 ${}로 지정해줄 수 있어서 설정해줌
val successKey = "{${key.code}}:success"
val failKey = "{${key.code}}:fail"
val maxScoreMs: Long = currentTime - (windowInSecond * 1000)
return redisTemplate.execute(
updateSuccessRateScript(),
createKeys(isSuccess, successKey, failKey),
currentTime.toString(),
maxScoreMs.toString(),
windowInSecond.toString()
)
}
private fun createKeys(isSuccess: Boolean, successKey: String, failKey: String): List<String> {
return when (isSuccess) {
true -> listOf(successKey, successKey, failKey)
false -> listOf(failKey, successKey, failKey)
}
}
위의 스크립트는 여러 복잡한 상황들을 가정하지 않았다. 대표적으로 서로 다른 서버에서 동일한 currentTime으로 요청이 들어온 경우, 하나의 데이터로 취급된다. 이를 위해서는 현재 시간 + 요청온 사용자의 ID를 해싱한 값을 저장하는 방식으로 처리하여 개선할 수 있다.
위의 코드를 큰 배경으로 삼고, 필요한 케이스에 맞게 확장시키면 될 것이다.
'Server' 카테고리의 다른 글
[Server] 진짜 중복과 가짜 중복의 구분(중복 여부를 판단하는 기준) (4) | 2024.10.22 |
---|---|
[Server] 비즈니스 정책과 입력 데이터, 서로 다른 데이터 검증 및 유효성 검사(Validation) (7) | 2024.10.01 |
[Redis] 레디스가 제공하는 분산락(RedLock)의 특징과 한계 (7) | 2023.10.10 |
[Infra] 서비스 메시(Service Mesh)의 등장과 쿠버네티스(Kubernetes)의 SidecarContainers (2) | 2023.10.03 |
[Server] 멀티 모듈을 설계하는 관점과 고려사항 with Spring & Gradle (5) | 2023.08.29 |