Skip to content

Commit 050c39d

Browse files
authored
Feat: [Crew] 크루 랭킹 레디스 도입 및 레디스 오류 수정 (#68)
* Feat: [Crew] 크루 랭킹 레디스 도입 및 레디스 오류 수정 * Feat: [Crew] 크루 맴버 랭킹 N+1 해결
1 parent cf3cd84 commit 050c39d

6 files changed

Lines changed: 201 additions & 13 deletions

File tree

runtracker/src/main/java/com/runtracker/domain/course/dto/CourseDetailDTO.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22

33
import com.runtracker.domain.course.enums.Difficulty;
44
import com.runtracker.global.vo.Coordinate;
5+
import lombok.AllArgsConstructor;
56
import lombok.Builder;
67
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
79

810
import java.time.LocalDateTime;
911
import java.util.List;
1012

1113
@Getter
1214
@Builder
15+
@NoArgsConstructor
16+
@AllArgsConstructor
1317
public class CourseDetailDTO {
1418
private Long id;
1519
private Long memberId;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.runtracker.domain.crew.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
6+
@Getter
7+
@AllArgsConstructor
8+
public class CrewRankingCacheDTO {
9+
private final Double totalDistance;
10+
private final Integer totalRunningTime;
11+
}

runtracker/src/main/java/com/runtracker/domain/crew/service/CrewMemberRankingService.java

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,19 @@ private List<CrewMemberRanking> findExistingMemberRankings(Long crewId, LocalDat
123123
}
124124

125125
private CrewMemberRankingDTO.Response buildMemberRankingResponse(Long crewId, LocalDate date, List<CrewMemberRanking> rankings) {
126+
List<Long> memberIds = rankings.stream()
127+
.map(CrewMemberRanking::getMemberId)
128+
.toList();
129+
130+
Map<Long, Member> memberMap = memberRepository.findAllById(memberIds).stream()
131+
.collect(Collectors.toMap(Member::getId, member -> member));
132+
126133
List<CrewMemberRankingDTO.MemberRankInfo> rankInfos = rankings.stream()
127-
.map(this::convertToMemberRankInfo)
134+
.map(ranking -> convertToMemberRankInfo(ranking, memberMap))
128135
.toList();
129136

130137
Optional<Crew> crew = crewRepository.findById(crewId);
131-
LocalDateTime lastUpdated = rankings.isEmpty() ? LocalDateTime.now() :
138+
LocalDateTime lastUpdated = rankings.isEmpty() ? LocalDateTime.now() :
132139
rankings.stream()
133140
.map(CrewMemberRanking::getUpdatedAt)
134141
.max(LocalDateTime::compareTo)
@@ -188,8 +195,9 @@ private void validateCrewExists(Long crewId) {
188195
}
189196
}
190197

191-
private CrewMemberRankingDTO.MemberRankInfo convertToMemberRankInfo(CrewMemberRanking ranking) {
192-
Optional<Member> member = memberRepository.findById(ranking.getMemberId());
198+
private CrewMemberRankingDTO.MemberRankInfo convertToMemberRankInfo(
199+
CrewMemberRanking ranking, Map<Long, Member> memberMap) {
200+
Member member = memberMap.get(ranking.getMemberId());
193201

194202
double averageDistance = ranking.getParticipationCount() > 0 ?
195203
ranking.getTotalDistance() / ranking.getParticipationCount() : 0.0;
@@ -198,8 +206,8 @@ private CrewMemberRankingDTO.MemberRankInfo convertToMemberRankInfo(CrewMemberRa
198206

199207
return CrewMemberRankingDTO.MemberRankInfo.builder()
200208
.memberId(ranking.getMemberId())
201-
.memberName(member.map(Member::getName).orElse("Unknown"))
202-
.memberPhoto(member.map(Member::getPhoto).orElse(null))
209+
.memberName(member != null ? member.getName() : "Unknown")
210+
.memberPhoto(member != null ? member.getPhoto() : null)
203211
.rank(ranking.getRankPosition())
204212
.totalDistance(ranking.getTotalDistance())
205213
.totalRunningTime(ranking.getTotalRunningTime())
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package com.runtracker.domain.crew.service;
2+
3+
import com.runtracker.domain.crew.dto.CrewRankingCacheDTO;
4+
import com.runtracker.domain.crew.entity.CrewRanking;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.data.redis.core.RedisTemplate;
8+
import org.springframework.data.redis.core.ZSetOperations;
9+
import org.springframework.stereotype.Service;
10+
11+
import java.time.LocalDate;
12+
import java.util.*;
13+
import java.util.concurrent.TimeUnit;
14+
15+
@Slf4j
16+
@Service
17+
@RequiredArgsConstructor
18+
public class CrewRankingCacheService {
19+
20+
private final RedisTemplate<String, String> redisTemplate;
21+
22+
private static final String RANKING_KEY_PREFIX = "crew:ranking:";
23+
private static final long CACHE_TTL_DAYS = 1;
24+
25+
/**
26+
* 랭킹 데이터를 Redis에 저장
27+
*/
28+
public void saveRankingToCache(LocalDate date, List<CrewRanking> rankings) {
29+
String key = getRankingKey(date);
30+
31+
try {
32+
redisTemplate.delete(key);
33+
34+
ZSetOperations<String, String> zSetOps = redisTemplate.opsForZSet();
35+
36+
for (CrewRanking ranking : rankings) {
37+
String member = ranking.getCrewId() + ":" + ranking.getTotalRunningTime();
38+
zSetOps.add(key, member, ranking.getTotalDistance());
39+
}
40+
41+
redisTemplate.expire(key, CACHE_TTL_DAYS, TimeUnit.DAYS);
42+
} catch (Exception e) {
43+
log.error("Failed to save crew ranking to cache: date={}", date, e);
44+
}
45+
}
46+
47+
/**
48+
* Redis에서 랭킹 데이터 조회
49+
*/
50+
public Map<Long, CrewRankingCacheDTO> getRankingFromCache(LocalDate date) {
51+
String key = getRankingKey(date);
52+
53+
try {
54+
ZSetOperations<String, String> zSetOps = redisTemplate.opsForZSet();
55+
56+
Set<ZSetOperations.TypedTuple<String>> rankingsWithScores =
57+
zSetOps.reverseRangeWithScores(key, 0, -1);
58+
59+
if (rankingsWithScores == null || rankingsWithScores.isEmpty()) {
60+
log.debug("No ranking cache found for date: {}", date);
61+
return null;
62+
}
63+
64+
Map<Long, CrewRankingCacheDTO> result = new LinkedHashMap<>();
65+
66+
for (ZSetOperations.TypedTuple<String> tuple : rankingsWithScores) {
67+
String member = tuple.getValue();
68+
Double score = tuple.getScore();
69+
70+
if (member != null && score != null) {
71+
String[] parts = member.split(":");
72+
Long crewId = Long.parseLong(parts[0]);
73+
Integer totalRunningTime = Integer.parseInt(parts[1]);
74+
75+
result.put(crewId, new CrewRankingCacheDTO(score, totalRunningTime));
76+
}
77+
}
78+
79+
return result;
80+
81+
} catch (Exception e) {
82+
log.error("Failed to get crew ranking from cache: date={}", date, e);
83+
return null;
84+
}
85+
}
86+
87+
/**
88+
* 캐시 무효화
89+
*/
90+
public void invalidateCache(LocalDate date) {
91+
String key = getRankingKey(date);
92+
redisTemplate.delete(key);
93+
}
94+
95+
/**
96+
* Redis 키 생성
97+
*/
98+
private String getRankingKey(LocalDate date) {
99+
return RANKING_KEY_PREFIX + date.toString();
100+
}
101+
}

runtracker/src/main/java/com/runtracker/domain/crew/service/CrewRankingService.java

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.runtracker.domain.crew.service;
22

3+
import com.runtracker.domain.crew.dto.CrewRankingCacheDTO;
34
import com.runtracker.domain.crew.dto.CrewRankingDTO;
45
import com.runtracker.domain.crew.dto.CrewRankingData;
56
import com.runtracker.domain.crew.entity.Crew;
@@ -28,26 +29,44 @@ public class CrewRankingService {
2829
private final CrewRepository crewRepository;
2930
private final CrewMemberRepository crewMemberRepository;
3031
private final RecordRepository recordRepository;
32+
private final CrewRankingCacheService cacheService;
3133

3234
/**
33-
* 랭킹 조회
35+
* 랭킹 조회 (Cache-Aside 패턴)
3436
*/
3537
public CrewRankingDTO.Response getDailyRanking(LocalDate date) {
38+
Map<Long, CrewRankingCacheDTO> cachedRanking = cacheService.getRankingFromCache(date);
39+
40+
if (cachedRanking != null && !cachedRanking.isEmpty()) {
41+
return buildResponseFromCache(date, cachedRanking);
42+
}
43+
3644
List<CrewRanking> rankings = findExistingRankings(date);
37-
45+
3846
if (rankings.isEmpty()) {
3947
rankingCalculation(date);
4048
rankings = findExistingRankings(date);
4149
}
42-
50+
51+
if (!rankings.isEmpty()) {
52+
cacheService.saveRankingToCache(date, rankings);
53+
}
54+
4355
return buildResponse(date, rankings);
4456
}
4557

4658
/**
4759
* 랭킹 강제 재계산
4860
*/
4961
public void recalculateRanking(LocalDate date) {
62+
cacheService.invalidateCache(date);
63+
5064
rankingCalculation(date);
65+
66+
List<CrewRanking> rankings = findExistingRankings(date);
67+
if (!rankings.isEmpty()) {
68+
cacheService.saveRankingToCache(date, rankings);
69+
}
5170
}
5271

5372
/**
@@ -98,12 +117,46 @@ private List<CrewRanking> findExistingRankings(LocalDate date) {
98117
return crewRankingRepository.findByDateOrderByRankPosition(date);
99118
}
100119

120+
/**
121+
* Redis 캐시 데이터로 Response 생성
122+
*/
123+
private CrewRankingDTO.Response buildResponseFromCache(LocalDate date,
124+
Map<Long, CrewRankingCacheDTO> cachedRanking) {
125+
List<Long> crewIds = new ArrayList<>(cachedRanking.keySet());
126+
127+
Map<Long, Crew> crewMap = crewRepository.findAllById(crewIds).stream()
128+
.collect(Collectors.toMap(Crew::getId, crew -> crew));
129+
130+
List<CrewRankingDTO.CrewRankInfo> rankInfos = new ArrayList<>();
131+
int rank = 1;
132+
133+
for (Long crewId : crewIds) {
134+
CrewRankingCacheDTO data = cachedRanking.get(crewId);
135+
Crew crew = crewMap.get(crewId);
136+
137+
rankInfos.add(CrewRankingDTO.CrewRankInfo.builder()
138+
.crewId(crewId)
139+
.crewName(crew != null ? crew.getTitle() : "Unknown")
140+
.crewPhoto(crew != null ? crew.getPhoto() : null)
141+
.totalDistance(data.getTotalDistance())
142+
.totalRunningTime(data.getTotalRunningTime())
143+
.rank(rank++)
144+
.build());
145+
}
146+
147+
return CrewRankingDTO.Response.builder()
148+
.date(date)
149+
.rankings(rankInfos)
150+
.lastUpdated(LocalDateTime.now())
151+
.build();
152+
}
153+
101154
private CrewRankingDTO.Response buildResponse(LocalDate date, List<CrewRanking> rankings) {
102155
List<CrewRankingDTO.CrewRankInfo> rankInfos = rankings.stream()
103156
.map(this::convertToRankInfo)
104157
.toList();
105158

106-
LocalDateTime lastUpdated = rankings.isEmpty() ? LocalDateTime.now() :
159+
LocalDateTime lastUpdated = rankings.isEmpty() ? LocalDateTime.now() :
107160
rankings.stream()
108161
.map(CrewRanking::getUpdatedAt)
109162
.max(LocalDateTime::compareTo)

runtracker/src/main/java/com/runtracker/global/config/RedisConfig.java

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package com.runtracker.global.config;
22

3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.fasterxml.jackson.databind.SerializationFeature;
5+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
36
import org.springframework.beans.factory.annotation.Value;
47
import org.springframework.context.annotation.Bean;
58
import org.springframework.context.annotation.Configuration;
@@ -20,7 +23,9 @@ public class RedisConfig {
2023

2124
@Bean
2225
public RedisConnectionFactory redisConnectionFactory() {
23-
return new LettuceConnectionFactory(redisHost, redisPort);
26+
LettuceConnectionFactory factory = new LettuceConnectionFactory(redisHost, redisPort);
27+
factory.afterPropertiesSet();
28+
return factory;
2429
}
2530

2631
@Bean
@@ -41,10 +46,16 @@ public RedisTemplate<String, Object> objectRedisTemplate() {
4146
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
4247
redisTemplate.setConnectionFactory(redisConnectionFactory());
4348

49+
ObjectMapper objectMapper = new ObjectMapper();
50+
objectMapper.registerModule(new JavaTimeModule());
51+
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
52+
53+
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper);
54+
4455
redisTemplate.setKeySerializer(new StringRedisSerializer());
45-
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
56+
redisTemplate.setValueSerializer(serializer);
4657
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
47-
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
58+
redisTemplate.setHashValueSerializer(serializer);
4859

4960
return redisTemplate;
5061
}

0 commit comments

Comments
 (0)