Skip to content

Commit 1cea9a4

Browse files
authored
♻️ refactor: 랭킹 조회 Redis 적용 (#111)
1 parent 1f88146 commit 1cea9a4

File tree

18 files changed

+741
-65
lines changed

18 files changed

+741
-65
lines changed

backend/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ dependencies {
4343
testRuntimeOnly 'com.h2database:h2'
4444
testImplementation 'org.springframework.security:spring-security-test'
4545
testImplementation 'com.github.database-rider:rider-spring:1.44.0'
46+
testImplementation 'org.testcontainers:junit-jupiter'
47+
testImplementation "com.redis:testcontainers-redis:2.2.4"
4648

4749
/* ETC */
4850
implementation 'org.apache.commons:commons-lang3:3.12.0'

backend/src/main/java/io/f1/backend/domain/stat/api/StatController.java

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
import org.springframework.data.web.PageableDefault;
1212
import org.springframework.http.ResponseEntity;
1313
import org.springframework.web.bind.annotation.GetMapping;
14-
import org.springframework.web.bind.annotation.PathVariable;
1514
import org.springframework.web.bind.annotation.RequestMapping;
15+
import org.springframework.web.bind.annotation.RequestParam;
1616
import org.springframework.web.bind.annotation.RestController;
1717

1818
@RestController
@@ -25,18 +25,8 @@ public class StatController {
2525
@LimitPageSize
2626
@GetMapping("/rankings")
2727
public ResponseEntity<StatPageResponse> getRankings(
28+
@RequestParam(required = false) String nickname,
2829
@PageableDefault(sort = "score", direction = Direction.DESC) Pageable pageable) {
29-
StatPageResponse response = statService.getRanks(pageable);
30-
31-
return ResponseEntity.ok().body(response);
32-
}
33-
34-
@LimitPageSize
35-
@GetMapping("/rankings/{nickname}")
36-
public ResponseEntity<StatPageResponse> getRankingsByNickname(
37-
@PathVariable String nickname, @PageableDefault Pageable pageable) {
38-
StatPageResponse response =
39-
statService.getRanksByNickname(nickname, pageable.getPageSize());
40-
return ResponseEntity.ok().body(response);
30+
return ResponseEntity.ok().body(statService.getRanks(pageable, nickname));
4131
}
4232
}
Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,55 @@
11
package io.f1.backend.domain.stat.app;
22

3-
import static io.f1.backend.domain.stat.mapper.StatMapper.toStatListPageResponse;
4-
53
import io.f1.backend.domain.stat.dao.StatRepository;
64
import io.f1.backend.domain.stat.dto.StatPageResponse;
7-
import io.f1.backend.domain.stat.dto.StatWithNickname;
85
import io.f1.backend.global.exception.CustomException;
96
import io.f1.backend.global.exception.errorcode.RoomErrorCode;
107

118
import lombok.RequiredArgsConstructor;
129

13-
import org.springframework.data.domain.Page;
14-
import org.springframework.data.domain.PageRequest;
10+
import org.apache.commons.lang3.StringUtils;
1511
import org.springframework.data.domain.Pageable;
16-
import org.springframework.data.domain.Sort;
17-
import org.springframework.data.domain.Sort.Direction;
1812
import org.springframework.stereotype.Service;
1913
import org.springframework.transaction.annotation.Transactional;
2014

2115
@Service
16+
@Transactional
2217
@RequiredArgsConstructor
2318
public class StatService {
2419

2520
private final StatRepository statRepository;
2621

2722
@Transactional(readOnly = true)
28-
public StatPageResponse getRanks(Pageable pageable) {
29-
Page<StatWithNickname> stats = statRepository.findAllStatsWithUser(pageable);
30-
return toStatListPageResponse(stats);
31-
}
23+
public StatPageResponse getRanks(Pageable pageable, String nickname) {
24+
StatPageResponse response;
3225

33-
@Transactional(readOnly = true)
34-
public StatPageResponse getRanksByNickname(String nickname, int pageSize) {
26+
if (StringUtils.isBlank(nickname)) {
27+
response = statRepository.getRanks(pageable);
28+
} else {
29+
response = statRepository.getRanksByNickname(nickname, pageable.getPageSize());
30+
}
3531

36-
Page<StatWithNickname> stats =
37-
statRepository.findAllStatsWithUser(getPageableFromNickname(nickname, pageSize));
32+
if (response.totalElements() == 0) {
33+
throw new CustomException(RoomErrorCode.PLAYER_NOT_FOUND);
34+
}
3835

39-
return toStatListPageResponse(stats);
36+
return response;
4037
}
4138

42-
private Pageable getPageableFromNickname(String nickname, int pageSize) {
43-
long score =
44-
statRepository
45-
.findScoreByNickname(nickname)
46-
.orElseThrow(() -> new CustomException(RoomErrorCode.PLAYER_NOT_FOUND));
39+
// TODO: 게임 종료 후 호출 필요
40+
public void updateRank(long userId, boolean win, int deltaScore) {
41+
statRepository.updateRank(userId, win, deltaScore);
42+
}
4743

48-
long rowNum = statRepository.countByScoreGreaterThan(score);
44+
public void addUser(long userId, String nickname) {
45+
statRepository.addUser(userId, nickname);
46+
}
47+
48+
public void removeUser(long userId) {
49+
statRepository.removeUser(userId);
50+
}
4951

50-
int pageNumber = rowNum > 0 ? (int) (rowNum / pageSize) : 0;
51-
return PageRequest.of(pageNumber, pageSize, Sort.by(Direction.DESC, "score"));
52+
public void updateNickname(long userId, String newNickname) {
53+
statRepository.updateNickname(userId, newNickname);
5254
}
5355
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package io.f1.backend.domain.stat.dao;
2+
3+
import io.f1.backend.domain.stat.dto.StatWithNickname;
4+
import io.f1.backend.domain.stat.dto.StatWithNicknameAndUserId;
5+
import io.f1.backend.domain.stat.entity.Stat;
6+
7+
import org.springframework.data.domain.Page;
8+
import org.springframework.data.domain.Pageable;
9+
import org.springframework.data.jpa.repository.JpaRepository;
10+
import org.springframework.data.jpa.repository.Modifying;
11+
import org.springframework.data.jpa.repository.Query;
12+
13+
import java.util.List;
14+
import java.util.Optional;
15+
16+
public interface StatJpaRepository extends JpaRepository<Stat, Long> {
17+
18+
@Query(
19+
"""
20+
SELECT
21+
new io.f1.backend.domain.stat.dto.StatWithNickname
22+
(u.nickname, s.totalGames, s.winningGames, s.score)
23+
FROM
24+
Stat s JOIN s.user u
25+
""")
26+
Page<StatWithNickname> findAllStatsWithUser(Pageable pageable);
27+
28+
@Query("SELECT s.score FROM Stat s WHERE s.user.nickname = :nickname")
29+
Optional<Long> findScoreByNickname(String nickname);
30+
31+
long countByScoreGreaterThan(Long score);
32+
33+
@Query(
34+
"""
35+
SELECT
36+
new io.f1.backend.domain.stat.dto.StatWithNicknameAndUserId
37+
(u.id, u.nickname, s.totalGames, s.winningGames, s.score)
38+
FROM
39+
Stat s JOIN s.user u
40+
""")
41+
List<StatWithNicknameAndUserId> findAllStatWithNicknameAndUserId();
42+
43+
@Modifying
44+
@Query(
45+
"""
46+
UPDATE
47+
Stat s
48+
SET
49+
s.totalGames = s.totalGames + 1, s.winningGames = s.winningGames + 1, s.score = s.score + :deltaScore
50+
WHERE
51+
s.user.id = :userId
52+
""")
53+
void updateStatByUserIdCaseWin(long deltaScore, long userId);
54+
55+
@Modifying
56+
@Query(
57+
"""
58+
UPDATE
59+
Stat s
60+
SET
61+
s.totalGames = s.totalGames + 1, s.score = s.score + :deltaScore
62+
WHERE
63+
s.user.id = :userId
64+
""")
65+
void updateStatByUserIdCaseLose(long deltaScore, long userId);
66+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package io.f1.backend.domain.stat.dao;
2+
3+
import static java.util.Objects.requireNonNull;
4+
5+
import io.f1.backend.domain.stat.dto.StatPageResponse;
6+
import io.f1.backend.domain.stat.dto.StatResponse;
7+
import io.f1.backend.domain.stat.dto.StatWithNicknameAndUserId;
8+
9+
import lombok.RequiredArgsConstructor;
10+
11+
import org.springframework.data.domain.PageRequest;
12+
import org.springframework.data.domain.Pageable;
13+
import org.springframework.data.domain.Sort;
14+
import org.springframework.data.domain.Sort.Direction;
15+
import org.springframework.data.redis.core.HashOperations;
16+
import org.springframework.data.redis.core.RedisTemplate;
17+
import org.springframework.data.redis.core.ValueOperations;
18+
import org.springframework.data.redis.core.ZSetOperations;
19+
import org.springframework.data.redis.core.ZSetOperations.TypedTuple;
20+
import org.springframework.stereotype.Repository;
21+
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.Set;
25+
import java.util.concurrent.atomic.AtomicInteger;
26+
27+
@Repository
28+
@RequiredArgsConstructor
29+
public class StatRedisRepository {
30+
private static final String STAT_RANK = "stat:rank";
31+
private static final String STAT_USER = "stat:user:%d";
32+
private static final String STAT_NICKNAME = "stat:%s";
33+
34+
private final RedisTemplate<String, Object> redisTemplate;
35+
private ZSetOperations<String, Object> zSetOps;
36+
private HashOperations<String, Object, Object> hashOps;
37+
private ValueOperations<String, Object> valueOps;
38+
39+
public void setup() {
40+
zSetOps = redisTemplate.opsForZSet();
41+
hashOps = redisTemplate.opsForHash();
42+
valueOps = redisTemplate.opsForValue();
43+
}
44+
45+
public void initialize(StatWithNicknameAndUserId stat) {
46+
String statUserKey = getStatUserKey(stat.userId());
47+
String statNicknameKey = getStatNickname(stat.nickname());
48+
49+
// stat:user:{id}
50+
hashOps.put(statUserKey, "nickname", stat.nickname());
51+
hashOps.put(statUserKey, "totalGames", stat.totalGames());
52+
hashOps.put(statUserKey, "winningGames", stat.winningGames());
53+
54+
// stat:rank
55+
zSetOps.add(STAT_RANK, stat.userId(), stat.score());
56+
57+
// stat:{nickname}
58+
valueOps.set(statNicknameKey, stat.userId());
59+
}
60+
61+
public void removeUser(long userId) {
62+
String statUserKey = getStatUserKey(userId);
63+
String nickname = (String) hashOps.get(statUserKey, "nickname");
64+
redisTemplate.delete(statUserKey);
65+
valueOps.getAndDelete(getStatNickname(nickname));
66+
zSetOps.remove(STAT_RANK, userId);
67+
}
68+
69+
public StatPageResponse findAllStatsWithUser(Pageable pageable) {
70+
int page = pageable.getPageNumber();
71+
int size = pageable.getPageSize();
72+
long startIdx = (long) page * size;
73+
74+
long totalCount = getTotalMemberCount();
75+
int totalPages = (int) Math.ceil((double) totalCount / size);
76+
77+
Set<TypedTuple<Object>> rankSet = getRankSet(startIdx, size);
78+
79+
AtomicInteger rankCounter = new AtomicInteger((int) startIdx + 1);
80+
List<StatResponse> ranks =
81+
rankSet.stream()
82+
.map(rank -> convertToStatResponse(rank, rankCounter.getAndIncrement()))
83+
.toList();
84+
85+
return new StatPageResponse(totalPages, page + 1, rankSet.size(), ranks);
86+
}
87+
88+
public Pageable getPageableFromNickname(String nickname, int pageSize) {
89+
long userId = getUserIdFromNickname(nickname);
90+
long rowNum = requireNonNull(zSetOps.reverseRank(STAT_RANK, userId)) + 1;
91+
int pageNumber = rowNum > 0 ? (int) (rowNum / pageSize) : 0;
92+
return PageRequest.of(pageNumber, pageSize, Sort.by(Direction.DESC, "score"));
93+
}
94+
95+
private long getTotalMemberCount() {
96+
long count = requireNonNull(zSetOps.zCard(STAT_RANK));
97+
if (count == 0) {
98+
throw new NullPointerException("No member found in redis");
99+
}
100+
return count;
101+
}
102+
103+
private Set<TypedTuple<Object>> getRankSet(long startIdx, int size) {
104+
Set<TypedTuple<Object>> result =
105+
zSetOps.reverseRangeWithScores(STAT_RANK, startIdx, startIdx + size - 1);
106+
return requireNonNull(result);
107+
}
108+
109+
public void updateRank(long userId, boolean win, int deltaScore) {
110+
String statUserKey = getStatUserKey(userId);
111+
112+
zSetOps.incrementScore(STAT_RANK, userId, deltaScore);
113+
hashOps.increment(statUserKey, "totalGames", 1);
114+
if (win) {
115+
hashOps.increment(statUserKey, "winningGames", 1);
116+
}
117+
}
118+
119+
public void updateNickname(long userId, String newNickname) {
120+
String statUserKey = getStatUserKey(userId);
121+
valueOps.set(getStatNickname(newNickname), userId);
122+
123+
String oldNickname = (String) hashOps.get(statUserKey, "nickname");
124+
valueOps.getAndDelete(getStatNickname(oldNickname));
125+
126+
hashOps.put(statUserKey, "nickname", newNickname);
127+
}
128+
129+
private StatResponse convertToStatResponse(TypedTuple<Object> rank, int rankValue) {
130+
long userId = ((Number) requireNonNull(rank.getValue())).longValue();
131+
Map<Object, Object> statUserMap = hashOps.entries(getStatUserKey(userId));
132+
133+
return new StatResponse(
134+
rankValue,
135+
(String) statUserMap.get("nickname"),
136+
(long) statUserMap.get("totalGames"),
137+
(long) statUserMap.get("winningGames"),
138+
requireNonNull(rank.getScore()).longValue());
139+
}
140+
141+
private static String getStatUserKey(long userId) {
142+
return String.format(STAT_USER, userId);
143+
}
144+
145+
private static String getStatNickname(String nickname) {
146+
return String.format(STAT_NICKNAME, nickname);
147+
}
148+
149+
private long getUserIdFromNickname(String nickname) {
150+
return ((Number) requireNonNull(valueOps.get(getStatNickname(nickname)))).longValue();
151+
}
152+
}
Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,20 @@
11
package io.f1.backend.domain.stat.dao;
22

3-
import io.f1.backend.domain.stat.dto.StatWithNickname;
4-
import io.f1.backend.domain.stat.entity.Stat;
3+
import io.f1.backend.domain.stat.dto.StatPageResponse;
54

6-
import org.springframework.data.domain.Page;
75
import org.springframework.data.domain.Pageable;
8-
import org.springframework.data.jpa.repository.JpaRepository;
9-
import org.springframework.data.jpa.repository.Query;
106

11-
import java.util.Optional;
7+
public interface StatRepository {
128

13-
public interface StatRepository extends JpaRepository<Stat, Long> {
9+
StatPageResponse getRanks(Pageable pageable);
1410

15-
@Query(
16-
"""
17-
SELECT
18-
new io.f1.backend.domain.stat.dto.StatWithNickname
19-
(u.nickname, s.totalGames, s.winningGames, s.score)
20-
FROM
21-
Stat s JOIN s.user u
22-
""")
23-
Page<StatWithNickname> findAllStatsWithUser(Pageable pageable);
11+
StatPageResponse getRanksByNickname(String nickname, int pageSize);
2412

25-
@Query("SELECT s.score FROM Stat s WHERE s.user.nickname = :nickname")
26-
Optional<Long> findScoreByNickname(String nickname);
13+
void addUser(long userId, String nickname);
2714

28-
long countByScoreGreaterThan(Long score);
15+
void updateRank(long userId, boolean win, int deltaScore);
16+
17+
void updateNickname(long userId, String nickname);
18+
19+
void removeUser(long userId);
2920
}

0 commit comments

Comments
 (0)