백엔드

Redis SortedSet을 사용한 인기글 구현

shoon95 2025. 3. 16. 04:05

많은 커뮤니티가 인기글 서비스를 제공한다.

내가 진행하는 프로젝트에서도 당연히 인기글 서비스를 제공하는데, 인기글 서비스를 구현한 방법을 공유한다.

 

인기글을 구현하는 방법은 되게 다양하게 구현할 수 있다.

이때 우리 서비스에서는 하루 단위로 인기글을 인기글을 초기화 한다는 요구 명세가 존재했다.

 

또한 프로젝트에서 인기글을 계산하는 방식은 조회수, 좋아요, 스터디 지원이 발생했을 때 해당 이벤트에 따라 가중치를 주는 방식으로 인기글 점수를 업데이트 하도록 하였다.

 

인기글 점수 업데이트 시점

1. 조회 발생(어뷰징 제외 실제 조회)

2. 좋아요 발생(+)

3. 좋아요 취소(-)

4. 스터디 지원(+)

5. 스터디 지원 취소(-)

6. 글 삭제(인기글 목록에 존재하면 삭제)

 

이외에도 인기글 갱신 방식은 변경될 수 있기에 추가, 수정, 삭제하기 편한 구조를 유지하려고 노력했다.

 

EventHandler

먼저 다양한 인기글 점수 업데이트를 이벤트 기반으로 받아서 처리할 수 있도록 했다.

위 업데이트 시점에 특정 이벤트를 발생시키고 그에 맞는 EventHandler를 통해 인기글 점수 갱신 과정이 이루어진다.

 

EventType

@RequiredArgsConstructor
@Getter
public enum HotStudyEventType {
    VIEW(1L),
    LIKE(1L),
    LIKE_CANCEL(-1L),
    JOIN(1L),
    JOIN_CANCEL(-1L),
    DELETE(0L);

    private final Long value;
}

먼저 Enum으로 EventType을 관리하며, 해당 enum은 value를 가지고 있다. 여기서 value는 view를 1회 증가, likeCount를 1회 증가 또는 1회 감소 등을 처리할 수 있도록 값을 넣어두었다.

 

EventHandler

  • EventHandler(interface)
public interface EventHandler {
    void handle(Long studyId, HotStudyEventType type);
    List<HotStudyEventType> supportList();
}
  • LikeEventHandler(구현체)
@Component
@RequiredArgsConstructor
public class LikeEventHandler implements EventHandler{

    private final HotStudyLikeRepository hotStudyLikeRepository;

    @Override
    public void handle(Long studyId, HotStudyEventType type) {
        hotStudyLikeRepository.createOrUpdate(studyId, type.getValue(), TimeCalculator.calculateDurationToMidNight());
    }

    @Override
    public List<HotStudyEventType> supportList() {
        return new ArrayList<>(List.of(HotStudyEventType.LIKE, HotStudyEventType.LIKE_CANCEL));
    }
}


특정 EventType의 Event가 발생했을 때 처리할 수 있는 EventHandler는 다음과 같은 두 메서드를 가지고 있다.

 

1. handle : studyId,와 EventType을 전달 받아 인기글 관련 로직 처리

2. supportList : 해당 EventHandler가 처리할 수 있는 EventType의 List를 반환

 

각 EventHandler는 EventType에 맞는 작업을 수행하는데 수행 내용은 다음과 같다.

 

1. 조회 발생 -> redis에 해당 studyId의 조회 수 업데이트하는 메서드 호출 

  • HOT-STUDY::STUDY::{studyId}::VIEW-COUNT : value +1

2. 좋아요 요청 또는 취소 -> redis에 해당 studyId의 좋아요 수 업데이트하는 메서드 호출

  • HOT-STUDY::STUDY::{studyId}::LIKE-COUNT : value +1

3. 스터디 지원 또는 취소 -> redis에 해당 studyId의 스터디 지원 수 업데이트하는 메서드 호출

  • HOT-STUDY::STUDY::{studyId}::JOIN-COUNT : value +1

4. 스터디 삭제 -> redis에서 해당 스터디가 인기글 목록에 있다면 삭제

public class HotStudyLikeRepository {

    private final RedisTemplate<String, Long> redisTemplate;

    private static final String KEY_FORMAT = "HOT-STUDY::STUDY::%s::LIKE-COUNT";

    public void createOrUpdate(Long studyId, Long count, Duration ttl) {
        String key = generateKey(studyId);
        Boolean isNew = redisTemplate.opsForValue().setIfAbsent(key, 1L, ttl);
        if (Boolean.FALSE.equals(isNew)) {
            Long newValue = redisTemplate.opsForValue().increment(key, count);
            if (newValue < 0) {
                redisTemplate.opsForValue().set(key, 0L, ttl);
            }
        }
    }

    public Long read(Long studyId) {
        Long count = redisTemplate.opsForValue().get(generateKey(studyId));
        return count != null ? count : 0L;
    }

    private String generateKey(Long studyId) {
        return KEY_FORMAT.formatted(studyId);
    }
}

 

각 데이터는 금일 자정 시간까지를 TTL로 설정되어 있어, 하루 단위로 초기화된다.

  • TimeCalculator.calculateDurationToMidNight()
public class TimeCalculator {
    public static Duration calculateDurationToMidNight() {
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime midnight = now.plusDays(1).with(LocalTime.MIDNIGHT);
        return Duration.between(now, midnight);
    }
}

 

이때, 위에서 만든 EventType에 따라 적절한 EventHandler 매핑이 필요한데, 이를 편리하게 할 수 있도록 팩토리 메서드 패턴을 통해 필요한 EventHandler를 반환 받고 바로 사용할 수 있도록 구현했다.

  • EventHandlerDispatcher
@Component
public class HotStudyEventHandlerDispatcher {
    protected static final Map<List<HotStudyEventType>, EventHandler> hotStudyEventHandlerMap = new HashMap<>();

    @Autowired
    public HotStudyEventHandlerDispatcher(List<EventHandler> eventHandlerList) {
        for (EventHandler eventHandler : eventHandlerList) {
            hotStudyEventHandlerMap.put(eventHandler.supportList(), eventHandler);
        }
    }

    public EventHandler getHotStudyEventHandler(HotStudyEventType eventType) {
        if (!supports(eventType)) {
            throw new MosException(StudyErrorCode.INTERNAL_SERVER_ERROR);
        }
        for (Map.Entry<List<HotStudyEventType>, EventHandler> entry : hotStudyEventHandlerMap.entrySet()) {
            if (entry.getKey().contains(eventType)) {
                return entry.getValue();
            }
        }
        return null;
    }

    private boolean supports(HotStudyEventType eventType) {
        for (List<HotStudyEventType> supportedEventTypes : hotStudyEventHandlerMap.keySet()) {
            if (supportedEventTypes.contains(eventType)) {
                return true;
            }
        }
        return false;
    }
}

 

 

실제 사용은 다음과 같이 편리하게 사용할 수 있다.

public void handleEvent(HotStudyEventType type, Long studyId) {
    EventHandler hotStudyEventHandler = hotStudyEventHandlerDispatcher.getHotStudyEventHandler(type);
    hotStudyEventHandler.handle(studyId, type);
    if (isCreateOrUpdateEvent(type)) {
        hotStudyScoreUpdater.update(studyId);
    } else {
        hotStudyRepository.remove(studyId);
    }

}

private boolean isCreateOrUpdateEvent(HotStudyEventType type) {
        return !HotStudyEventType.DELETE.equals(type);
    }

 

HotStudyService 

이제 인기글 서비스 처리 로직을 살펴보면, 기본적인 코드는 위와 같다.

먼저 handleEvent까지 처리할 EventType과 studyId가 전달되면 제일 먼저 EventType을 처리할 수 있는 EventHandler찾고, 반환 받은 EventHandler를 통해 event를 처리한다.

 

이때 eventHandler가 하는 역할은 위 코드에서 공유되었듯이, EventType에 따라 게시글의 조회 수, 좋아요 수, 스터디 지원자 수를 Redis에 업데이트하며 하루 단위로 초기화 한다.

만약, EventType이 게시글 삭제였다면, 인기 게시글 목록에서 해당 게시글을 삭제하도록 한다.

 

이후 Redis에 필요한 데이터가 저장되었다면 이 데이터를 토대로 인기글 점수 업데이트가 진행된다.

 

HotStudyScoreUpdater

인기글 점수 갱신은 HotStudyScoreUpdater에게 위임한다.

  • HotStudyScoreUpdater
@Component
@RequiredArgsConstructor
public class HotStudyScoreUpdater {

    private final HotStudyRepository hotStudyRepository;
    private final HotStudyScoreCalculator hotStudyScoreCalculator;

    private static final long HOT_STUDY_COUNT = 10;
    private static final Duration HOT_STUDY_TTL = Duration.ofDays(3);

    public void update(Long studyId) {
        long score = hotStudyScoreCalculator.calculate(studyId);
        hotStudyRepository.add(studyId, score, HOT_STUDY_COUNT, HOT_STUDY_TTL);
    }
}

 

HotStudyScoreUpdate는 hotStudyScoreCalcualtor를 호출하여 인기글 점수를 계산하고, 계산한 점수를 토대로 인기길 순위를 정한다(hotStudyRepository.add()).

 

이때 hotStudyScoreCalculator는 위에서 Redis에 저장해둔 View, Like, Join의 count를 가져와 각각의 가중치를 곱해서 더하는 방식으로 특정 게시글의 score를 계산한다.

  • HotStudyScoreCalculator
@Component
@RequiredArgsConstructor
public class HotStudyScoreCalculator {
    private final HotStudyViewRepository hotStudyViewRepository;
    private final HotStudyLikeRepository hotStudyLikeRepository;
    private final HotStudyJoinRepository hotStudyJoinRepository;

    private static final long STUDY_VIEW_COUNT_WEIGHT = 1;
    private static final long STUDY_LIKE_COUNT_WEIGHT = 3;
    private static final long STUDY_JOIN_COUNT_WEIGHT = 5;

    public long calculate(Long studyId) {
        Long studyViewCount = hotStudyViewRepository.read(studyId);
        Long studyLikeCount = hotStudyLikeRepository.read(studyId);
        Long studyJointCount = hotStudyJoinRepository.read(studyId);


        return studyViewCount * STUDY_VIEW_COUNT_WEIGHT
                + studyLikeCount * STUDY_LIKE_COUNT_WEIGHT
                + studyJointCount * STUDY_JOIN_COUNT_WEIGHT;
    }
}

 

이렇게 계산된 score를 가지고 이제 인기글 순위를 구해야한다.

인기글 순위 업데이트는 hotStudyRepository에서 이루어진다.

 

hotStudyRepository.add 메서드에는 다음과 같은 값들이 파라미터로 전달된다.

  • studyId : 점수가 업데이트된 게시글 아이디
  • score : 업데이트된 점수
  • HOT_STUDY_COUNT : 인기글 목록을 몇개까지 유지할 것인지
  • HOT_STUDY_TTL : 인기글 목록을 얼마나 오래 Redis에 저장해둘 것인지

hotStudyRepository.add() 메서드는 넘겨받은 파라미터를 활용해 Redist Sorted Set을 통해 score를 기준으로 정렬을 유지한다.

  • hotStudyRepository.add()
public void add(Long studyId, Long score, Long limit, Duration ttl) {
    redisTemplate.executePipelined( (RedisCallback<?>) action -> {
        StringRedisConnection conn = (StringRedisConnection) action;
        String key = generateKey(LocalDateTime.now());
        conn.zAdd(key, score, String.valueOf(studyId));
        conn.zRemRange(key, 0, - limit - 1);
        conn.expire(key, ttl.toSeconds());
        return null;
    });
}

업데이트된 게시글을 넘겨 받아 sorted set에 데이터를 추가한다.

만약 현재 인기글이 10개 존재하고, 인기글 목록은 최대 10개까지만 유지하도록 하는 상황이라고 가정해보자.

  • 현재 인기글 목록 수 : 10개
  • 인기글 목록 최대 수 : 10개

이때 특정 게시글에 조회 또는 좋아요 또는 스터디 지원이 발생하여 인기글 점수가 업데이트 되었다고 해보자.

 

해당 스터디는 업데이트된 점수와 함께 hotStudyRepository.add() 메서드를 통해 Redis Sorted Set으로 전달되게 된다.

이때 Redis Sorted Set에는 기존에 존재하면 인기글 목록 10개 + 새로 업데이트된 게시글 1개, 총 11개가 존재하게 된다.

 

이후 Sorted Set은 score를 기준으로 정렬을 진행하게 되고, 11개의 게시글이 점수순으로 정렬된다(오름차순).

 

이제 정렬된 데이터는 오름차순이기에 뒤에서부터 10개를 짤라내면 가장 점수가 높은 데이터 10개를 유지할 수 있다.

 

이후 인기글 조회시에는 데이터를 score기준으로 뒤집어서 역순으로 내보내면 된다.

public List<Long> readAll() {
    Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet()
            .reverseRangeWithScores(generateKey(LocalDateTime.now()), 0, -1);
    if (tuples == null) {
        return Collections.emptyList();
    }
    return tuples.stream()
            .map(ZSetOperations.TypedTuple::getValue)
            .filter(Objects::nonNull)
            .map(Long::valueOf)
            .toList();
}

 

결론

여러 인기글 갱신 이벤트를 처리하기 위해 각 EventType에 맞는 EventHandler를 두었으며, EventHandlerDispatcher를 통해 필요한 EventHandler 찾아 사용할 수 있도록 하였다.

 

이후 Redis SortedSet을 통해 인기글 상위 10개를 항상 유지할 수 있도록 구현했다.

 

다양한 방식으로 구현할 수 있겠으나 위 방법을 사용하면 실시간으로 인기글 점수를 업데이트하고 반영할 수 있다는 장점이 있다.