[Spring] Redis로 랭킹 시스템 구현하기

yuKeon·2023년 11월 5일
3
post-thumbnail

0. 개요

Graphy 프로젝트를 진행하면서 '이번 주 인기 프로젝트' 기능 구현을 맡았다. Redis의 Sorted Set을 사용하여 조회 수를 기준으로 랭킹을 관리하고, 매주 월요일 오전 6시에 상위 10개 프로젝트를 Redis에 저장한다. 
이번 포스팅은 구현 과정을 기술한다.

개발 환경

Language : Java 11
Framework : Spring Boot 2.7.8 
DataBase : MySQL 8

1. Redis 설정

1.1. build.gradle

...
dependencies {
    ...

    // Redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis:2.3.1.RELEASE'

    ...
}

1.2. application.yml

spring:
  redis:
    port: 6379
    host: localhost

local 환경에서 Redis를 사용하기 때문에 host는 localhost로 지정했다. 다른 서버나 Docker 환경이라면 이에 맞춰 수정한다.

1.3. RedisConfig.java

@EnableRedisRepositories
@Configuration
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private int redisPort;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }
}

Java 진영의 Redis Client 라이브러리는 Jedis와 Lettuce가 있다. Spring Boot 2.0 부터 기본 클라이언트에서 Lettuce가 사용되고, 성능이 더 좋기 때문에 Lettuce로 Redis를 연결한다.

1.4. RedisConfig.java 추가

Spring에서 Redis를 사용하는 방법은 두 가지다. 
첫 번째는 RedisRepository를 사용하는 것이다. 이 방법은 Spring Data JPA를 사용하는 것 처럼,간단하게 엔티티를 관리할 수 있다.

두 번째는 RedisTemplate이다. 이 방법은 Redis의 모든 기능에 접근이 가능하여, 복잡한 연산이나 로직을 수행하기에 적합하다. 또한, Redis의 다양한 데이터 타입(String, List, Set, Sorted Set등)을 다룰 수 있다.

이번 구현에는 RedisTemplate을 사용했다. 랭킹 관리에 Sorted Set이 필요하고 상황에 따라 데이터 타입을 유연하게 선택해야 되기 때문이다.

2. Sorted Set으로 랭킹 관리

프로젝트 상세 조회 API가 호출되면 쿠키 상태에 따라 조회 수를 증가시키고 Sorted Set으로 랭킹을 관리한다. 자세한 구현은 아래와 같다.

2.1. ProjectService.java

@Transactional
public Cookie addViewCount(HttpServletRequest request, Long projectId) {
    Project project = this.getProjectById(projectId);

    Cookie[] cookies = request.getCookies();
    Cookie oldCookie = this.findCookie(cookies, "View_Count");

    if (oldCookie != null) {
        if (!oldCookie.getValue().contains("[" + projectId + "]")) {
            oldCookie.setValue(oldCookie.getValue() + "[" + projectId + "]");
            project.addViewCount();
            redisTemplate.opsForZSet().incrementScore(RANKING_KEY, projectId, 1);
        }
        oldCookie.setPath("/");
        return oldCookie;
    } else {
        Cookie newCookie = new Cookie("View_Count", "[" + projectId + "]");
        newCookie.setPath("/");
        project.addViewCount();
        redisTemplate.opsForZSet().incrementScore(RANKING_KEY, projectId, 1);
        return newCookie;
    }
}

위 메서드는 프로젝트 상세 조회 시 쿠키의 유효성 검사를 통해 조회 수를 증가시키는 메서드다. 로직은 다음과 같다.

쿠키가 존재하는 경우

  1. 해당 쿠키에 [{projectId}] 값이 존재하는지 확인한다.
    1.1 있다면 조회 기록이 있기 때문에 조회 수를 증가시키지 않는다.
    1.2.없다면 조회 수를 증가시키고 쿠키 값에 projectId를
  2. ZSet에서 해당 Project의 점수(조회 수)를 1 증가시킨다.

쿠키가 없는 경우

  1. 쿠키를 생성하고 쿠키 값에 projectId를 추가한다.
  2. ZSet에서 해당 Project의 점수(조회 수)를 1 증가시킨다.

위 로직을 거쳐 다음과 같이 Redis에 저장된다.

value(projectId) : score(조회 수)

2.2. Sorted Set을 사용한 이유

Redis의 Sorted Set을 사용하지 않아도 랭킹 시스템을 구현할 수 있다. 
SQL의 Order By를 사용하면 조회 수를 기준으로 Project를 정렬할 수 있고, 이는 Spring Data JPA를 사용하면 쉽게 구현할 수 있다. 하지만 Sorted Set을 사용한 이유는 다음과 같다.

실시간 정렬 Sorted Set은 항목을 추가하거나 갱신할 때마다 자동으로 정렬된다. 즉, 항상 정렬된 상태로 데이터를 유지한다. 하지만 Order By를 사용하면 조회할 때마다 정렬 연산이 발생한다.

빠른 조회가 가능하다. Sorted Set은 상위 랭크의 데이터를 O(log(N))의 시간 복잡도로 조회한다. 만약 인덱스를 올바르게 설정하지 않은 상태에서 Order By 쿼리를 사용하면 플 테이블 스캔이 발생하고 이는 성능 저하로 이어진다.

랭킹을 유연하게 설정할 수 있다. 현재는 조회 수로만 랭킹을 측정하지만 추후에 다른 요소를 랭킹에 추가한다면 다양한 연산을 지원하는 Sorted Set이 필요하다.

3. Redis에 상위 랭킹 프로젝트 저장하기

랭킹을 기준으로 상위 10개의 프로젝트를 Redis에 저장한다. 
상위 10개 프로젝트는 홈페이지 상단에 위치하여 사이트 접속 시 조회 요청이 발생한다. 매 요청마다 Disk I/O가 발생하는 것은 비효율적이고, 상단에 고정된 인기 프로젝트가 조회 요청이 가장 많을 것으로 예상하여 이를 Redis로 관리한다.

2.1. ProjectService.java

@Scheduled(cron = "0 0 6 ? * MON", zone = "Asia/Seoul")
public void initializeProjectRanking() {
    NEXT_RANKING = END_RANKING;
    HashOperations<String, Long, GetProjectDetailResponse> hashOperation = redisRankingTemplate.opsForHash();
    List<GetProjectDetailResponse> topRankingProjectList = getRankedProjectListById(getTopRankingProjectIdList());

    topRankingProjectList.forEach(project -> hashOperation.put(TOP_RANKING_PROJECT_KEY, project.getId(), project));
    redisRankingTemplate.expire(TOP_RANKING_PROJECT_KEY, 7, TimeUnit.DAYS);
}

private List<Long> getTopRankingProjectIdList() {
        ZSetOperations<String, Long> zSetOperations = redisTemplate.opsForZSet();
        return new ArrayList<>(Objects.requireNonNull(zSetOperations.reverseRange(RANKING_KEY, START_RANKING, END_RANKING)));
    }

private List<GetProjectDetailResponse> getRankedProjectListById(List<Long> projectIds) {
    List<Project> projectList = projectRepository.findAllById(projectIds);

    return projectList.stream()
            .map(project -> {
                List<GetCommentWithMaskingResponse> comments = commentService.findCommentListWithMasking(project.getId());
                return GetProjectDetailResponse.of(project, comments);
            })
            .collect(Collectors.toList());
}

initializeProjectRanking() 
: 매주 월요일 오전 6시에 상위 DTO로 매핑된 Project를 Redis에 저장한다. 인기 프로젝트는 1주일 단위로 갱신되기 때문에 TTL을 7일로 설정한다.

getTopRankingProjectIdList() 
: Sorted Set으로 관리하는 상위 프로젝트의 Id를 조회한다.

getRankedProjectListById() 
: projectId로 Project를 조회하고 이를 DTO로 매핑한다.

4. 정합성 문제

만약 특정 프로젝트를 수정/삭제한 경우, 해당 프로젝트가 Redis에 저장되어 있다면 어떤 문제가 발생할까?

Redis에 변경사항이 반영되지 않는다면 변경 전 상태의 프로젝트가 조회될 것이다.

따라서, 랭킹에 등록된 프로젝트의 상태 변경이 발생하면 이를 Redis에도 적용하여 정합성 문제를 해결한다.

4.1. 프로젝트 수정

@Transactional
public UpdateProjectResponse modifyProject(Long projectId, UpdateProjectRequest dto) {
    Project project = projectRepository.findById(projectId).orElseThrow(() -> new EmptyResultException(ErrorCode.PROJECT_DELETED_OR_NOT_EXIST));
    projectTagService.removeProjectTag(project.getId());
    Tags updatedTags = tagService.findTagListByName(dto.getTechTags());

    project.updateProject(dto, updatedTags);
    if (isProjectIdExistInRanking(projectId)) modifyProjectInRanking(project);

    return UpdateProjectResponse.from(project);
}

private boolean isProjectIdExistInRanking(Long projectId) {
        HashOperations<String, Long, GetProjectDetailResponse> hashOperation = redisRankingTemplate.opsForHash();
        return hashOperation.hasKey(TOP_RANKING_PROJECT_KEY, projectId);
    }

private void modifyProjectInRanking(Project project) {
    HashOperations<String, Long, GetProjectDetailResponse> hashOperation = redisRankingTemplate.opsForHash();
    List<GetCommentWithMaskingResponse> comments = commentService.findCommentListWithMasking(project.getId());
    GetProjectDetailResponse updateProject = GetProjectDetailResponse.of(project, comments);
    hashOperation.put(TOP_RANKING_PROJECT_KEY, project.getId(), updateProject);
}

modifyProject()
: 수정하려는 프로젝트가 랭킹에 속한 프로젝트라면 Redis에 저장된 (랭킹에 속한) 프로젝트도 함께 수정한다.

isProjectIdExistInRanking()
: 인자값으로 전달 받은 id가 랭킹에 속하는 Project의 id인지 검사한다.

modifyProjectInRanking()
: 인자값으로 전달 받은 project를 DTO로 매핑하고 Redis의 put 메서드를 통해 수정 사항을 반영한다.

4.2. 프로젝트 삭제

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public class ProjectService {
    ...
    private final int START_RANKING = 0;
    private final int END_RANKING = 9;
    private int NEXT_RANKING = END_RANKING;
    @Transactional
    public void removeProject(Long projectId) {
        try {
            projectRepository.deleteById(projectId);
            if (isProjectIdExistInRanking(projectId)) addNextRankingProject(projectId);
        } catch (EmptyResultDataAccessException e) {
            throw new EmptyResultException(ErrorCode.PROJECT_DELETED_OR_NOT_EXIST);
        }
    }

    private void addNextRankingProject(Long deletedProjectId) {
        ZSetOperations<String, Long> zSetOperations = redisTemplate.opsForZSet();
        HashOperations<String, Long, GetProjectDetailResponse> hashOperation = redisRankingTemplate.opsForHash();

        hashOperation.delete(TOP_RANKING_PROJECT_KEY, deletedProjectId);
        zSetOperations.remove(RANKING_KEY, deletedProjectId);

        Long nextRankingProjectId = Objects.requireNonNull(zSetOperations.reverseRange(RANKING_KEY, NEXT_RANKING, NEXT_RANKING))
                .iterator()
                .next();
        NEXT_RANKING++;

        GetProjectDetailResponse nextRankingProject = this.findProjectById(nextRankingProjectId);
        hashOperation.put(TOP_RANKING_PROJECT_KEY, nextRankingProjectId, nextRankingProject);
    }

removeProject()
: 삭제하려는 프로젝트가 랭킹에 속한 프로젝트라면 Redis에 저장된 (랭킹에 속한) 프로젝트도 삭제하고, 다음 랭킹의 프로젝트를 Redis에 저장하는 메서드인 addNextRankingProject()를 호출한다.

addNextRankingProject()
: 랭킹에 속한 프로젝트가 삭제되면 다음 랭킹의 프로젝트를 저장해서 정해진 개수를 유지한다. 해당 메서드의 로직을 그림으로 표현하면 아래와 같다.

5. 결론

Redis의 Sorted Set으로 '이번 주 인기 프로젝트' 기능을 구현했다.
지금은 단순히 랭킹을 조회하고, 순위권의 프로젝트를 Redis에 저장하는 것만 구현했다. 추후에 코드 중복 개선, In-memory의 특성을 고려한 백업도 구현을 고려하고 있다.

0개의 댓글