인기 게시글 조회 API 작성

송선권·2024년 2월 8일
1
post-thumbnail

배경

코인 마이그레이션 중 인기 게시글 조회 API를 작성하면서 학습한 내용을 간단하게 정리한다.

1. 저장 방식 선정

현재 게시글 조회수는 MySQL에 기록하고 있다. 따라서 "DB에 저장된 조회수를 기반으로 인기 게시글 조회를 수행하면 되지 않을까?" 라고 생각했다. 하지만 자주 바뀌는 인기 게시글의 특성 상 RDB에 자주 접근하는 것은 비효율적으로 보였다. 그래서 접근 비용이 비교적 저렴한 Redis를 사용하기로 했다. 이 고민에 대한 자세한 내용은 아래 사진을 참고하자.


(위 글은 팀원들과의 논의 과정에서 작성한 내용이다.)

2. DB 접근 방식 선정

Spring에서 Redis에 접근하는 방법은 크게 RedisTemplate을 사용하는 방법과 RedisRepository를 사용하는 방법으로 나뉜다. 이 부분은 어느 것을 골라도 무방하게 보인다. 그리고 실제로도 평범한 저장 용도로 사용한다면 취향에 맞게 고르면 된다.

Spring Data Redis - RedisTemplate

RedisTemplate은 뭔가 "전통적인" 방법으로 보였다. 다양한 자료구조는 물론 Redis에서 제공하는 거의 모든 기능을 사용할 수 있는 것 같다. 하지만 사전 설정을 해야 하거나 Data Access 로직을 작성하는 과정이 번거로울 수 있다.

Spring Data Redis - RedisRepository

Spring Data RedisSpring Data JPA와 같이 묶인 거대한 Spring Data 프로젝트 중 하나이다. 그래서 JPA Repository 인터페이스를 사용해본 적이 있다면 아주 익숙하고 간결하게 사용할 수 있다. 또한 엔티티별로 유효 기간(TTL, Time To Live)을 설정할 수 있다. 하지만 Redis의 다양한 자료구조를 지원하지 않는다는 단점이 있다.

원래라면 당연히 Spring Data Redis를 사용했겠지만 이번에는 주제 특성 상 많은 고민을 했다.

Sorted Set

이번 주제는 인기 게시글 조회인데, 이에 적절한 Sorted Set이라는 Redis 자료구조가 존재한다. 이 자료구조는 하나의 KEY에 대해 여러 VALUE들이 각자의 SCORE를 기준으로 자동정렬된다. 따라서 랭킹을 구현할 때 용이하게 사용된다. 이번 API에서도 게시글 조회량 순위가 필요하기 때문에 해당 자료구조를 사용하고자 했다.

Sorted Set을 사용하기 위해서는 Spring Data Redis 대신 RedisTemplate을 사용해야 한다. 여기까지는 별 문제가 되지 않았으나 큰 문제가 하나 있다. Sorted Set은 각 요소에 대해 TTL을 기입하는 기능을 제공하지 않는다. 그래서 내가 구상했던 구조로는 구현이 불가능했다.

어떤 구조로 구현할 지 고민한 결과 크게 3가지로 분류할 수 있었다.

  1. Sorted Set 자료구조를 사용한다. 단, 유효기간을 설정할 별도의 방법을 적용한다. (참고 자료)
    1. Sorted Set에는 게시글 ID와 조회수(score)를 두고, 별도의 자료구조에 게시글 ID와 만료시간을 저장한다. 이후 만료시간이 지나면 Sorted Set에서 해당 게시글 score를 -1한다.
    2. Sorted Set에 데이터를 저장할 때 게시글 ID, 조회수와 함께 만료시간을 끼워넣는다. 이후 문자열을 분리하여 만료시간이 지난 경우 score를 -1한다.
  2. 기존 리프레시 토큰 저장 방법과 같이 @RedisHash 형태로 구현한다.

자세한 내용은 다음 사진을 참고하자. (원본 글)

논의 끝에 RedisRepository(@RedisHash 방식)으로 진행하기로 했다. 다만 구현 후 벤치마킹을 하여 성능 상 큰 이슈가 발생하면 그 때 개선하는 방향으로 잡았다.

RedisRepository 사용 방법

RedisRepository(@RedisHash) 방식을 사용한다면 CrudRepository 등을 사용하게 될 텐데, 관련된 API에 대한 자세한 사용법은 이 곳에서 확인할 수 있다.

3. 로직 구성

Redis Key 형태


키 명에 게시글 id를 넣어 분류(@RedisHash에서 제공)하고, 그 내용으로 조회수를 저장하고 조회수의 만료시각을 배열로 저장한다.

누군가가 게시글을 조회하면 해당 키의 조회수가 1 증가하고 그와 함께 만료 시각을 배열 요소로 추가하여 저장한다. 이후에 만료 시각이 다 되면 서버에서 자동으로 조회수를 1 감소시키고 해당 만료 시각을 제거한다.

만료된 조회 로그 정리

내가 구상한 자료구조는 만료 시각을 레디스 키별로 할당하는 것이 아니기 때문에 만료 시각 기입이 불가능하다. 따라서 Scheduler를 두고 일정 시각마다 자동으로 만료 점검을 진행하도록 로직을 작성했다.

@Component  
@RequiredArgsConstructor  
public class ValidationScheduler {  
  
    private final CommunityService communityService;  
  
    @Scheduled(cron = "0 0 * * * *") /* every hour */  
    public void validateHits() {  
        communityService.validateHits();  
    }  
}

실행 시간 간격은 cron 표현식을 사용하여 표현했다.

public void validateHits() {  
    List<HotArticle> hotArticles = hotArticleRepository.findAll();  
    hotArticles.forEach(HotArticle::validate);  
    hotArticles.forEach(hotArticle -> {  
        if (hotArticle.isEmpty()) {  
            hotArticleRepository.deleteById(hotArticle.getId());  
        } else {  
            hotArticleRepository.save(hotArticle);  
        }  
    });  
}
  1. Redis에 기록된 최근 접속 기록을 전부 가져온다. (기록 유효기간: 1일)
  2. 만료된 기록 수만큼 조회수를 낮춘다. (HotArticle::validate)
  3. 남은 조회수가 0이라면(hotArticle.isEmpty()) Redis에서 해당 키를 제거한다.
  4. 남은 조회수가 1 이상이라면 변화된(유효기간 처리가 끝난) 값을 저장한다.

4. 결과 출력

인기 게시글 조회 기능은 항상 10개의 글을 반환해야 한다. 심지어는 최근 올라온 모든 게시글의 조회수가 0이어도 말이다. 따라서 Redis에서 가져온 최근 접속 기록과 함께 최근에 등록된 게시글을 불러왔다. 이를 통해 조회수가 1 이상인 게시글이 10개 미만이더라도 가장 최근에 등록된 게시글이 자동으로 이어붙여지도록 구현했다.

public List<HotArticleItemResponse> getHotArticles() {  
    List<Long> articles = getRecentlyArticlesId();  
    List<Long> hotArticlesId = getHotArticlesId();  
    hotArticlesId.addAll(articles);  
  
    return hotArticlesId.stream()  
        .distinct()  
        .limit(HOT_ARTICLE_LIMIT) // HOT_ARTICLE_LIMIT = 10
        .map(articleRepository::getById)  
        .map(HotArticleItemResponse::from)  
        .toList();  
}
  1. 최근 등록된 게시글 중 일부를 가져온다.
  2. Redis에 기록된 최근 접속 기록을 전부 가져온다.
  3. Redis 접속 기록 List 끝에 최근 등록된 게시글 List를 이어붙인다.
  4. 중복을 제거하고 앞에서부터 10개를 잘라낸다.
  5. DTO에 담아 반환한다.

5. 성능 검사

아무래도 현재 로직은 성능과는 거리가 멀어 보인다. Redis의 모든 키를 불러와서 직접 정렬하는 것이 너무 무거울 것 같다. 그래도 실사용이 가능할지 성능 검사를 진행해보았다.

DB 접근 방식에 대해 고민할 때 팀원 한 명이 "사용자가 1억명이라면?" 이라는 이야기를 해주었다. 그래서 Redis에 1억 개의 데이터를 넣고 API를 돌려보기로 했다.

비즈니스 로직 동작 시간
개수: 10000, 걸린 시간: 1.205(s)
개수: 100000, 걸린 시간: 8.831(s)
개수: 1000000, 걸린 시간: 81.534(s)

아무리 로컬 환경이라지만 1억 개는 테스트 자체가 불가능할 것 같아 100만개 까지만 수행해보았다... 😢

위안을 삼자면, 트래픽 통계 사이트를 방문한 결과 분야별로 국내 5위급 사이트의 트래픽이 일평균 100만이었다. 현재 Redis 토큰 유효기간이 1일이니 한 번에 최대 100만 개의 토큰이 쌓인다면 80초 안에 해결이 가능하다. 또한 해당 로직은 만료 검사와 함께 1시간에 1회 정도만 수행해도 괜찮을 것이다.

6. 회고

생각했던 것보다도 많이 느린 것 같다. 확실히 성능 개선이 필요해 보인다. 하지만 이번 작업은 현재 프로젝트에서 거의 사용되지 않는 우선순위가 매우 낮은 API이기 때문에 일단은 보류해두기로 했다.

성능과는 별개로 이번 작업을 진행하면서 개인적으로 Redis와 Spring Data Redis에 대한 이해도가 많이 올라 만족스럽다.

참고 자료

https://github.com/BCSDLab/KOIN_API_V2/discussions/177
https://www.baeldung.com/spring-data-redis-tutorial

0개의 댓글