study/Spring

[Spring] Redis Sorted Set을 이용한 검색 랭킹 조회 기능 개선

eo_neunal 2023. 12. 10. 22:11

0. 들어가며

현재 구현 중인 블로그 검색 서비스에서는 인기 키워드 목록 조회를 위해 검색 내역을 H2 DB에 저장하고 있다.

H2 DB는 멀티스레딩을 지원하기 때문에 동시성 처리가 필요하다. 따라서 인기 키워드에 대한 동시성 문제를 해결하기 위해 Lock을 사용할 수 있다.

하지만 H2 DB의 Lock처리를 하는 대신 싱글 스레드 환경의 Redis를 이용해 동시성 문제를 해결하면서 Redis에서 지원하는 Sorted Set(ZSet)을 이용해 정렬 처리까지 하려고 한다.

1. Redis Sorted Set

Redis에서 지원하는 자료구조 중 하나인 Sorted Set은 score에 의해 정렬된 중복되지 않은 문자열을 갖는 Collection이며, 동일한 score를 갖는 문자열의 경우 사전순으로 정렬된다.

Redis에 저장되는 데이터는 기본적으로 바이트 형태다. Spring에서는 Redis의 데이터에 간단하게 접근할 수 있도록 도와주는 RedisTemplate<Key, Value> 클래스가 존재한다.

RedisTemplate는 opsForString(), opsForList() 등 다양한 자료구조를 다룰 수 있는 Operations를 반환해 주는 메소드를 제공한다.

그중 Sorted Set을 지원하는 ZSetOperations를 사용하기 위해서는 opsForZSet() 메소드를 사용하면 된다.

2. 작업 환경

Java 11

Spring Boot 2.7

Gradle 8.4

JUnit 5

3. Implement

3.1. 의존성 추가

| build.gradle

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

3.2. 환경 변수 추가

| main/…/resources/application.yml

spring:
  redis:
    host: localhost
    port: 6379

3.3. RedisConfig 추가

@EnableRedisRepositories
@Configuration
public class RedisConfig {

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

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

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate() {  // (1)
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}

(1) Redis에 데이터를 저장하고 불러오기 위한 직렬화/역직렬화 설정을 한다.

3.4. 키워드 검색 메소드

| Before

@Transactional
public void increaseSearchCount(String keyword) {
    BlogSearch blogSearch = blogSearchRepository.findByKeyword(keyword);
    if (blogSearch == null) {
        blogSearchRepository.save(BlogSearch.from(keyword));
        return;
    }
    blogSearch.increase();
}

 

| After

@Transactional
public void increaseSearchCount(String keyword) {
    redisTemplate.opsForZSet().incrementScore(BOOK_SEARCH_RANKING_KEY, keyword, RANKING_INCREMENT_SCORE);
}

ZSetOperations의 incrementScore(key, value, score) 메소드를 이용해 value에 점수를 부여하고, 이를 통해 정렬이 가능하다.

3.5. 검색 랭킹 조회

| Before

@Transactional(readOnly = true)
public BlogSearchRankingResponse getSearchRanking() {
    List<BlogSearchRankingResponseElement> responseElements = blogSearchRepository.findTop10ByOrderByCountDesc()
            .stream()
            .map(BlogSearchRankingResponseElement::from)
            .collect(Collectors.toList());
    return BlogSearchRankingResponse.from(responseElements);
}

 

| After

@Transactional(readOnly = true)
public BlogSearchRankingResponse getSearchRanking() {
    return BlogSearchRankingResponse.from(
            Optional.ofNullable(redisTemplate.opsForZSet().reverseRangeWithScores(BOOK_SEARCH_RANKING_KEY, RANKING_START, RANKING_END))
                    .map(keywords -> keywords.stream()
                            .map(keyword -> BlogSearchRankingResponseElement.of(keyword.getValue(), keyword.getScore()))
                            .collect(Collectors.toList()))
                    .orElseGet(Collections::emptyList)
    );
}

ZSetOperations의 reverseRangeWithScores(key, startIndex, endIndex) 메소드에 검색 횟수를 저장한 key와 원하는 개수만큼의 index를 인자로 넘겨 score 역순으로 정렬된 결과를 불러올 수 있다.

4. Test

테스트 환경을 위한 Redis를 구축하는 방법은 크게 세 가지가 있다.

  1. 개발 환경에서 사용 중인 Redis와 다른 port로 Redis를 실행
  2. EmbeddedRedis 사용
  3. TestContainers 사용

1번 방법의 경우 테스트가 외부 라이브러리의 의존성을 갖기 때문에 어느 환경에서 동일하게 테스트를 진행할 수 없다는 단점이 있어 추천하지 않는 방법이다.

 

오늘은 2번째 방법인 EmbeddedRedis를 이용해 Redis 테스트 환경을 구축하려고 한다.

4.1. 의존성 추가

| build.gradle

testImplementation ('it.ozimov:embedded-redis:0.7.3') {
    exclude group: 'org.slf4j', module: 'slf4j-simple'
}

it.ozimov:embedded-redis:0.7.3 의존성 추가 시 클래스 경로에 여러 개의 SLF4J가 바인딩되는 문제가 발생하기 때문에 exclude group: 'org.slf4j', module: 'slf4j-simple'을 추가해야 한다.

4.2. 환경 변수 추가

| test/…/resources/application.yml

spring:
  redis:
    host: localhost
    port: 6379

4.3. EmbeddedRedisConfig 추가

| EmbeddedRedisConfig.java

@TestConfiguration
public class EmbeddedRedisConfig {

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

    private RedisServer redisServer;

    @PostConstruct
    public void redisServer() throws Exception {  // (1)
        redisServer = RedisServer.builder()
                .port(isRedisRunning(port) ? findAvailablePort() : port)
                .build();
        redisServer.start();
    }

    @PreDestroy
    public void stopRedis() {  // (2)
        if (redisServer != null) {
            redisServer.stop();
        }
    }

    private boolean isRedisRunning(int port) throws IOException {
        return isRunning(executeGrepProcessCommand(port));
    }

    private Process executeGrepProcessCommand(int port) throws IOException {
        String[] shell = {"/bin/sh", "-c", "netstat -nat | grep LISTEN | grep " + port};
        return Runtime.getRuntime().exec(shell);
    }

    private boolean isRunning(Process process) throws IOException {
        StringBuilder pidInfo = new StringBuilder();
        try (BufferedReader input = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
            String line;
            while ((line = input.readLine()) != null) {
                pidInfo.append(line);
            }
        }
        return !pidInfo.toString().isEmpty();
    }

    private int findAvailablePort() throws IOException {
        for (int port = 10000; port <= 65535; port++) {
            if (!isRunning(executeGrepProcessCommand(port))) {
                return port;
            }
        }
        throw new IllegalArgumentException("Not Found Available port: 10000 ~ 65535");
    }
}

(1) 환경 변수로 받은 port가 사용 중이면 사용 가능한 port를, 아니면 환경 변수로 받은 port를 이용해서 RedisServer를 실행한다.

(2) RedisServer가 실행 중이면 실행을 종료한다.

4.4 테스트 코드

| BlogSearchRankingServiceTest.java

@Import(EmbeddedRedisConfig.class)  // (1)
@SpringBootTest
class BlogSearchRankingServiceTest {

    private static final String BOOK_SEARCH_RANKING_KEY = "bookSearchRanking";

    @Autowired
    private BlogSearchRankingService blogSearchRankingService;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    ZSetOperations<String, String> zSetOperations;

    @BeforeEach
    void setUp() {
        zSetOperations = redisTemplate.opsForZSet();
    }

    @AfterEach
    void clear() {
        zSetOperations.getOperations().delete(BOOK_SEARCH_RANKING_KEY);  // (2)
    }

    @Nested
    class 검색_횟수_증가할_때 {

        @Nested
        class 저장된_검색기록이_없으면 {

            @Test
            void 검색기록이_생성된다() {
                // given
                String keyword = "키워드";
                // when
                blogSearchRankingService.increaseSearchCount(keyword);
                // then
                assertThat(zSetOperations.zCard(BOOK_SEARCH_RANKING_KEY)).isEqualTo(1);  // (3)
            }

            @Test
            void 검색횟수가_초기값이다() {
                // given
                String keyword = "키워드";
                // when
                blogSearchRankingService.increaseSearchCount(keyword);
                // then
                assertThat(zSetOperations.score(BOOK_SEARCH_RANKING_KEY, keyword)).isEqualTo(1);  // (4)
            }
        }

        ...
    }

		...
}

(1) EmbeddedRedis 관련 설정이 포함된 EmbeddedRedisConfig 클래스를 해당 클래스에 임포트 한다.

(2) 각 테스트 후 저장된 데이터로 인해 테스트가 실패하는 것을 방지하기 위해 RedisOperations의 delete(key) 메소드를 이용해 각 테스트 수행 후 데이터를 지우도록 한다.

(3) ZSetOperations의 zCard(key) 메소드는 해당 key에 포함된 value의 총개수를 반환한다.

      같은 기능을 하는 size(key) 메소드와 달리 파라미터로 받는 key 값의 null을 허용하지 않는다.

(4) ZSetOperations의 score(key, value) 메소드는 해당 key의 value의 score를 반환한다.

 

| 실행결과

실행 결과 테스트가 모두 통과하는 것을 확인할 수 있다.

5. 소스 코드

https://github.com/jeongyuneo/blog-search-service

 

GitHub - jeongyuneo/blog-search-service

Contribute to jeongyuneo/blog-search-service development by creating an account on GitHub.

github.com

References

Redis sorted sets

org.springframework.data.redis.core.RedisTemplate<K,V>

Interface ZSetOperations<K,V>