코인 마이그레이션 중 인기 게시글 조회 API를 작성하면서 학습한 내용을 간단하게 정리한다.
현재 게시글 조회수는 MySQL에 기록하고 있다. 따라서 "DB에 저장된 조회수를 기반으로 인기 게시글 조회를 수행하면 되지 않을까?" 라고 생각했다. 하지만 자주 바뀌는 인기 게시글의 특성 상 RDB에 자주 접근하는 것은 비효율적으로 보였다. 그래서 접근 비용이 비교적 저렴한 Redis를 사용하기로 했다. 이 고민에 대한 자세한 내용은 아래 사진을 참고하자.
(위 글은 팀원들과의 논의 과정에서 작성한 내용이다.)
Spring에서 Redis에 접근하는 방법은 크게 RedisTemplate
을 사용하는 방법과 RedisRepository
를 사용하는 방법으로 나뉜다. 이 부분은 어느 것을 골라도 무방하게 보인다. 그리고 실제로도 평범한 저장 용도로 사용한다면 취향에 맞게 고르면 된다.
RedisTemplate
은 뭔가 "전통적인" 방법으로 보였다. 다양한 자료구조는 물론 Redis에서 제공하는 거의 모든 기능을 사용할 수 있는 것 같다. 하지만 사전 설정을 해야 하거나 Data Access 로직을 작성하는 과정이 번거로울 수 있다.
Spring Data Redis
는 Spring Data JPA
와 같이 묶인 거대한 Spring Data
프로젝트 중 하나이다. 그래서 JPA Repository 인터페이스를 사용해본 적이 있다면 아주 익숙하고 간결하게 사용할 수 있다. 또한 엔티티별로 유효 기간(TTL, Time To Live)을 설정할 수 있다. 하지만 Redis의 다양한 자료구조를 지원하지 않는다는 단점이 있다.
원래라면 당연히 Spring Data Redis
를 사용했겠지만 이번에는 주제 특성 상 많은 고민을 했다.
이번 주제는 인기 게시글 조회인데, 이에 적절한 Sorted Set이라는 Redis 자료구조가 존재한다. 이 자료구조는 하나의 KEY에 대해 여러 VALUE들이 각자의 SCORE를 기준으로 자동정렬된다. 따라서 랭킹을 구현할 때 용이하게 사용된다. 이번 API에서도 게시글 조회량 순위가 필요하기 때문에 해당 자료구조를 사용하고자 했다.
Sorted Set을 사용하기 위해서는 Spring Data Redis
대신 RedisTemplate
을 사용해야 한다. 여기까지는 별 문제가 되지 않았으나 큰 문제가 하나 있다. Sorted Set은 각 요소에 대해 TTL을 기입하는 기능을 제공하지 않는다. 그래서 내가 구상했던 구조로는 구현이 불가능했다.
어떤 구조로 구현할 지 고민한 결과 크게 3가지로 분류할 수 있었다.
@RedisHash
형태로 구현한다.자세한 내용은 다음 사진을 참고하자. (원본 글)
논의 끝에 RedisRepository(@RedisHash 방식)
으로 진행하기로 했다. 다만 구현 후 벤치마킹을 하여 성능 상 큰 이슈가 발생하면 그 때 개선하는 방향으로 잡았다.
RedisRepository(@RedisHash)
방식을 사용한다면 CrudRepository
등을 사용하게 될 텐데, 관련된 API에 대한 자세한 사용법은 이 곳에서 확인할 수 있다.
키 명에 게시글 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);
}
});
}
HotArticle::validate
)hotArticle.isEmpty()
) Redis에서 해당 키를 제거한다.인기 게시글 조회 기능은 항상 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();
}
아무래도 현재 로직은 성능과는 거리가 멀어 보인다. 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회 정도만 수행해도 괜찮을 것이다.
생각했던 것보다도 많이 느린 것 같다. 확실히 성능 개선이 필요해 보인다. 하지만 이번 작업은 현재 프로젝트에서 거의 사용되지 않는 우선순위가 매우 낮은 API이기 때문에 일단은 보류해두기로 했다.
성능과는 별개로 이번 작업을 진행하면서 개인적으로 Redis와 Spring Data Redis에 대한 이해도가 많이 올라 만족스럽다.
https://github.com/BCSDLab/KOIN_API_V2/discussions/177
https://www.baeldung.com/spring-data-redis-tutorial