Skip to content

Commit 37f12dd

Browse files
committed
♻️ refactor: 랭킹 조회 Redis 적용
1 parent 51d79c6 commit 37f12dd

File tree

18 files changed

+881
-190
lines changed

18 files changed

+881
-190
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: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66

77
import lombok.RequiredArgsConstructor;
88

9+
import org.apache.commons.lang3.StringUtils;
910
import org.springframework.data.domain.Pageable;
1011
import org.springframework.data.domain.Sort.Direction;
1112
import org.springframework.data.web.PageableDefault;
1213
import org.springframework.http.ResponseEntity;
14+
import org.springframework.security.core.parameters.P;
1315
import org.springframework.web.bind.annotation.GetMapping;
1416
import org.springframework.web.bind.annotation.PathVariable;
1517
import org.springframework.web.bind.annotation.RequestMapping;
18+
import org.springframework.web.bind.annotation.RequestParam;
1619
import org.springframework.web.bind.annotation.RestController;
1720

1821
@RestController
@@ -25,18 +28,8 @@ public class StatController {
2528
@LimitPageSize
2629
@GetMapping("/rankings")
2730
public ResponseEntity<StatPageResponse> getRankings(
31+
@RequestParam(required = false) String nickname,
2832
@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);
33+
return ResponseEntity.ok().body(statService.getRanks(pageable, nickname));
4134
}
4235
}
Lines changed: 28 additions & 26 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;
5+
86
import io.f1.backend.global.exception.CustomException;
97
import io.f1.backend.global.exception.errorcode.RoomErrorCode;
10-
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+
}
4947

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

11-
import java.util.Optional;
6+
public interface StatRepository {
7+
8+
StatPageResponse getRanks(Pageable pageable);
9+
10+
StatPageResponse getRanksByNickname(String nickname, int pageSize);
11+
12+
void addUser(long userId, String nickname);
1213

13-
public interface StatRepository extends JpaRepository<Stat, Long> {
14+
void updateRank(long userId, boolean win, int deltaScore);
1415

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);
16+
void updateNickname(long userId, String nickname);
2417

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

28-
long countByScoreGreaterThan(Long score);
2920
}

0 commit comments

Comments
 (0)