Spring

Redis Sorted Sets으로 인기 검색어 구현하기

heyh0 2024. 4. 9. 00:51

# Event

프로젝트의 요구사항 중 검색칸을 눌렀을 때, 인기 검색어 보여줘야 했다.

 

처음에는 ElasticSearch를 이용해서 구현하고 싶었지만 알아야 할 내용이 너무 많았다.

그래서 일단 MVP 구현을 위해 차선책으로 Redis의 Sorted Sets을 사용했다.

 

왜 차선책인가 ?

DB보다는 Redis가 더 좋을 것이라는 것은 자명하다.

만약 검색어를 DB에서 쿼리로 가져온다면 하드디스크 기반의 저장 공간에 접근해야 한다.

하지만 Redis는 In-Memory DB이기 때문에 더 빠를 수밖에 없다.

 

또한 Sorted Sets을 통해 요청 검색어의 점수를 1씩 증가시킨다.

조회할 때는 점수 Top-10을 반환한다.

 

# Implementation

@Configuration
@RequiredArgsConstructor
@EnableRedisRepositories
public class RedisConfig {

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

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

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

    @Primary
    @Bean(name = "redisConnectionFactoryFirst")
    public LettuceConnectionFactory redisConnectionFactoryFirst() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();

        redisStandaloneConfiguration.setHostName(host);
        redisStandaloneConfiguration.setPort(port);
        redisStandaloneConfiguration.setPassword(password);
        redisStandaloneConfiguration.setDatabase(0);

        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

    @Bean(name = "redisConnectionFactorySecond")
    public LettuceConnectionFactory redisConnectionFactorySecond() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();

        redisStandaloneConfiguration.setHostName(host);
        redisStandaloneConfiguration.setPort(port);
        redisStandaloneConfiguration.setPassword(password);
        redisStandaloneConfiguration.setDatabase(1);

        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

    @Primary
    @Bean(name = "redisTemplateFirst")
    public RedisTemplate<String, String> redisTemplateFirst(
            @Qualifier("redisConnectionFactoryFirst") LettuceConnectionFactory connectionFactory) {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();

        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setEnableTransactionSupport(true);

        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new StringRedisSerializer());

        return redisTemplate;
    }

    @Bean(name = "redisTemplateSecond")
    public RedisTemplate<String, String> redisTemplateSecond(
            @Qualifier("redisConnectionFactorySecond") LettuceConnectionFactory connectionFactory) {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));
        return redisTemplate;
    }

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(
            @Qualifier("redisConnectionFactoryFirst") RedisConnectionFactory connectionFactory) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        return container;
    }

    @Bean
    public RedisCacheManager redisCacheManager(
            @Qualifier("redisConnectionFactorySecond") RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

        return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(connectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }
}

 

일단 구현을 위주로 코드를 작성했기 때문에 리팩토링이 필요하다.

간단하게 설명하자면 기존에 이미 Redis를 사용해서 베스트셀러 책을 저장하고 있었다.

Multi Redis를 사용하려면 코드와 같이 @Qualifier로 각 레디스를 구분해야 한다.

또한 각 레디스마다 다른 인덱스를 사용해야 한다.

나는 0번과 1번을 사용했다. (0 ~ 15까지 16개 사용 가능)

 

Spring에서 Redis를 연결할 때, 하나의 연결에 하나의 Redis DB를 사용할 수 있다.

이부분을 구현하는데 시간을 많이 썼다.. ㅎㅎ

 

 

API 로직은 생각보다 간단하다.

아래 코드를 살펴보자.

@Getter
@Setter
@NoArgsConstructor
public class SearchKeywordRequest {

    private String keyword;

}

 

@Slf4j
@Service
public class SearchService {

    private final RedisTemplate<String, String> redisTemplate;

    public SearchService(@Qualifier("redisTemplateSecond") RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void searchKeyword(String keyword) {
        ZSetOperations<String, String> zset = redisTemplate.opsForZSet();
        zset.incrementScore("ranking", keyword, 1); // 점수 증가
    }

    public List<String> getRankKeywords() {
        ZSetOperations<String, String> zset = redisTemplate.opsForZSet();
        Set<String> typedTuples = zset.reverseRange("ranking", 0, 9);
        return List.copyOf(typedTuples);
    }

}

 

@Slf4j
@RequestMapping("/v1/search")
@RequiredArgsConstructor
@RestController
public class SearchController {

    private final SearchService searchService;

    @PostMapping("/rank")
    public ApiResponse<Void> search(@ModelAttribute SearchKeywordRequest request) {
        log.info("키워드 = {}", request.getKeyword());
        searchService.searchKeyword(request.getKeyword());
        return ApiResponse.ok(null);
    }

    @GetMapping("/rank")
    public ApiResponse<List<String>> search() {
        return ApiResponse.ok(searchService.getRankKeywords());
    }

}

 

POST요청에는 검색어 점수를 증가시키고, GET요청은 검색어 TOP-10을 조회한다.

현재는 List<String>으로 결과를 반환하지만 나중에는 검색어와 점수 변수를 함께 리턴하는 DTO를 추가할 것이다.

 

# Test

@SpringBootTest
@RecordApplicationEvents
public class SearchServiceTest extends RedisTestContainer {

    @Autowired
    private SearchService searchService;

    @Autowired
    private @Qualifier("redisTemplateSecond") RedisTemplate<String, String> redisTemplate;

    @MockBean
    private S3FileManager s3FileManager;

    @DisplayName("사용자가 검색한 키워드의 점수가 1 증가한다.")
    @org.junit.jupiter.api.Test
    void searchSaveRanking() {
        //given
        String keyword = "testKeyword";

        //when
        searchService.searchKeyword(keyword);

        //then
        ZSetOperations<String, String> zset = redisTemplate.opsForZSet();
        Double score = zset.score("ranking", keyword);
        assertThat(score).isEqualTo(1.0); // 검색어의 점수가 1.0인지 확인

    }

    @DisplayName("키워드 검색 횟수 상위 10개를 가져온다.")
    @Test
    void getRankKeywords() {
        //given
        String keyword = "testKeyword";

        //when
        searchService.searchKeyword(keyword);

        //then
        List<String> rankKeywords = searchService.getRankKeywords();
        Assertions.assertThat(rankKeywords).contains(keyword); // 랭킹에 추가된 검색어가 있는지 확인
    }

}

 

RedisTestContainer를 통해 테스트를 진행했고 성공했다.

굿 🔥