카테고리 없음

캐싱을 통한 인기글 조회 API 성능 개선

olsohee 2024. 5. 9. 16:44

인기글 선별

프로젝트에서 구현한 기능 중 인기글 조회 기능이 있다. 그런데 인기글 조회 API는 자주 호출된다는 특징이 있으므로, 캐싱을 적용해봤다.

 

인기글에 대한 캐싱을 적용하기 전에, 우선 인기글은 어떻게 정의할까? 인기글은 한시간 동안 조회수가 가장 많은 10개의 게시글이다. 그리고 인기글을 선별하기 위해 레디스를 사용했는데, 그 방식은 다음과 같다.

  • 게시글 조회시 조회수를 레디스에 반영한다.
  • 이때 레디스에 저장되는 데이터 형식은 다음과 같다.
    • key: 날짜와 시간이 String 형태로 저장된다.
    • value: 한시간 동안 조회된 게시글 id들이 sortes set 형태로 저장된다. 이때 정렬 기준은 조회수이다.

다음 사진을 예로 들면, key는 "2024-05-09T16:00"가 되고, 그 값은 게시글 id인 "1", "3", "2"이다. 이때 게시글 조회수를 기준으로 id가 오름차순 정렬되어 있다.

인기글 조회 API에 캐싱 도입

캐싱 도입 전

캐싱을 도입하기 전에도 마찬가지로, 게시글을 조회할 때마다 조회수가 레디스에 저장된다. 즉, 한시간 동안 조회되는 게시글의 id들이 조회수 순으로 sorted set 형태로 레디스에 저장된다. 

 

그리고 인기글 조회 요청이 오면, previousTime 변수에 해당하는 key에 저장된 게시글 id들 중 조회수가 높은 10개의 id를 조회한다. 그리고 10개의 게시글 id를 기반으로 DB에서 데이터를 조회해온 후 dto로 변환하여 반환한다. 

 

즉, 인기글 조회 요청에는 캐싱을 적용하지 않았기 때문에, 인기글 조회 요청이 올 때마다 매번 DB에 접근한다

// 인기글 조회 요청이 올 때마다 호출되는 메소드
public ReadPostListResponse readBestPosts() {

    // previousTime 변수에 해당하는 key에 저장된 게시글 id들 중 조회수가 높은 10개의 id를 조회
    Set<String> postIds = stringRedisTemplate.opsForZSet().reverseRange(previousTime, 0, 9);

    // 게시글 id에 해당하는 데이터를 DB에서 조회한 후 dto 변환
    List<ReadPostListResponse.ReadPostResponseInList> bestPostDtos = postIds.stream()
            .map(postId -> {
                Post post = postRepository.findById(Long.valueOf(postId))
                        .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));
                UserDto postUserDto = userService.getUserDto(post.getEmail());
                int bookmarkCount = bookmarkRepository.findByPost(post).size();
                return new ReadPostListResponse.ReadPostResponseInList(postUserDto, post, bookmarkCount);
            })
            .toList();

    return new ReadPostListResponse(bestPostDtos.size(), bestPostDtos);
}

캐싱 도입 후

캐싱을 도입하면, 매 시간 정각마다 saveBestPostsDtoInRedis 메소드가 실행되며, previousTime 변수에 해당하는 key에 저장된 게시글 id들 중 조회수가 높은 10개의 id를 조회한 후 각 게시글을 DB에서 조회해와서 ReadPostListResponse라는 하나의 dto를 생성하여 레디스에 저장해둔다.

 

따라서 인기글 조회 요청이 올 때마다 DB에 접근할 필요 없이 레디스에 캐싱된 데이터가 반환된다.

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class BestPostCacheService {

    private final UserService userService;
    private final RedisTemplate<String, String> stringRedisTemplate;
    private final RedisTemplate<String, ReadPostListResponse> readPostListResponseRedisTemplate;
    private final PostRepository postRepository;
    private final BookmarkRepository bookmarkRepository;
    private String currentTime;
    private String previousTime;
    private static final String BEST_POSTS = "best_posts";

    @PostConstruct
    public void initTime() {
        LocalDateTime now = LocalDateTime.now();
        this.previousTime = this.currentTime = LocalDateTime.of(now.getYear(), now.getMonth(), now.getDayOfMonth(), now.getHour(), 0, 0).toString();
    }

    public void reflectPostViewsInRedis(long postId) {
        stringRedisTemplate.opsForZSet().incrementScore(currentTime, String.valueOf(postId), 1);
    }

    public ReadPostListResponse readBestPosts() {
        return readPostListResponseRedisTemplate.opsForValue().get(BEST_POSTS);
    }

    @Scheduled(cron = "0 * * * * *")
    public void saveBestPostsDtoInRedis() {
        updateTime();

        ReadPostListResponse bestPostsDto = generateBestPostsDto();

        if (readPostListResponseRedisTemplate.hasKey(BEST_POSTS)) {
            readPostListResponseRedisTemplate.delete(BEST_POSTS);
        }

        // 인기글 dto를 레디스에 저장
        readPostListResponseRedisTemplate.opsForValue().set(BEST_POSTS, bestPostsDto);
    }

    private ReadPostListResponse generateBestPostsDto() {
        // previousTime 변수에 해당하는 key에 저장된 게시글 id들 중 조회수가 높은 10개의 id를 조회 및 삭제
        Set<String> postIds = stringRedisTemplate.opsForZSet().reverseRange(previousTime, 0, 9);
        stringRedisTemplate.delete(previousTime);

        // 게시글 id에 해당하는 데이터를 DB에서 조회한 후 dto 변환
        List<ReadPostListResponse.ReadPostResponseInList> bestPostDtos = postIds.stream()
                .map(postId -> {
                    Post post = postRepository.findById(Long.valueOf(postId))
                        .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));
                    UserDto postUserDto = userService.getUserDto(post.getEmail());
                    int bookmarkCount = bookmarkRepository.findByPost(post).size();
                    return new ReadPostListResponse.ReadPostResponseInList(postUserDto, post, bookmarkCount);
                })
                .toList();

        return new ReadPostListResponse(bestPostDtos.size(), bestPostDtos);
    }

    private void updateTime() {
        previousTime = currentTime;
        LocalDateTime now = LocalDateTime.now();
        currentTime = LocalDateTime.of(now.getYear(), now.getMonth(), now.getDayOfMonth(), now.getHour(), now.getMinute(), now.getSecond()).toString();
    }
}

 

성능 비교

성능을 비교하기 위해 테스트 데이터를 넣고, 캐싱 적용 전과 적용 후 모두 동일하게 게시글을 조회했다. 그리고 포스트맨으로 인기글 조회 API를 각각 10번씩 요청하여 그 응답 속도의 평균을 냈다. 

 

그 결과, 캐싱 적용 전에는 응답 속도가 평균 47.3ms, 캐싱 적용 후에는 응답 속도가 평균 7.7ms가 나왔다. 즉, 조회 성능이 약 83.7% 개선되었다.

추가 고려사항

캐싱할 때 추가로 고려해야 할 점은 데이터 정합성이다. 캐싱되는 데이터로 다음과 같은 데이터가 있다.

  • userName
  • title
  • category
  • createdAt
  • updatedAt
  • bookmarkCount 
  • views 

이때 특히나 update가 잦은 값은 bookmarkCount와 views이다. 그런데 값이 update 된다고 해서, 매번 update 된 값을 반영하기 위해 캐싱된 데이터를 수정하면, 이는 캐싱의 의미가 사라진다. 인기글의 특성상 조회수와 북마크 수는 update가 매우 잦기 때문이다. 그뿐만 아니라 이 값들은 당장 반영되지 않더라도 비즈니스적으로 크게 문제가 없는 데이터들이다. 따라서 이 부분에 대해서는 데이터 정합성을 고려하지 않고, 캐싱된 데이터에 update 값을 반영하지 않았다. (물론 DB에는 반영된다.)