From 83a1316a647f1159bb4308fb0de8ab40a814aa2a Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Sep 2025 18:06:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(character):=20UTC=2020=EC=8B=9C=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EA=B8=B0=EB=B0=98=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=A7=91=EA=B3=84=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EC=BA=90=EC=8B=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 집계 기준을 "채팅방 전체 메시지 수"로 변경하여 캐릭터별 인기 순위 산정 - 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 부하 최소화, 경계 전환 후 자연 무효화 --- .editorconfig | 2 +- .../controller/ChatCharacterController.kt | 2 +- .../character/service/ChatCharacterService.kt | 26 ++++++--- .../service/PopularCharacterQuery.kt | 54 +++++++++++++++++++ .../service/RankingWindowCalculator.kt | 37 +++++++++++++ .../vividnext/sodalive/configs/RedisConfig.kt | 10 ++++ 6 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt diff --git a/.editorconfig b/.editorconfig index ebb76ef..bb0fdf4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,5 +7,5 @@ indent_size = 4 indent_style = space trim_trailing_whitespace = true insert_final_newline = true -max_line_length = 120 +max_line_length = 130 tab_width = 4 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 930a9a4..65b551f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -64,7 +64,7 @@ class ChatCharacterController( } } - // 인기 캐릭터 조회 (현재는 빈 리스트) + // 인기 캐릭터 조회 val popularCharacters = service.getPopularCharacters() .map { Character( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index c052972..84422d5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -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.ChatCharacterTagRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository +import org.springframework.cache.annotation.Cacheable import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -26,16 +27,29 @@ class ChatCharacterService( private val tagRepository: ChatCharacterTagRepository, private val valueRepository: ChatCharacterValueRepository, 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) - fun getPopularCharacters(): List { - // 채팅방 구현 전이므로 빈 리스트 반환 - return emptyList() + @Cacheable( + cacheNames = ["popularCharacters_24h"], + key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-chat-character').cacheKey" + ) + fun getPopularCharacters(limit: Long = 20): List { + val window = RankingWindowCalculator.now("popular-chat-character") + val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit) + return loadCharactersInOrder(topIds) + } + + private fun loadCharactersInOrder(ids: List): List { + if (ids.isEmpty()) return emptyList() + val list = chatCharacterRepository.findAllById(ids) + val map = list.associateBy { it.id } + return ids.mapNotNull { map[it] } } /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt new file mode 100644 index 0000000..ea542f7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt @@ -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 { + 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() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt new file mode 100644 index 0000000..0767e7d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt @@ -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) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt index 04a6c2c..eea5eab 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt @@ -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) .cacheDefaults(defaultCacheConfig) .withInitialCacheConfigurations(cacheConfigMap)