feat(character): UTC 20시 경계 기반 인기 캐릭터 집계 구현 및 캐시 적용
- 집계 기준을 "채팅방 전체 메시지 수"로 변경하여 캐릭터별 인기 순위 산정 - Querydsl `PopularCharacterQuery` 추가: chat_message → chat_participant(CHARACTER) → chat_character 조인 - 시간 경계: UTC 20:00 기준 [windowStart, nextBoundary) 구간 사용(배타적 종료 `<`) - `ChatCharacterService.getPopularCharacters`에 @Cacheable 적용 - cacheNames: `popularCharacters_24h` - key: `RankingWindowCalculator.now('popular-chat-character').cacheKey` - 상위 20개 기본, `loadCharactersInOrder`로 랭킹 순서 보존 - `RankingWindowCalculator`: 경계별 동적 키 생성(`popular-chat-character:{windowStartEpoch}`) 및 윈도우 계산 - `RedisConfig`: 24시간 TTL 캐시 `popularCharacters_24h` 추가(문자열/JSON 직렬화 지정) - `ChatCharacterController`: 메인 API에 인기 캐릭터 섹션 연동 WHY - 20시(UTC) 경계 변경 시 키가 달라져 첫 조회에서 자동 재집계/재캐싱 - 방 전체 참여도를 반영해 보다 직관적인 인기 지표 제공 - 캐시(24h TTL)로 DB 부하 최소화, 경계 전환 후 자연 무효화
This commit is contained in:
parent
f05f146c89
commit
83a1316a64
|
@ -7,5 +7,5 @@ indent_size = 4
|
||||||
indent_style = space
|
indent_style = space
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
max_line_length = 120
|
max_line_length = 130
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
|
|
|
@ -64,7 +64,7 @@ class ChatCharacterController(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인기 캐릭터 조회 (현재는 빈 리스트)
|
// 인기 캐릭터 조회
|
||||||
val popularCharacters = service.getPopularCharacters()
|
val popularCharacters = service.getPopularCharacters()
|
||||||
.map {
|
.map {
|
||||||
Character(
|
Character(
|
||||||
|
|
|
@ -16,6 +16,7 @@ import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepo
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository
|
||||||
|
import org.springframework.cache.annotation.Cacheable
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
@ -26,16 +27,29 @@ class ChatCharacterService(
|
||||||
private val tagRepository: ChatCharacterTagRepository,
|
private val tagRepository: ChatCharacterTagRepository,
|
||||||
private val valueRepository: ChatCharacterValueRepository,
|
private val valueRepository: ChatCharacterValueRepository,
|
||||||
private val hobbyRepository: ChatCharacterHobbyRepository,
|
private val hobbyRepository: ChatCharacterHobbyRepository,
|
||||||
private val goalRepository: ChatCharacterGoalRepository
|
private val goalRepository: ChatCharacterGoalRepository,
|
||||||
|
private val popularCharacterQuery: PopularCharacterQuery
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* 일주일간 대화가 가장 많은 인기 캐릭터 목록 조회
|
* UTC 20:00 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회
|
||||||
* 현재는 채팅방 구현 전이므로 빈 리스트 반환
|
* Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getPopularCharacters(): List<ChatCharacter> {
|
@Cacheable(
|
||||||
// 채팅방 구현 전이므로 빈 리스트 반환
|
cacheNames = ["popularCharacters_24h"],
|
||||||
return emptyList()
|
key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-chat-character').cacheKey"
|
||||||
|
)
|
||||||
|
fun getPopularCharacters(limit: Long = 20): List<ChatCharacter> {
|
||||||
|
val window = RankingWindowCalculator.now("popular-chat-character")
|
||||||
|
val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit)
|
||||||
|
return loadCharactersInOrder(topIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCharactersInOrder(ids: List<Long>): List<ChatCharacter> {
|
||||||
|
if (ids.isEmpty()) return emptyList()
|
||||||
|
val list = chatCharacterRepository.findAllById(ids)
|
||||||
|
val map = list.associateBy { it.id }
|
||||||
|
return ids.mapNotNull { map[it] }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
package kr.co.vividnext.sodalive.chat.character.service
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.QChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.ParticipantType
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.QChatMessage
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.QChatParticipant
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class PopularCharacterQuery(
|
||||||
|
private val queryFactory: JPAQueryFactory
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 집계 기준: "채팅방 전체 메시지 수"로 캐릭터 인기 집계
|
||||||
|
* - 메시지 작성자(pMsg)가 누가 되었든 해당 방의 소유 캐릭터(p=CHARACTER)의 id로 그룹핑
|
||||||
|
* - 시간 종료 경계는 배타적(<) 비교로 단순화
|
||||||
|
*/
|
||||||
|
fun findPopularCharacterIds(
|
||||||
|
windowStart: Instant,
|
||||||
|
endExclusive: Instant,
|
||||||
|
limit: Long
|
||||||
|
): List<Long> {
|
||||||
|
val m = QChatMessage.chatMessage
|
||||||
|
val p = QChatParticipant.chatParticipant
|
||||||
|
val c = QChatCharacter.chatCharacter
|
||||||
|
|
||||||
|
val start = LocalDateTime.ofInstant(windowStart, ZoneOffset.UTC)
|
||||||
|
val end = LocalDateTime.ofInstant(endExclusive, ZoneOffset.UTC)
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(c.id)
|
||||||
|
.from(m)
|
||||||
|
// 방의 캐릭터 소유자 참가자(p=CHARACTER)를 통해 캐릭터 기준으로 그룹핑
|
||||||
|
.join(p).on(
|
||||||
|
p.chatRoom.id.eq(m.chatRoom.id)
|
||||||
|
.and(p.participantType.eq(ParticipantType.CHARACTER))
|
||||||
|
)
|
||||||
|
.join(c).on(c.id.eq(p.character.id))
|
||||||
|
.where(
|
||||||
|
m.createdAt.goe(start)
|
||||||
|
.and(m.createdAt.lt(end)) // 배타적 종료
|
||||||
|
.and(m.isActive.isTrue)
|
||||||
|
.and(c.isActive.isTrue)
|
||||||
|
)
|
||||||
|
.groupBy(c.id)
|
||||||
|
.orderBy(m.id.count().desc())
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
package kr.co.vividnext.sodalive.chat.character.service
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTC 20:00:00을 경계로 집계 윈도우와 캐시 키를 계산한다.
|
||||||
|
*/
|
||||||
|
data class RankingWindow(
|
||||||
|
val windowStart: Instant,
|
||||||
|
val windowEnd: Instant,
|
||||||
|
val nextBoundary: Instant,
|
||||||
|
val cacheKey: String
|
||||||
|
)
|
||||||
|
|
||||||
|
object RankingWindowCalculator {
|
||||||
|
private val ZONE: ZoneId = ZoneOffset.UTC
|
||||||
|
private const val BOUNDARY_HOUR = 20 // 20:00:00 UTC
|
||||||
|
|
||||||
|
fun now(prefix: String = "popular-chat-character"): RankingWindow {
|
||||||
|
val now = ZonedDateTime.now(ZONE)
|
||||||
|
val todayBoundary = now.toLocalDate().atTime(BOUNDARY_HOUR, 0, 0).atZone(ZONE)
|
||||||
|
val (start, endExclusive, nextBoundary) = if (now.isBefore(todayBoundary)) {
|
||||||
|
val start = todayBoundary.minusDays(1)
|
||||||
|
Triple(start, todayBoundary, todayBoundary)
|
||||||
|
} else {
|
||||||
|
val next = todayBoundary.plusDays(1)
|
||||||
|
Triple(todayBoundary, next, next)
|
||||||
|
}
|
||||||
|
val windowStart = start.toInstant()
|
||||||
|
val windowEnd = endExclusive.minusNanos(1).toInstant() // [start, end]
|
||||||
|
val cacheKey = "$prefix:${windowStart.epochSecond}"
|
||||||
|
return RankingWindow(windowStart, windowEnd, nextBoundary.toInstant(), cacheKey)
|
||||||
|
}
|
||||||
|
}
|
|
@ -123,6 +123,16 @@ class RedisConfig(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 24시간 TTL 캐시: 인기 캐릭터 집계용
|
||||||
|
cacheConfigMap["popularCharacters_24h"] = RedisCacheConfiguration.defaultCacheConfig()
|
||||||
|
.entryTtl(Duration.ofHours(24))
|
||||||
|
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
|
||||||
|
.serializeValuesWith(
|
||||||
|
RedisSerializationContext.SerializationPair.fromSerializer(
|
||||||
|
GenericJackson2JsonRedisSerializer()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return RedisCacheManager.builder(redisConnectionFactory)
|
return RedisCacheManager.builder(redisConnectionFactory)
|
||||||
.cacheDefaults(defaultCacheConfig)
|
.cacheDefaults(defaultCacheConfig)
|
||||||
.withInitialCacheConfigurations(cacheConfigMap)
|
.withInitialCacheConfigurations(cacheConfigMap)
|
||||||
|
|
Loading…
Reference in New Issue