프로젝트 Memes의 주간, 월간, 총 좋아요 수에 따른 리더보드를 구현하려고 한다.
JQPL에서 QueryDSL 그리고 Redis의 Sorted Set으로 변경되기 까지의 과정을 적어보려고 한다.
@Query("SELECT ms FROM Memes ms WHERE ms.createdAt >= :time ORDER BY ms.likeCount DESC")
@EntityGraph(attributePaths = {"meme"})
List<Memes> findTopMemesByLikeCountForPeriod(Pageable pageable, LocalDateTime time);
createdAt
즉 생성 날짜라는 것이다.createdAt
이 기준이 아닌, 특정 기간 동안 받은 likeCount
즉 좋아요 수를 기준으로 작성해한다.Memes
에다가 주간 카운트 필드랑 월간 카운트 필드를 만드는 것은 어떨까?Memes
의 튜플 하나당 쿼리가 하나씩 나가는 것이기 때문에 조회 한 번에 엄청난 양의 쿼리가 발생할 수 있다.Group By
를 활용?Memes
에서 SELECT
를 할 것이 아니라, Like
테이블에서 GROUP BY를 통해서 인기차트를 구현하면 되는 것 아닌가? @Override
public List<MemesInfo> findTopMemesByLikeCountForPeriod(LocalDateTime time) {
return jpaQueryFactory.select(memesQDtoFactory.qMemesInfoSetLike())
.from(like)
.join(like.memes, memes)
.groupBy(memes.id)
.where(like.createdAt.goe(time))
.orderBy(memes.count().desc())
.limit(TOP_TEN)
.fetch();
}
Like
을 테이블에서 memesId
를 통해 그룹화 한다음 집계함수 Count
를 활용하여, 좋아요 수 내림차순으로 구한다.WHERE
절을 통해서, LIKE
튜플의 생성 날짜를 기준으로 튜플들을 가져오게 된다.JOIN
을 통해서 memesId
를 활용해 LIKE
와 MEMES
테이블에서 원하는 칼럼을 LIMIT
를 통해서 10개만 가져온다.JOIN
, GROUP BY
, ORDER BY
, LIMIT
등 너무 많은 연산이 들어가서 데이터가 많아지면 DB에 부하가 생길 수 있을 것 같다는 생각을 했다.VIEW
를 활용하는 방식은 안될까?VIEW
를 활용하여, 집계함수 COUNT
가 가장 높은 상위 10개를 DB 차원에서 지속적으로 업데이트 하는 것날짜
, Value를 memesId
, Score를 LikeCount
로 하여 Redis에 저장한다.ZUNIONSTORE
를 활용하여 일주일, 한달치 Key를 결합한 후, 저장하여 사용자에게 반환하면 된다.{Key} : {Sorted Set : {Value : Score}}
형태로 되어있다.DATE
, Value : MemesId
, Score : LIKE_COUNT
DATE
를 일주일 전 혹은 한달 전을 기준으로 합계를 계산하여, 랭킹을 매기는 방식ZUNION
을 활용하면 Redis에 부하가 가지 않을까?ZUNION
을 활용하면 그냥 합쳐주지만 이라는 시간복잡도가 존재하기 때문에 매 요청마다 ZUNION
을 활용하는 건 좋은 방법같지 않다.ZUNION
을 해두고 이후 조회마다 ZUNION
을 실행하면서 조회를 할까?ZUNION
하여 주간과 월간 Key를 생성하도록 설계했다.@Configuration
@EnableRedisRepositories
// Transaction을 활용할 것을 어노테이션을 통해서 지정해준다.
**@EnableTransactionManagement**
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
// Lettuce 라이브러리를 통해서 Redis와 연결합니다.
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port));
}
// RedisTemplate로 저장할 Redis의 Key와 Value의 직렬화, 역직렬화할 형식을 지정합니다.
@Bean(name = "rankingRedisTemplate")
public RedisTemplate<String, Long> rankingRedisTemplate() {
RedisTemplate<String, Long> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
**redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer(Long.class));**
// Redis을 @Transactional 지원하도록 한 설정입니다.
redisTemplate.setEnableTransactionSupport(true);
**redisTemplate.setConnectionFactory(redisConnectionFactory());**
return redisTemplate;
}
// Redis Sorted Set을 사용하기 위한 ZSetOperations 객체를 반환하는 Bean 입니다.
@Bean(name = "rankingZSetOperations")
public ZSetOperations<String, Long> rankingZSetOperations(
@Qualifier("rankingRedisTemplate") RedisTemplate<String, Long> redisTemplate) {
return redisTemplate.opsForZSet();
}
// 'Transaction management는 PlatformTransactionManager를 필요로 하지만,
// Spring Data Redis는 PlatformTransactionManager의 구현체를 포함하고 있지 않다.
// 다른 구현체인 JpaTransactionManager를 빌려서 Transaction을 설정했다.
@Bean
public PlatformTransactionManager transactionManager() {
return new JpaTransactionManager();
}
}
Lettuce
를 활용한 Redis 에 접근을 위한 Bean
설정 코드Redis
에 접근할 때 직렬화와 역직렬화를 설정할 RedisTemplate
설정 후 반환 코드Sorted Set
을 제어하는 ZSetOperations
객체 반환 코드Redis는 롤백을 지원하지 않는다.
단지 명령어를 하나의 큐에 넣고 큐에 쌓인 명령어를 실행되는 동안 다른 명령어가 실행되지 않음을 보장할 뿐이다.
Redis에 대한 테스트를 진행할 때는, 롤백을 통해 Redis가 테스트 전과 후가 같은 상황을 기대할 수 없습다. 따라서 배포 환경과 테스트 환경의 Redis를 분리하고 Redis의 단위 테스트 시작전에,
redisConnectionFactory.getConnection().flushAll();
로 Redis를 비우는 작업을 하여 단위 테스트를 진행하고 있다.
ZSetOperations
의 메서드들의 공식문서이다.ZSetOperations (Spring Data Redis 3.3.0 API)
@RequiredArgsConstructor
@Service
public class RankingService {
private final String PREFIX = "LIKE_RANKING_DATE::";
private final String POSTFIX_WEEK = "::WEEK";
private final String POSTFIX_MONTH = "::MONTH";
private final Long TOP_TEN = 9L;
private final ZSetOperations<String, Long> rankingZSet;
@Transactional
public void increaseTodayMemesLikeCountFromMemesId(Long memesId) {
String key = PREFIX + LocalDate.now();
increaseMemesLikeCountForToday(key, memesId);
increaseMemesLikeCountForLastWeek(key, memesId);
increaseMemesLikeCountForLastMonth(key, memesId);
}
private void increaseMemesLikeCountForToday(String key, Long memesId) {
rankingZSet.incrementScore(key, memesId, 1);
}
private void increaseMemesLikeCountForLastWeek(String key, Long memesId) {
key += POSTFIX_WEEK;
unionMemesIfKeyNotExists(key, 7);
rankingZSet.incrementScore(key, memesId, 1);
}
private void increaseMemesLikeCountForLastMonth(String key, Long memesId) {
key += POSTFIX_MONTH;
unionMemesIfKeyNotExists(key, 30);
rankingZSet.incrementScore(key, memesId, 1);
}
private void unionMemesIfKeyNotExists(String key, int day) {
if (isNotExistedKey(key)) {
unionMemesFromKeyAndDay(key, day);
}
}
private boolean isNotExistedKey(String key) {
Set<Long> check = rankingZSet.range(key, 0, 1);
return check.isEmpty();
}
private void unionMemesFromKeyAndDay(String key, int day) {
List<String> keyList = new ArrayList<>();
LocalDate today = LocalDate.now();
for (int i = 1; i < day; i++) {
LocalDate date = today.minusDays(i);
keyList.add(PREFIX + date);
}
rankingZSet.unionAndStore(key, keyList, key);
}
@Transactional
public void decreaseTodayMemesLikeCountFromMemesId(Long memesId) {
String key = PREFIX + LocalDate.now();
decreaseMemesLikeCountForToday(key, memesId);
decreaseMemesLikeCountForLastWeek(key, memesId);
decreaseMemesLikeCountForLastMonth(key, memesId);
}
private void decreaseMemesLikeCountForToday(String key, Long memesId) {
rankingZSet.incrementScore(key, memesId, -1);
}
private void decreaseMemesLikeCountForLastWeek(String key, Long memesId) {
key += POSTFIX_WEEK;
unionMemesIfKeyNotExists(key, 7);
rankingZSet.incrementScore(key, memesId, -1);
}
private void decreaseMemesLikeCountForLastMonth(String key, Long memesId) {
key += POSTFIX_MONTH;
unionMemesIfKeyNotExists(key, 30);
rankingZSet.incrementScore(key, memesId, -1);
}
@Transactional(readOnly = true)
public List<MemesRankDto> findTopTenMemesLikeCountForWeek() {
String key = PREFIX + LocalDate.now() + POSTFIX_WEEK;
unionMemesIfKeyNotExists(key, 7);
Set<ZSetOperations.TypedTuple<Long>> rankTuple = rankingZSet.reverseRangeWithScores(key, 0, TOP_TEN);
List<MemesRankDto> result = rankTuple.stream().map(MemesRankDto::of).toList();
return result;
}
@Transactional(readOnly = true)
public List<MemesRankDto> findTopTenMemesLikeCountForMonth() {
String key = PREFIX + LocalDate.now() + POSTFIX_MONTH;
unionMemesIfKeyNotExists(key, 30);
Set<ZSetOperations.TypedTuple<Long>> rankTuple = rankingZSet.reverseRangeWithScores(key, 0, TOP_TEN);
List<MemesRankDto> result = rankTuple.stream().map(MemesRankDto::of).toList();
return result;
}
}
클린 코드를 읽고나서 코드와 사이드 이펙트와 같은 점을 곰곰히 생각하면서 작성한 첫 코드다. 당장은 나의 최선이라고 생각하는 코드지만, 나중에 혹은 당장 내일 다시보면 더럽다고 느껴질 수 있을 것 같다. 지금도 중복과 상수를 좀 더 깔끔하게 표현하는 방법이 있을까에 대한 고민을 하고 있다. 코드가 작동하기만 하면 그냥 넘어갔던 과거에 비해 변수명부터 메서드명까지 하나하나 고민하면서 짠 코드라는 점에서 성장한 것 같아서 기분이 좋기도하다.
계속해서 클린코드를 지향하면서 코드 자체가 문서가 될 수 있도록 발전하는 것이 나의 목표다
개발자는 작가다. 작가는 독자를 이해시킬 필요가 있다.