Skip to content

Commit

Permalink
fix : redis 랭킹 버그 수정 (#150)
Browse files Browse the repository at this point in the history
  • Loading branch information
kshired authored Dec 17, 2023
1 parent af0ca43 commit fd8030e
Show file tree
Hide file tree
Showing 5 changed files with 206 additions and 50 deletions.
19 changes: 14 additions & 5 deletions src/main/kotlin/io/csbroker/apiserver/common/config/RedisConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.data.redis.serializer.StringRedisSerializer
import org.springframework.orm.jpa.JpaTransactionManager
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.annotation.EnableTransactionManagement

@Configuration
@EnableTransactionManagement
class RedisConfig(
@Value("\${spring.redis.host}")
private val host: String,
Expand All @@ -23,11 +27,16 @@ class RedisConfig(

@Bean
fun stringRedisTemplate(): StringRedisTemplate {
val redisTemplate = StringRedisTemplate()
redisTemplate.keySerializer = StringRedisSerializer()
redisTemplate.valueSerializer = StringRedisSerializer()
redisTemplate.setConnectionFactory(redisConnectionFactory())
return StringRedisTemplate().also {
it.keySerializer = StringRedisSerializer()
it.valueSerializer = StringRedisSerializer()
it.connectionFactory = redisConnectionFactory()
it.setEnableTransactionSupport(true)
}
}

return redisTemplate
@Bean
fun transactionManager(): PlatformTransactionManager {
return JpaTransactionManager()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,10 @@ class AdminController(
),
)
}

@PostMapping("/rank/refresh")
fun refreshRank(): ApiResponse<Unit> {
userService.calculateRank()
return ApiResponse.success()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package io.csbroker.apiserver.repository.common
import io.csbroker.apiserver.common.config.properties.AppProperties
import io.csbroker.apiserver.dto.common.RankListDto
import io.csbroker.apiserver.dto.user.RankResultDto
import org.springframework.data.redis.core.RedisOperations
import org.springframework.data.redis.core.SessionCallback
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.data.redis.core.ZSetOperations.TypedTuple
import org.springframework.stereotype.Repository
Expand All @@ -22,8 +24,7 @@ class RedisRepository(
}

fun setRefreshTokenByEmail(email: String, refreshToken: String) {
redisTemplate.opsForValue()
.set(email, refreshToken, appProperties.auth.refreshTokenExpiry, TimeUnit.MILLISECONDS)
redisTemplate.opsForValue().set(email, refreshToken, appProperties.auth.refreshTokenExpiry, TimeUnit.MILLISECONDS)
}

fun setPasswordVerification(code: String, email: String) {
Expand All @@ -38,64 +39,44 @@ class RedisRepository(
redisTemplate.delete(code)
}

@Suppress("UNCHECKED_CAST")
fun setRank(scoreMap: Map<String, Double>) {
redisTemplate.opsForZSet().add(
RANKING,
scoreMap.map { TypedTuple.of(it.key, it.value) }.toSet(),
redisTemplate.execute(
object : SessionCallback<Unit> {
override fun <K : Any?, V : Any?> execute(operations: RedisOperations<K, V>) {
val stringOperations = operations as RedisOperations<String, String>
stringOperations.multi()
stringOperations.delete(RANKING)
stringOperations.opsForZSet().add(
RANKING,
scoreMap.map { TypedTuple.of(it.key, it.value) }.toSet(),
)
stringOperations.exec()
}
},
)
}

fun getRank(key: String): RankResultDto {
var rank: Long? = null

val score = redisTemplate.opsForZSet().score(RANKING, key) ?: 0.0
val rankKey = redisTemplate.opsForZSet().reverseRangeByScore(RANKING, score, score, 0, 1)?.first()
val score = redisTemplate.opsForZSet().score(RANKING, key)
?: return RankResultDto(null, 0.0)

if (rankKey != null) {
rank = redisTemplate.opsForZSet().reverseRank(RANKING, rankKey)?.plus(1)
val rankKey = redisTemplate.opsForZSet().reverseRangeByScore(RANKING, score, score, 0, 1)?.firstOrNull()
val rank = rankKey?.let {
redisTemplate.opsForZSet().reverseRank(RANKING, it)?.plus(1)
}

return RankResultDto(rank, score)
}

fun getRanks(size: Long, page: Long): RankListDto {
val start = size * page
val end = size * (page + 1) - 1
val end = start + size - 1

val keyWithScores = redisTemplate.opsForZSet().reverseRangeWithScores(RANKING, start, end)
val keyWithScores = redisTemplate.opsForZSet().reverseRangeWithScores(RANKING, start, end) ?: emptySet()
val totalElements = redisTemplate.opsForZSet().size(RANKING) ?: 0
val totalPage = if (totalElements % size > 0) totalElements / size + 1 else totalElements / size
val result = mutableListOf<RankListDto.RankDetail>()
var rank = 1L
var isFirst = true

keyWithScores?.let {
it.forEach { keyWithScore ->
if (!isFirst && result.last().score != keyWithScore.score) {
isFirst = true
}

if (isFirst) {
val score = keyWithScore.score!!
val key = redisTemplate.opsForZSet().reverseRangeByScore(RANKING, score, score, 0, 1)!!.first()
rank = redisTemplate.opsForZSet().reverseRank(RANKING, key)!!.plus(1)
isFirst = false
}

val keys = keyWithScore.value!!.split('@')
val id = UUID.fromString(keys[0])
val username = keys[1]

result.add(
RankListDto.RankDetail(
id,
username,
rank,
keyWithScore.score!!,
),
)
}
}
val result = getRankDetails(keyWithScores)

return RankListDto(
size = size,
Expand All @@ -105,4 +86,35 @@ class RedisRepository(
contents = result,
)
}

private fun getRankDetails(keyWithScores: Set<TypedTuple<String>>): List<RankListDto.RankDetail> {
return keyWithScores.fold(emptyList()) { acc, value ->
val (score, id, username) = value.getRankInfo()

acc + if (acc.isEmpty() || acc.last().score != score) {
val key = redisTemplate.opsForZSet().reverseRangeByScore(RANKING, score, score, 0, 1)!!.first()
RankListDto.RankDetail(
id,
username,
redisTemplate.opsForZSet().reverseRank(RANKING, key)!!.plus(1),
score,
)
} else {
RankListDto.RankDetail(
id,
username,
acc.last().rank,
score,
)
}
}
}

private fun TypedTuple<String>.getRankInfo(): Triple<Double, UUID, String> {
val keys = value!!.split('@')
val id = UUID.fromString(keys[0])
val username = keys[1]

return Triple(this.score!!, id, username)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,6 @@ class UserServiceImpl(
user.profileImageUrl = imgUrl
}

@Transactional
@Scheduled(cron = "0 0 * * * *")
override fun calculateRank() {
val userScoreMap = userRepository.findAll().associate {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package io.csbroker.apiserver.repository.common

import io.csbroker.apiserver.common.config.properties.AppProperties
import io.csbroker.apiserver.dto.common.RankListDto
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.data.redis.core.ZSetOperations.TypedTuple
import java.util.UUID

class RedisRepositoryTest {
private lateinit var sut: RedisRepository
private lateinit var redisTemplate: StringRedisTemplate
private val appProperties = AppProperties(
auth = AppProperties.Auth(
tokenExpiry = 1,
refreshTokenExpiry = 1,
tokenSecret = "this is secret",
),
oAuth2 = AppProperties.OAuth2(
authorizedRedirectUris = listOf("http://localhost:3000/oauth2/redirect"),
),
)

@BeforeEach
fun setUp() {
redisTemplate = mockk()
sut = RedisRepository(
redisTemplate = redisTemplate,
appProperties = appProperties,
)
}

@Test
fun `랭킹 조회에 성공한다`() {
// given
val randomUUIDs = (0..6).map { UUID.randomUUID() }
every { redisTemplate.opsForZSet().reverseRangeWithScores(RANKING, 0, 8) } returns setOf(
TypedTuple.of("${randomUUIDs[0]}@username1", 5.0),
TypedTuple.of("${randomUUIDs[1]}@username2", 5.0),
TypedTuple.of("${randomUUIDs[2]}@username3", 4.0),
TypedTuple.of("${randomUUIDs[3]}@username4", 4.0),
TypedTuple.of("${randomUUIDs[4]}@username5", 3.0),
TypedTuple.of("${randomUUIDs[5]}@username6", 2.0),
TypedTuple.of("${randomUUIDs[6]}@username7", 1.0),
)
every { redisTemplate.opsForZSet().size(RANKING) } returns 7
every { redisTemplate.opsForZSet().reverseRangeByScore(RANKING, 5.0, 5.0, 0, 1) } returns setOf(
"${randomUUIDs[0]}@username1",
"${randomUUIDs[1]}@username2",
)
every { redisTemplate.opsForZSet().reverseRank(RANKING, "${randomUUIDs[0]}@username1") } returns 0
every { redisTemplate.opsForZSet().reverseRangeByScore(RANKING, 4.0, 4.0, 0, 1) } returns setOf(
"${randomUUIDs[2]}@username3",
"${randomUUIDs[3]}@username4",
)
every { redisTemplate.opsForZSet().reverseRank(RANKING, "${randomUUIDs[2]}@username3") } returns 2
every { redisTemplate.opsForZSet().reverseRangeByScore(RANKING, 3.0, 3.0, 0, 1) } returns setOf(
"${randomUUIDs[4]}@username5",
)
every { redisTemplate.opsForZSet().reverseRank(RANKING, "${randomUUIDs[4]}@username5") } returns 4
every { redisTemplate.opsForZSet().reverseRangeByScore(RANKING, 2.0, 2.0, 0, 1) } returns setOf(
"${randomUUIDs[5]}@username6",
)
every { redisTemplate.opsForZSet().reverseRank(RANKING, "${randomUUIDs[5]}@username6") } returns 5
every { redisTemplate.opsForZSet().reverseRangeByScore(RANKING, 1.0, 1.0, 0, 1) } returns setOf(
"${randomUUIDs[6]}@username7",
)
every { redisTemplate.opsForZSet().reverseRank(RANKING, "${randomUUIDs[6]}@username7") } returns 6

// when
val result = sut.getRanks(
size = 9,
page = 0,
)

// then
result.size shouldBe 9
result.totalPage shouldBe 1
result.currentPage shouldBe 0
result.numberOfElements shouldBe 7
result.contents shouldBe listOf(
RankListDto.RankDetail(
randomUUIDs[0],
"username1",
1L,
5.0,
),
RankListDto.RankDetail(
randomUUIDs[1],
"username2",
1L,
5.0,
),
RankListDto.RankDetail(
randomUUIDs[2],
"username3",
3L,
4.0,
),
RankListDto.RankDetail(
randomUUIDs[3],
"username4",
3L,
4.0,
),
RankListDto.RankDetail(
randomUUIDs[4],
"username5",
5L,
3.0,
),
RankListDto.RankDetail(
randomUUIDs[5],
"username6",
6L,
2.0,
),
RankListDto.RankDetail(
randomUUIDs[6],
"username7",
7L,
1.0,
),
)
}
}

0 comments on commit fd8030e

Please sign in to comment.