diff --git a/backend/build.gradle b/backend/build.gradle index fda0ab52..619de390 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -43,6 +43,8 @@ dependencies { testRuntimeOnly 'com.h2database:h2' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'com.github.database-rider:rider-spring:1.44.0' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation "com.redis:testcontainers-redis:2.2.4" /* ETC */ implementation 'org.apache.commons:commons-lang3:3.12.0' diff --git a/backend/src/main/java/io/f1/backend/domain/stat/api/StatController.java b/backend/src/main/java/io/f1/backend/domain/stat/api/StatController.java index 2918612a..9418cd0c 100644 --- a/backend/src/main/java/io/f1/backend/domain/stat/api/StatController.java +++ b/backend/src/main/java/io/f1/backend/domain/stat/api/StatController.java @@ -11,8 +11,8 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -25,18 +25,8 @@ public class StatController { @LimitPageSize @GetMapping("/rankings") public ResponseEntity getRankings( + @RequestParam(required = false) String nickname, @PageableDefault(sort = "score", direction = Direction.DESC) Pageable pageable) { - StatPageResponse response = statService.getRanks(pageable); - - return ResponseEntity.ok().body(response); - } - - @LimitPageSize - @GetMapping("/rankings/{nickname}") - public ResponseEntity getRankingsByNickname( - @PathVariable String nickname, @PageableDefault Pageable pageable) { - StatPageResponse response = - statService.getRanksByNickname(nickname, pageable.getPageSize()); - return ResponseEntity.ok().body(response); + return ResponseEntity.ok().body(statService.getRanks(pageable, nickname)); } } diff --git a/backend/src/main/java/io/f1/backend/domain/stat/app/StatService.java b/backend/src/main/java/io/f1/backend/domain/stat/app/StatService.java index 53668555..a8b3e9e4 100644 --- a/backend/src/main/java/io/f1/backend/domain/stat/app/StatService.java +++ b/backend/src/main/java/io/f1/backend/domain/stat/app/StatService.java @@ -1,53 +1,55 @@ package io.f1.backend.domain.stat.app; -import static io.f1.backend.domain.stat.mapper.StatMapper.toStatListPageResponse; - import io.f1.backend.domain.stat.dao.StatRepository; import io.f1.backend.domain.stat.dto.StatPageResponse; -import io.f1.backend.domain.stat.dto.StatWithNickname; import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.RoomErrorCode; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; +import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.domain.Sort.Direction; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service +@Transactional @RequiredArgsConstructor public class StatService { private final StatRepository statRepository; @Transactional(readOnly = true) - public StatPageResponse getRanks(Pageable pageable) { - Page stats = statRepository.findAllStatsWithUser(pageable); - return toStatListPageResponse(stats); - } + public StatPageResponse getRanks(Pageable pageable, String nickname) { + StatPageResponse response; - @Transactional(readOnly = true) - public StatPageResponse getRanksByNickname(String nickname, int pageSize) { + if (StringUtils.isBlank(nickname)) { + response = statRepository.getRanks(pageable); + } else { + response = statRepository.getRanksByNickname(nickname, pageable.getPageSize()); + } - Page stats = - statRepository.findAllStatsWithUser(getPageableFromNickname(nickname, pageSize)); + if (response.totalElements() == 0) { + throw new CustomException(RoomErrorCode.PLAYER_NOT_FOUND); + } - return toStatListPageResponse(stats); + return response; } - private Pageable getPageableFromNickname(String nickname, int pageSize) { - long score = - statRepository - .findScoreByNickname(nickname) - .orElseThrow(() -> new CustomException(RoomErrorCode.PLAYER_NOT_FOUND)); + // TODO: 게임 종료 후 호출 필요 + public void updateRank(long userId, boolean win, int deltaScore) { + statRepository.updateRank(userId, win, deltaScore); + } - long rowNum = statRepository.countByScoreGreaterThan(score); + public void addUser(long userId, String nickname) { + statRepository.addUser(userId, nickname); + } + + public void removeUser(long userId) { + statRepository.removeUser(userId); + } - int pageNumber = rowNum > 0 ? (int) (rowNum / pageSize) : 0; - return PageRequest.of(pageNumber, pageSize, Sort.by(Direction.DESC, "score")); + public void updateNickname(long userId, String newNickname) { + statRepository.updateNickname(userId, newNickname); } } diff --git a/backend/src/main/java/io/f1/backend/domain/stat/dao/StatJpaRepository.java b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatJpaRepository.java new file mode 100644 index 00000000..5034bac5 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatJpaRepository.java @@ -0,0 +1,66 @@ +package io.f1.backend.domain.stat.dao; + +import io.f1.backend.domain.stat.dto.StatWithNickname; +import io.f1.backend.domain.stat.dto.StatWithNicknameAndUserId; +import io.f1.backend.domain.stat.entity.Stat; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface StatJpaRepository extends JpaRepository { + + @Query( + """ + SELECT + new io.f1.backend.domain.stat.dto.StatWithNickname + (u.nickname, s.totalGames, s.winningGames, s.score) + FROM + Stat s JOIN s.user u + """) + Page findAllStatsWithUser(Pageable pageable); + + @Query("SELECT s.score FROM Stat s WHERE s.user.nickname = :nickname") + Optional findScoreByNickname(String nickname); + + long countByScoreGreaterThan(Long score); + + @Query( + """ + SELECT + new io.f1.backend.domain.stat.dto.StatWithNicknameAndUserId + (u.id, u.nickname, s.totalGames, s.winningGames, s.score) + FROM + Stat s JOIN s.user u + """) + List findAllStatWithNicknameAndUserId(); + + @Modifying + @Query( + """ + UPDATE + Stat s + SET + s.totalGames = s.totalGames + 1, s.winningGames = s.winningGames + 1, s.score = s.score + :deltaScore + WHERE + s.user.id = :userId + """) + void updateStatByUserIdCaseWin(long deltaScore, long userId); + + @Modifying + @Query( + """ + UPDATE + Stat s + SET + s.totalGames = s.totalGames + 1, s.score = s.score + :deltaScore + WHERE + s.user.id = :userId + """) + void updateStatByUserIdCaseLose(long deltaScore, long userId); +} diff --git a/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRedisRepository.java b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRedisRepository.java new file mode 100644 index 00000000..54c41ecb --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRedisRepository.java @@ -0,0 +1,152 @@ +package io.f1.backend.domain.stat.dao; + +import static java.util.Objects.requireNonNull; + +import io.f1.backend.domain.stat.dto.StatPageResponse; +import io.f1.backend.domain.stat.dto.StatResponse; +import io.f1.backend.domain.stat.dto.StatWithNicknameAndUserId; + +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.data.redis.core.ZSetOperations.TypedTuple; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +@Repository +@RequiredArgsConstructor +public class StatRedisRepository { + private static final String STAT_RANK = "stat:rank"; + private static final String STAT_USER = "stat:user:%d"; + private static final String STAT_NICKNAME = "stat:%s"; + + private final RedisTemplate redisTemplate; + private ZSetOperations zSetOps; + private HashOperations hashOps; + private ValueOperations valueOps; + + public void setup() { + zSetOps = redisTemplate.opsForZSet(); + hashOps = redisTemplate.opsForHash(); + valueOps = redisTemplate.opsForValue(); + } + + public void initialize(StatWithNicknameAndUserId stat) { + String statUserKey = getStatUserKey(stat.userId()); + String statNicknameKey = getStatNickname(stat.nickname()); + + // stat:user:{id} + hashOps.put(statUserKey, "nickname", stat.nickname()); + hashOps.put(statUserKey, "totalGames", stat.totalGames()); + hashOps.put(statUserKey, "winningGames", stat.winningGames()); + + // stat:rank + zSetOps.add(STAT_RANK, stat.userId(), stat.score()); + + // stat:{nickname} + valueOps.set(statNicknameKey, stat.userId()); + } + + public void removeUser(long userId) { + String statUserKey = getStatUserKey(userId); + String nickname = (String) hashOps.get(statUserKey, "nickname"); + redisTemplate.delete(statUserKey); + valueOps.getAndDelete(getStatNickname(nickname)); + zSetOps.remove(STAT_RANK, userId); + } + + public StatPageResponse findAllStatsWithUser(Pageable pageable) { + int page = pageable.getPageNumber(); + int size = pageable.getPageSize(); + long startIdx = (long) page * size; + + long totalCount = getTotalMemberCount(); + int totalPages = (int) Math.ceil((double) totalCount / size); + + Set> rankSet = getRankSet(startIdx, size); + + AtomicInteger rankCounter = new AtomicInteger((int) startIdx + 1); + List ranks = + rankSet.stream() + .map(rank -> convertToStatResponse(rank, rankCounter.getAndIncrement())) + .toList(); + + return new StatPageResponse(totalPages, page + 1, rankSet.size(), ranks); + } + + public Pageable getPageableFromNickname(String nickname, int pageSize) { + long userId = getUserIdFromNickname(nickname); + long rowNum = requireNonNull(zSetOps.reverseRank(STAT_RANK, userId)) + 1; + int pageNumber = rowNum > 0 ? (int) (rowNum / pageSize) : 0; + return PageRequest.of(pageNumber, pageSize, Sort.by(Direction.DESC, "score")); + } + + private long getTotalMemberCount() { + long count = requireNonNull(zSetOps.zCard(STAT_RANK)); + if (count == 0) { + throw new NullPointerException("No member found in redis"); + } + return count; + } + + private Set> getRankSet(long startIdx, int size) { + Set> result = + zSetOps.reverseRangeWithScores(STAT_RANK, startIdx, startIdx + size - 1); + return requireNonNull(result); + } + + public void updateRank(long userId, boolean win, int deltaScore) { + String statUserKey = getStatUserKey(userId); + + zSetOps.incrementScore(STAT_RANK, userId, deltaScore); + hashOps.increment(statUserKey, "totalGames", 1); + if (win) { + hashOps.increment(statUserKey, "winningGames", 1); + } + } + + public void updateNickname(long userId, String newNickname) { + String statUserKey = getStatUserKey(userId); + valueOps.set(getStatNickname(newNickname), userId); + + String oldNickname = (String) hashOps.get(statUserKey, "nickname"); + valueOps.getAndDelete(getStatNickname(oldNickname)); + + hashOps.put(statUserKey, "nickname", newNickname); + } + + private StatResponse convertToStatResponse(TypedTuple rank, int rankValue) { + long userId = ((Number) requireNonNull(rank.getValue())).longValue(); + Map statUserMap = hashOps.entries(getStatUserKey(userId)); + + return new StatResponse( + rankValue, + (String) statUserMap.get("nickname"), + (long) statUserMap.get("totalGames"), + (long) statUserMap.get("winningGames"), + requireNonNull(rank.getScore()).longValue()); + } + + private static String getStatUserKey(long userId) { + return String.format(STAT_USER, userId); + } + + private static String getStatNickname(String nickname) { + return String.format(STAT_NICKNAME, nickname); + } + + private long getUserIdFromNickname(String nickname) { + return ((Number) requireNonNull(valueOps.get(getStatNickname(nickname)))).longValue(); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepository.java b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepository.java index 445abc37..1326786e 100644 --- a/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepository.java +++ b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepository.java @@ -1,29 +1,20 @@ package io.f1.backend.domain.stat.dao; -import io.f1.backend.domain.stat.dto.StatWithNickname; -import io.f1.backend.domain.stat.entity.Stat; +import io.f1.backend.domain.stat.dto.StatPageResponse; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import java.util.Optional; +public interface StatRepository { -public interface StatRepository extends JpaRepository { + StatPageResponse getRanks(Pageable pageable); - @Query( - """ - SELECT - new io.f1.backend.domain.stat.dto.StatWithNickname - (u.nickname, s.totalGames, s.winningGames, s.score) - FROM - Stat s JOIN s.user u - """) - Page findAllStatsWithUser(Pageable pageable); + StatPageResponse getRanksByNickname(String nickname, int pageSize); - @Query("SELECT s.score FROM Stat s WHERE s.user.nickname = :nickname") - Optional findScoreByNickname(String nickname); + void addUser(long userId, String nickname); - long countByScoreGreaterThan(Long score); + void updateRank(long userId, boolean win, int deltaScore); + + void updateNickname(long userId, String nickname); + + void removeUser(long userId); } diff --git a/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepositoryAdapter.java b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepositoryAdapter.java new file mode 100644 index 00000000..06885eb7 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepositoryAdapter.java @@ -0,0 +1,100 @@ +package io.f1.backend.domain.stat.dao; + +import static io.f1.backend.domain.stat.mapper.StatMapper.toStatListPageResponse; + +import io.f1.backend.domain.stat.dto.StatPageResponse; +import io.f1.backend.domain.stat.dto.StatWithNickname; +import io.f1.backend.domain.stat.dto.StatWithNicknameAndUserId; +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.RoomErrorCode; + +import jakarta.annotation.PostConstruct; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.stereotype.Repository; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class StatRepositoryAdapter implements StatRepository { + + private final StatJpaRepository jpaRepository; + private final StatRedisRepository redisRepository; + + @PostConstruct + public void setup() { + redisRepository.setup(); + warmingRedis(); + } + + @Override + public StatPageResponse getRanks(Pageable pageable) { + try { + return redisRepository.findAllStatsWithUser(pageable); + } catch (NullPointerException e) { + log.error("Cache miss fall back to MySQL", e); + } + + Page stats = jpaRepository.findAllStatsWithUser(pageable); + return toStatListPageResponse(stats); + } + + @Override + public StatPageResponse getRanksByNickname(String nickname, int pageSize) { + Pageable pageable = getPageableFromNickname(nickname, pageSize); + return getRanks(pageable); + } + + @Override + public void addUser(long userId, String nickname) { + redisRepository.initialize(new StatWithNicknameAndUserId(userId, nickname, 0, 0, 0)); + } + + @Override + public void updateRank(long userId, boolean win, int deltaScore) { + redisRepository.updateRank(userId, win, deltaScore); + if (win) { + jpaRepository.updateStatByUserIdCaseWin(deltaScore, userId); + } else { + jpaRepository.updateStatByUserIdCaseLose(deltaScore, userId); + } + } + + @Override + public void updateNickname(long userId, String nickname) { + redisRepository.updateNickname(userId, nickname); + } + + @Override + public void removeUser(long userId) { + redisRepository.removeUser(userId); + } + + private void warmingRedis() { + jpaRepository.findAllStatWithNicknameAndUserId().forEach(redisRepository::initialize); + } + + private Pageable getPageableFromNickname(String nickname, int pageSize) { + try { + return redisRepository.getPageableFromNickname(nickname, pageSize); + } catch (NullPointerException e) { + log.error("Cache miss fall back to MySQL", e); + } + long score = + jpaRepository + .findScoreByNickname(nickname) + .orElseThrow(() -> new CustomException(RoomErrorCode.PLAYER_NOT_FOUND)); + + long rowNum = jpaRepository.countByScoreGreaterThan(score); + + int pageNumber = rowNum > 0 ? (int) (rowNum / pageSize) : 0; + return PageRequest.of(pageNumber, pageSize, Sort.by(Direction.DESC, "score")); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/stat/dto/StatWithNicknameAndUserId.java b/backend/src/main/java/io/f1/backend/domain/stat/dto/StatWithNicknameAndUserId.java new file mode 100644 index 00000000..609313c1 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/stat/dto/StatWithNicknameAndUserId.java @@ -0,0 +1,4 @@ +package io.f1.backend.domain.stat.dto; + +public record StatWithNicknameAndUserId( + long userId, String nickname, long totalGames, long winningGames, long score) {} diff --git a/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java b/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java index 4db37734..3003d6a0 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java +++ b/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java @@ -2,16 +2,21 @@ import static io.f1.backend.domain.user.constants.SessionKeys.OAUTH_USER; import static io.f1.backend.domain.user.constants.SessionKeys.USER; +import static io.f1.backend.global.util.RedisPublisher.USER_DELETE; +import static io.f1.backend.global.util.RedisPublisher.USER_NEW; +import static io.f1.backend.global.util.RedisPublisher.USER_UPDATE; import io.f1.backend.domain.auth.dto.CurrentUserAndAdminResponse; import io.f1.backend.domain.user.dao.UserRepository; import io.f1.backend.domain.user.dto.AuthenticationUser; import io.f1.backend.domain.user.dto.SignupRequest; import io.f1.backend.domain.user.dto.UserPrincipal; +import io.f1.backend.domain.user.dto.UserSummary; import io.f1.backend.domain.user.entity.User; import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.AuthErrorCode; import io.f1.backend.global.exception.errorcode.UserErrorCode; +import io.f1.backend.global.util.RedisPublisher; import io.f1.backend.global.util.SecurityUtils; import jakarta.servlet.http.HttpSession; @@ -26,6 +31,7 @@ public class UserService { private final UserRepository userRepository; + private final RedisPublisher redisPublisher; @Transactional public CurrentUserAndAdminResponse signup(HttpSession session, SignupRequest signupRequest) { @@ -40,6 +46,8 @@ public CurrentUserAndAdminResponse signup(HttpSession session, SignupRequest sig SecurityUtils.setAuthentication(user); UserPrincipal userPrincipal = SecurityUtils.getCurrentUserPrincipal(); + redisPublisher.publish(USER_NEW, new UserSummary(user.getId(), nickname)); + return CurrentUserAndAdminResponse.from(userPrincipal); } @@ -94,6 +102,8 @@ public void deleteUser(Long userId) { .findById(userId) .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); userRepository.delete(user); + + redisPublisher.publish(USER_DELETE, userId.toString()); } @Transactional @@ -103,6 +113,8 @@ public void updateNickname(Long userId, String newNickname, HttpSession session) User user = initNickname(userId, newNickname); session.setAttribute(USER, AuthenticationUser.from(user)); SecurityUtils.setAuthentication(user); + + redisPublisher.publish(USER_UPDATE, new UserSummary(user.getId(), newNickname)); } @Transactional(readOnly = true) diff --git a/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java b/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java index fe06a352..c82ba2e1 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java +++ b/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java @@ -25,7 +25,7 @@ public interface UserRepository extends JpaRepository { @Query( "SELECT new io.f1.backend.domain.admin.dto.UserResponse(u.id, u.nickname, u.lastLogin," - + " u.createdAt) FROM User u WHERE LOWER(u.nickname) LIKE CONCAT('%'," - + " LOWER(:nickname), '%')") + + " u.createdAt) FROM User u WHERE LOWER(u.nickname) LIKE CONCAT('%'," + + " LOWER(:nickname), '%')") Page findUsersByNicknameContaining(String nickname, Pageable pageable); } diff --git a/backend/src/main/java/io/f1/backend/domain/user/dto/UserSummary.java b/backend/src/main/java/io/f1/backend/domain/user/dto/UserSummary.java new file mode 100644 index 00000000..36899b48 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/dto/UserSummary.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.user.dto; + +public record UserSummary(long userId, String nickname) {} diff --git a/backend/src/main/java/io/f1/backend/global/config/RedisConfig.java b/backend/src/main/java/io/f1/backend/global/config/RedisConfig.java new file mode 100644 index 00000000..d1d4b7f0 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/config/RedisConfig.java @@ -0,0 +1,41 @@ +package io.f1.backend.global.config; + +import io.f1.backend.global.util.RedisUserSubscriber; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate( + RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + + redisConnectionFactory.getConnection().serverCommands().flushAll(); + + return redisTemplate; + } + + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer( + RedisConnectionFactory redisConnectionFactory, + RedisUserSubscriber redisUserSubscriber) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(redisConnectionFactory); + container.addMessageListener(redisUserSubscriber, new PatternTopic("user-*")); + return container; + } +} diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java index 4a9b31ca..2732652b 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java @@ -12,7 +12,7 @@ public enum RoomErrorCode implements ErrorCode { ROOM_GAME_IN_PROGRESS("E403003", HttpStatus.FORBIDDEN, "게임이 진행 중 입니다."), ROOM_NOT_FOUND("E404005", HttpStatus.NOT_FOUND, "존재하지 않는 방입니다."), WRONG_PASSWORD("E401006", HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지않습니다."), - PLAYER_NOT_FOUND("E404007", HttpStatus.NOT_FOUND, "존재하지 않는 플레이어입니다."), + PLAYER_NOT_FOUND("E404007", HttpStatus.NOT_FOUND, "플레이어가 존재하지 않습니다."), SOCKET_SESSION_NOT_FOUND("E404006", HttpStatus.NOT_FOUND, "존재하지 않는 소켓 세션입니다."), GAME_ALREADY_PLAYING("E400015", HttpStatus.BAD_REQUEST, "이미 게임이 진행 중 입니다."), NOT_ROOM_OWNER("E403005", HttpStatus.FORBIDDEN, "방장만 게임 시작이 가능합니다."); diff --git a/backend/src/main/java/io/f1/backend/global/util/RedisPublisher.java b/backend/src/main/java/io/f1/backend/global/util/RedisPublisher.java new file mode 100644 index 00000000..fb2944e2 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/util/RedisPublisher.java @@ -0,0 +1,20 @@ +package io.f1.backend.global.util; + +import lombok.RequiredArgsConstructor; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedisPublisher { + + private final RedisTemplate redisTemplate; + public static final String USER_NEW = "user-new"; + public static final String USER_UPDATE = "user-update"; + public static final String USER_DELETE = "user-delete"; + + public void publish(String channel, Object message) { + redisTemplate.convertAndSend(channel, message); + } +} diff --git a/backend/src/main/java/io/f1/backend/global/util/RedisUserSubscriber.java b/backend/src/main/java/io/f1/backend/global/util/RedisUserSubscriber.java new file mode 100644 index 00000000..cb29647d --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/util/RedisUserSubscriber.java @@ -0,0 +1,51 @@ +package io.f1.backend.global.util; + +import static io.f1.backend.global.util.RedisPublisher.USER_DELETE; +import static io.f1.backend.global.util.RedisPublisher.USER_NEW; +import static io.f1.backend.global.util.RedisPublisher.USER_UPDATE; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.f1.backend.domain.stat.app.StatService; +import io.f1.backend.domain.user.dto.UserSummary; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisUserSubscriber implements MessageListener { + + private final StatService statService; + private final ObjectMapper om; + + @Override + public void onMessage(Message message, byte[] pattern) { + String channel = new String(message.getChannel()); + String json = new String(message.getBody()); + try { + switch (channel) { + case USER_NEW -> { + UserSummary userSummary = om.readValue(json, UserSummary.class); + statService.addUser(userSummary.userId(), userSummary.nickname()); + } + case USER_UPDATE -> { + UserSummary userSummary = om.readValue(json, UserSummary.class); + statService.updateNickname(userSummary.userId(), userSummary.nickname()); + } + case USER_DELETE -> { + long userId = Long.parseLong(om.readValue(json, String.class)); + statService.removeUser(userId); + } + } + } catch (JsonProcessingException e) { + log.error("RedisUserSubscriber Json Processing error, channel={}", channel, e); + } + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 828cd9f7..3dfb6d63 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -16,11 +16,10 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} - # Redis 도입 시 정의 - # data: - # redis: - # host: ${REDIS_HOST} - # port: ${REDIS_PORT} + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} jpa: defer-datasource-initialization: true # 현재는 data.sql 에서 더미 유저 자동 추가를 위해 넣어뒀음. diff --git a/backend/src/test/java/io/f1/backend/domain/stat/RedisStatBrowserTest.java b/backend/src/test/java/io/f1/backend/domain/stat/RedisStatBrowserTest.java new file mode 100644 index 00000000..8ced8afd --- /dev/null +++ b/backend/src/test/java/io/f1/backend/domain/stat/RedisStatBrowserTest.java @@ -0,0 +1,194 @@ +package io.f1.backend.domain.stat; + +import static io.f1.backend.domain.user.constants.SessionKeys.OAUTH_USER; +import static io.f1.backend.domain.user.constants.SessionKeys.USER; +import static io.f1.backend.global.exception.errorcode.RoomErrorCode.PLAYER_NOT_FOUND; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import static java.lang.Thread.sleep; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.database.rider.core.api.dataset.DataSet; + +import io.f1.backend.domain.stat.dao.StatJpaRepository; +import io.f1.backend.domain.stat.dao.StatRepositoryAdapter; +import io.f1.backend.domain.stat.dto.StatWithNickname; +import io.f1.backend.domain.stat.dto.StatWithNicknameAndUserId; +import io.f1.backend.domain.user.dao.UserRepository; +import io.f1.backend.domain.user.dto.AuthenticationUser; +import io.f1.backend.domain.user.dto.SignupRequest; +import io.f1.backend.domain.user.entity.User; +import io.f1.backend.global.config.RedisTestContainerConfig; +import io.f1.backend.global.template.BrowserTestTemplate; +import io.f1.backend.global.util.SecurityUtils; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.List; + +@WithMockUser +@Import(RedisTestContainerConfig.class) +public class RedisStatBrowserTest extends BrowserTestTemplate { + @Autowired ObjectMapper om; + + @Autowired UserRepository userRepository; + + @Autowired StatRepositoryAdapter statRepositoryAdapter; + + @Autowired RedisConnectionFactory redisConnectionFactory; + + @MockitoBean StatJpaRepository statJpaRepository; // JPA 미사용 + + @BeforeEach + public void setup() { + redisConnectionFactory.getConnection().serverCommands().flushAll(); + } + + @Test + @DataSet("datasets/stat/one-user-stat.yml") + @DisplayName("새로운 유저가 회원가입하면 Redis에 반영되어 JPA 없이 랭킹 조회가 가능하다") + void totalRankingNewUserWithoutJpa() throws Exception { + // given + User user = userRepository.findById(1L).orElseThrow(AssertionError::new); + MockHttpSession session = getMockSession(user, false); + + String nickname = "TEST"; + SignupRequest signupRequest = new SignupRequest(nickname); + + // when + mockMvc.perform( + post("/signup") + .session(session) + .header("content-type", "application/json") + .content(om.writeValueAsString(signupRequest))) + .andExpect(status().isCreated()); + + sleep(1000); + + ResultActions result = mockMvc.perform(get("/stats/rankings").param("nickname", nickname)); + + // then + checkExpectedRankingOneUser(result, nickname); + verifyNeverUsedJpa(); + } + + @Test + @DataSet("datasets/stat/one-user-stat.yml") + @DisplayName("기존 유저가 닉네임을 변경하면 Redis에 반영되어 JPA 없이 랭킹 조회가 가능하다") + void totalRankingChangeNicknameWithoutJpa() throws Exception { + // given + User user = userRepository.findById(1L).orElseThrow(AssertionError::new); + MockHttpSession session = getMockSession(user, true); + + warmingRedisOneUser(user); + + String nickname = "TEST"; + SignupRequest signupRequest = new SignupRequest(nickname); + + // when + mockMvc.perform( + put("/user/me") + .session(session) + .header("content-type", "application/json") + .content(om.writeValueAsString(signupRequest))) + .andExpect(status().isNoContent()); + + sleep(1000); + + ResultActions result = mockMvc.perform(get("/stats/rankings").param("nickname", nickname)); + + // then + checkExpectedRankingOneUser(result, nickname); + verifyNeverUsedJpa(); + } + + @Test + @DataSet("datasets/stat/one-user-stat.yml") + @DisplayName("기존 유저가 삭제되면 Redis 조회 결과가 없어 JPARepository로 fallback 된다") + void totalRankingDeleteUserFallbackToJpa() throws Exception { + // given + User user = userRepository.findById(1L).orElseThrow(AssertionError::new); + MockHttpSession session = getMockSession(user, true); + + Pageable pageable = PageRequest.of(0, 10, Direction.DESC, "score"); + Page expectedPage = new PageImpl<>(List.of(), pageable, 0); + given(statJpaRepository.findAllStatsWithUser(any())).willReturn(expectedPage); + + warmingRedisOneUser(user); + + // when + mockMvc.perform(delete("/user/me").session(session)).andExpect(status().isNoContent()); + + sleep(1000); + + ResultActions result = mockMvc.perform(get("/stats/rankings")); + + // then + verify(statJpaRepository, times(1)).findAllStatsWithUser(any()); + result.andExpectAll( + status().isNotFound(), jsonPath("$.code").value(PLAYER_NOT_FOUND.getCode())); + } + + private void checkExpectedRankingOneUser(ResultActions result, String nickname) + throws Exception { + result.andExpectAll( + status().isOk(), + jsonPath("$.totalPages").value(1), + jsonPath("$.currentPage").value(1), + jsonPath("$.totalElements").value(1), + jsonPath("$.ranks.length()").value(1), + jsonPath("$.ranks[0].nickname").value(nickname)); + } + + private void verifyNeverUsedJpa() { + verify(statJpaRepository, never()).findScoreByNickname(anyString()); + verify(statJpaRepository, never()).countByScoreGreaterThan(anyLong()); + verify(statJpaRepository, never()).findAllStatsWithUser(any()); + } + + private MockHttpSession getMockSession(User user, boolean signup) { + MockHttpSession session = new MockHttpSession(); + if (signup) { + session.setAttribute(USER, AuthenticationUser.from(user)); + SecurityUtils.setAuthentication(user); + } else { + session.setAttribute(OAUTH_USER, AuthenticationUser.from(user)); + } + + return session; + } + + private void warmingRedisOneUser(User user) { + StatWithNicknameAndUserId mockStat = + new StatWithNicknameAndUserId(user.getId(), user.getNickname(), 10, 10, 100); + given(statJpaRepository.findAllStatWithNicknameAndUserId()).willReturn(List.of(mockStat)); + statRepositoryAdapter.setup(); + } +} diff --git a/backend/src/test/java/io/f1/backend/domain/stat/StatBrowserTest.java b/backend/src/test/java/io/f1/backend/domain/stat/StatBrowserTest.java index 1120f5ed..7ac61fac 100644 --- a/backend/src/test/java/io/f1/backend/domain/stat/StatBrowserTest.java +++ b/backend/src/test/java/io/f1/backend/domain/stat/StatBrowserTest.java @@ -9,15 +9,33 @@ import com.github.database.rider.core.api.dataset.DataSet; +import io.f1.backend.domain.stat.dao.StatRepositoryAdapter; +import io.f1.backend.global.config.RedisTestContainerConfig; import io.f1.backend.global.template.BrowserTestTemplate; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.ResultActions; @WithMockUser +@Import(RedisTestContainerConfig.class) public class StatBrowserTest extends BrowserTestTemplate { + + @Autowired RedisConnectionFactory redisConnectionFactory; + + @Autowired private StatRepositoryAdapter repository; + + @BeforeEach + void init() { + redisConnectionFactory.getConnection().serverCommands().flushAll(); + repository.setup(); + } + @Test @DataSet("datasets/stat/one-user-stat.yml") @DisplayName("총 유저 수가 1명이면 첫 페이지에서 1개의 결과를 반환한다") @@ -96,7 +114,7 @@ void totalRankingWithUnregisteredNickname() throws Exception { String nickname = "UNREGISTERED"; // when - ResultActions result = mockMvc.perform(get("/stats/rankings/" + nickname)); + ResultActions result = mockMvc.perform(get("/stats/rankings").param("nickname", nickname)); // then result.andExpectAll( @@ -112,7 +130,8 @@ void totalRankingForThreeUserWithFirstRankedNickname() throws Exception { // when ResultActions result = - mockMvc.perform(get("/stats/rankings/" + nickname).param("size", "2")); + mockMvc.perform( + get("/stats/rankings").param("size", "2").param("nickname", nickname)); // then result.andExpectAll( @@ -133,7 +152,8 @@ void totalRankingForThreeUserWithLastRankedNickname() throws Exception { // when ResultActions result = - mockMvc.perform(get("/stats/rankings/" + nickname).param("size", "2")); + mockMvc.perform( + get("/stats/rankings").param("size", "2").param("nickname", nickname)); // then result.andExpectAll( diff --git a/backend/src/test/java/io/f1/backend/global/config/RedisTestContainerConfig.java b/backend/src/test/java/io/f1/backend/global/config/RedisTestContainerConfig.java new file mode 100644 index 00000000..e091b706 --- /dev/null +++ b/backend/src/test/java/io/f1/backend/global/config/RedisTestContainerConfig.java @@ -0,0 +1,29 @@ +package io.f1.backend.global.config; + +import com.redis.testcontainers.RedisContainer; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Configuration +@Testcontainers +public class RedisTestContainerConfig { + @Container + public static RedisContainer redisContainer = + new RedisContainer( + RedisContainer.DEFAULT_IMAGE_NAME.withTag(RedisContainer.DEFAULT_TAG)); + + static { + redisContainer.start(); + } + + @Bean + RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory( + redisContainer.getHost(), redisContainer.getFirstMappedPort()); + } +}