본문 바로가기
💻 뚝딱뚝딱/북북클럽

[개발일지 #028] 좋아요 순 피드 조회 (주간/월간/연간/누적)

by 뚜루리 2025. 5. 1.
728x90
320x100

🎯 오늘의 목표

  • 좋아요 순 피드 조회 (주간/월간/연간/누적)

⚙️ 진행한 작업

  • 좋아요 순 피드 조회 (주간/월간/연간/누적)

📌  좋아요 순 피드 조회하는 방법

1️⃣ DB 직접 카운트 + 정렬

  • 방식: SELECT * FROM feed ORDER BY like_count DESC
  • 장점:
    • 간단하고 JPA, QueryDSL로 쉽게 구현 가능
    • 별도 인프라(Redis) 없이 DB만으로 해결
  • 단점:
    • 피드가 많아질수록 ORDER BY like_count DESC 쿼리 부담 커짐
    • 실시간으로 TOP N 조회할 때 성능 이슈 발생
    • 좋아요수가 자주 변하면 정렬 부담 증가

2️⃣ Like 테이블 집계 쿼리 + 정렬

  • 방식 : SELECT feed_id, COUNT(*) FROM like GROUP BY feed_id ORDER BY COUNT(*) DESC
  • 장점:
    • Feed 엔티티에 like_count 컬럼 안 넣어도 됨
    • 집계 쿼리로 동작
  • 단점:
    • 여전히 매 요청마다 COUNT 계산 필요
    • 대량 데이터에서 성능 불안정

3️⃣ Redis Sorted Set(ZSet) 활용 

  • 방식: 좋아요 발생 시 ZINCRBY로 Redis에 count 업데이트 → 조회 시 ZREVRANGE로 TOP N 조회
  • 장점:
    • 실시간 카운트 + 랭킹 관리 가능
    • DB 부하 최소화, 매우 빠른 조회
    • 주간/월간/연간/누적 랭킹 확장 가능
  • 단점:
    • Redis 설치·운영 필요
    • DB와 데이터 싱크 관리 필요 (초기화, 동기화 고려)
    • 코드 복잡도 약간 증가

나는 Redis Sorted Set(ZSet) 방식을 선택함

  • 이유:
    • 좋아요순, 주간·월간·누적 랭킹까지 확장할 계획
    • QueryDSL만으로는 대용량 처리 성능에 한계가 있어서 Redis로 실시간 카운트를 관리하고 싶음
    • 실무에서 Redis 랭킹 시스템은 매우 많이 사용되므로 학습 경험이 필요함
    • Kafka로 확장할 계획이 있으므로 Redis 기반부터 잡아두는 게 유리함

 


🛠️ 개발내용

📌  RankingFeedService.java 생성

package ddururi.bookbookclub.domain.feed.service;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Set;

/**
 * 피드 랭킹 서비스 (좋아요순, 조회수순 관리)
 */
@Service
@RequiredArgsConstructor
public class RankingFeedService {

    private final RedisTemplate<String, String> redisTemplate;

    /**
     * 좋아요 카운트 증가/감소
     *
     * @param period weekly, monthly, yearly, total
     * @param feedId 피드 ID
     * @param increment 증가(+1), 감소(-1)
     */
    public void incrementLike(String period, Long feedId, int increment) {
        String redisKey = getLikeKey(period);
        redisTemplate.opsForZSet().incrementScore(redisKey, feedId.toString(), increment);
    }

    /**
     * 좋아요 TOP N 피드 조회
     */
    public List<Long> getTopLikedFeeds(String period, int topN) {
        String redisKey = getLikeKey(period);
        Set<String> feedIds = redisTemplate.opsForZSet()
                .reverseRange(redisKey, 0, topN - 1);
        return feedIds.stream().map(Long::valueOf).toList();
    }

    /**
     * Redis 키 매핑
     */
    private String getLikeKey(String period) {
        return switch (period.toLowerCase()) {
            case "weekly" -> "popular:feed:like:weekly";
            case "monthly" -> "popular:feed:like:monthly";
            case "yearly" -> "popular:feed:like:yearly";
            case "total" -> "popular:feed:like:total";
            default -> throw new IllegalArgumentException("Invalid period: " + period);
        };
    }
}

 

 

📌  LikeService.java 수정

package ddururi.bookbookclub.domain.like.service;

/**
 * 좋아요(Like) 관련 비즈니스 로직 처리 서비스
 */
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LikeService {

    private final LikeRepository likeRepository;
    private final FeedRepository feedRepository;
    private final RankingFeedService rankingFeedService;

    /**
     * 좋아요 토글 기능
     * - 이미 좋아요 되어 있으면 삭제 (취소)
     * - 좋아요 안 되어 있으면 생성
     *
     * @param user 사용자
     * @param feedId 피드 ID
     * @return 토글 후 상태 (true: 좋아요됨, false: 좋아요 취소됨)
     */
    @Transactional
    public boolean toggleLike(User user, Long feedId) {
        // 피드 존재 여부 확인
        Feed feed = feedRepository.findById(feedId)
                .orElseThrow(() -> new LikeException(ErrorCode.FEED_NOT_FOUND));

        // 좋아요가 이미 존재하면 삭제, 없으면 생성
        return likeRepository.findByUserIdAndFeedId(user.getId(), feedId)
                .map(existingLike -> {
                    likeRepository.delete(existingLike);
                    // Redis 좋아요 카운트 -1
                    rankingFeedService.incrementLike("weekly", feedId, -1);
                    rankingFeedService.incrementLike("monthly", feedId, -1);
                    rankingFeedService.incrementLike("yearly", feedId, -1);
                    rankingFeedService.incrementLike("total", feedId, -1);
                    return false;
                })
                .orElseGet(() -> {
                    likeRepository.save(Like.create(user, feed));
                    // Redis 좋아요 카운트 +1
                    rankingFeedService.incrementLike("weekly", feedId, 1);
                    rankingFeedService.incrementLike("monthly", feedId, 1);
                    rankingFeedService.incrementLike("yearly", feedId, 1);
                    rankingFeedService.incrementLike("total", feedId, 1);
                    return true;
                });
    }

}

 

 

📌  FeedController.java 수정

package ddururi.bookbookclub.domain.feed.controller;
/**
 * 피드 관련 API 컨트롤러
 * - 작성, 수정, 삭제, 단건 조회, 목록 조회
 */
@RestController
@RequestMapping("/api/feeds")
@RequiredArgsConstructor
public class FeedController {

    private final FeedService feedService;
    private final RankingFeedService rankingFeedService;

    //코드생략//

    /**
     * 좋아요 랭킹 조회 API
     * @param period weekly, monthly, yearly, total
     * @param topN 가져올 피드 수 (기본값 10)
     * @return 좋아요 TOP N 피드 ID 리스트
     */
    @GetMapping("/popular/like")
    public ApiResponse<?> getPopularByLike(
            @RequestParam String period,
            @RequestParam(defaultValue = "10") int topN) {
        return ApiResponse.success(rankingFeedService.getTopLikedFeeds(period, topN));
    }

}

 

 


[포스트맨으로 테스트하기]

GET /api/feeds/popular/like?period=weekly&topN=10

 

[테스트 결과 : 성공]

728x90
320x100