Skip to content

Commit 0a3e9d5

Browse files
authored
feat: Redis 랭킹 정보를 1시간 단위로 DB에 기록하는 스케줄러 구현 (#285)
* feat: VoteRanking 엔티티 추가 (#284) * feat: Redis 캐시된 투표 랭킹 정보를 정시마다 RDB에 저장하는 스케줄러 구현 (#284)
1 parent af8fa55 commit 0a3e9d5

File tree

4 files changed

+146
-0
lines changed

4 files changed

+146
-0
lines changed

src/main/java/com/moa/moa_server/domain/group/repository/GroupRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.util.List;
66
import java.util.Optional;
77
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Query;
89

910
public interface GroupRepository extends JpaRepository<Group, Long> {
1011
Optional<Group> findByInviteCode(String inviteCode);
@@ -14,4 +15,7 @@ public interface GroupRepository extends JpaRepository<Group, Long> {
1415
boolean existsByName(String groupName);
1516

1617
List<Group> findAllByUser(User user);
18+
19+
@Query("select g.id from Group g")
20+
List<Long> findAllGroupIds();
1721
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.moa.moa_server.domain.ranking.entity;
2+
3+
import com.moa.moa_server.domain.global.entity.BaseTimeEntity;
4+
import jakarta.persistence.*;
5+
import java.time.LocalDateTime;
6+
import lombok.*;
7+
8+
@Entity
9+
@Getter
10+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
11+
@AllArgsConstructor
12+
@Builder
13+
@Table(
14+
name = "vote_ranking",
15+
uniqueConstraints = @UniqueConstraint(columnNames = {"group_id", "ranked_at", "rank"}))
16+
public class VoteRanking extends BaseTimeEntity {
17+
18+
@Id
19+
@GeneratedValue(strategy = GenerationType.IDENTITY)
20+
private Long id;
21+
22+
@Column(name = "vote_id", nullable = false)
23+
private Long voteId;
24+
25+
@Column(name = "group_id", nullable = false)
26+
private Long groupId;
27+
28+
@Column(nullable = false)
29+
private int rank;
30+
31+
@Column(name = "ranked_at", nullable = false)
32+
private LocalDateTime rankedAt;
33+
34+
public static VoteRanking of(Long voteId, Long groupId, int rank, LocalDateTime rankedAt) {
35+
return VoteRanking.builder()
36+
.voteId(voteId)
37+
.groupId(groupId)
38+
.rank(rank)
39+
.rankedAt(rankedAt)
40+
.build();
41+
}
42+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.moa.moa_server.domain.ranking.repository;
2+
3+
import com.moa.moa_server.domain.ranking.entity.VoteRanking;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
6+
public interface VoteRankingRepository extends JpaRepository<VoteRanking, Long> {}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.moa.moa_server.domain.ranking.scheduler;
2+
3+
import com.moa.moa_server.domain.group.repository.GroupRepository;
4+
import com.moa.moa_server.domain.ranking.entity.VoteRanking;
5+
import com.moa.moa_server.domain.ranking.repository.VoteRankingRepository;
6+
import java.time.LocalDate;
7+
import java.time.LocalDateTime;
8+
import java.time.ZoneOffset;
9+
import java.time.format.DateTimeFormatter;
10+
import java.util.ArrayList;
11+
import java.util.Collections;
12+
import java.util.List;
13+
import java.util.Set;
14+
import lombok.RequiredArgsConstructor;
15+
import lombok.extern.slf4j.Slf4j;
16+
import org.springframework.data.redis.core.StringRedisTemplate;
17+
import org.springframework.data.redis.core.ZSetOperations;
18+
import org.springframework.scheduling.annotation.Scheduled;
19+
import org.springframework.stereotype.Component;
20+
import org.springframework.transaction.annotation.Transactional;
21+
22+
@Slf4j
23+
@Component
24+
@RequiredArgsConstructor
25+
public class VoteRankingWriteScheduler {
26+
27+
private final StringRedisTemplate redisTemplate;
28+
private final VoteRankingRepository voteRankingRepository;
29+
private final GroupRepository groupRepository;
30+
31+
@Transactional
32+
@Scheduled(cron = "0 0 * * * *") // 매 정시
33+
public void persistVoteRankings() {
34+
LocalDateTime rankedAt = LocalDateTime.now(ZoneOffset.UTC); // 저장 시각
35+
String dateKey =
36+
LocalDate.now(ZoneOffset.UTC).format(DateTimeFormatter.BASIC_ISO_DATE); // Redis 키 날짜
37+
List<VoteRanking> rankingsToSave = collectTopRankings(rankedAt, dateKey);
38+
39+
try {
40+
voteRankingRepository.saveAll(rankingsToSave);
41+
log.info("[VoteRankingWriteScheduler] 저장 완료 - 총 {}건", rankingsToSave.size());
42+
} catch (Exception e) {
43+
log.error(
44+
"[VoteRankingWriteScheduler] 저장 실패 - 저장 대상 {}건, reason={}",
45+
rankingsToSave.size(),
46+
e.getMessage(),
47+
e);
48+
}
49+
}
50+
51+
/** 모든 그룹에 대해 Redis에서 Top3 랭킹을 조회하고, DB 저장용 VoteRanking 리스트를 생성. */
52+
private List<VoteRanking> collectTopRankings(LocalDateTime rankedAt, String dateKey) {
53+
List<Long> groupIds = groupRepository.findAllGroupIds();
54+
List<VoteRanking> result = new ArrayList<>();
55+
56+
log.info("[VoteRankingWriteScheduler] 시작 - {}개 그룹 대상 랭킹 기록", groupIds.size());
57+
58+
// 각 그룹에 대해 Top3 랭킹 데이터 조회해 VoteRanking 객체로 반환
59+
for (Long groupId : groupIds) {
60+
result.addAll(buildRankingsFromRedis(groupId, rankedAt, dateKey));
61+
}
62+
63+
return result;
64+
}
65+
66+
/** Redis에서 특정 그룹의 Top3 투표를 조회하여, DB 저장용 VoteRanking 리스트로 변환. */
67+
private List<VoteRanking> buildRankingsFromRedis(
68+
Long groupId, LocalDateTime rankedAt, String dateKey) {
69+
// Redis key 생성: ranking:{groupId}:{yyyyMMdd}
70+
String key = "ranking:" + groupId + ":" + dateKey;
71+
72+
// 해당 그룹의 Redis Sorted Set에서 Top3 투표 ID 조회
73+
Set<ZSetOperations.TypedTuple<String>> topVotes =
74+
redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, 2);
75+
76+
if (topVotes == null || topVotes.isEmpty()) return Collections.emptyList();
77+
78+
List<VoteRanking> rankings = new ArrayList<>();
79+
int rank = 1;
80+
81+
for (ZSetOperations.TypedTuple<String> tuple : topVotes) {
82+
String voteIdStr = tuple.getValue();
83+
if (voteIdStr == null) {
84+
log.warn("[VoteRankingWriteScheduler] 랭킹 키={}에 null인 voteId가 포함되어 있어 건너뜁니다.", key);
85+
continue;
86+
}
87+
88+
VoteRanking ranking = VoteRanking.of(Long.parseLong(voteIdStr), groupId, rank++, rankedAt);
89+
rankings.add(ranking);
90+
}
91+
92+
return rankings;
93+
}
94+
}

0 commit comments

Comments
 (0)