Redis를 이용하여 최근검색어 구현하기

xeonu·2023년 7월 6일
1
post-custom-banner

의약품 촬영 인식 서비스를 개발하면서 의약품 검색엔진을 구축했었다. 더불어 사용자 경험 향상을 위해 다음과 같이 검색을 하기 직전에 최근검색어를 보여주기로 했다.

이를 구현하기 위해서 고려할 것은 다음과 같았다.

  • 몇개까지 저장할 것인가?
  • 어떤 장비로 접속해도 동일한 결과를 보장할 것인가?
  • 빠른 응답속도를 보장해야하는가?
  • DB에 저장하면 매번 검색할 때마다 조회할 때 부하가 심하지 않은가?
  • 삭제가 용이해야하는가?

매번 검색을 할 때 마다 최근검색어 데이터를 조회해야하는데 이를 MySQL과 같은 관계형 데이터베이스에 저장하면 부하가 심할 것이다. 또한 최근 검색어를 무한하게 저장할 수 있다고 가정했을 때 관계형 데이터베이스에서 최근검색어를 조회할 때 걸리는 시간도 오래 걸릴 것이다. 결국 관계형 데이터베이스에서 직접 데이터를 가져오는 방법은 제외시켰다.

빠른 응답속도를 보장하기 위해서 In-memory 기반의 NoSQL인 Redis를 사용하기로 했다. 하지만 Redis는 휘발성인 메모리에 데이터가 저장되어서 전원이 공급되지 않으면 데이터가 유실된다. 또 검색기록을 무한하게 저장할 경우 메모리 용량에 부담이 될 수 있다.

이를 위해서 검색기록을 10개까지만 저장하기로 했다. 원래 처음에는 몽고DB로 구현을 해야하나 고민을 했었는데 응답속도가 만족스럽지 않을 것 같아서 고민을 했었다. 그런데 네이버에서도 10개까지만 저장하는 것을 보고서 다들 비슷한 고민을 했구나 싶었다.

데이터가 유실될 수 있는 문제는 Redis의 AOF를 이용하여 해결했다.

구현하기

사용자마다 최근 중복되지 않은 10개의 검색어를 저장하려면 Redis의 Sorted Set을 이용하면된다. Sorted Set에 관한 자세한 정보는 공식문서를 확인하자. 하지만 우리는 중복된 것까지 저장하기로해서 list로 만들어보겠다.

key와 value를 지정해주어야한다. key는 "SearchLog"+[사용자 id]로 지정했고 value는 {검색어, 검색일자}로 지정했다.

다음과 같이 value로 설정할 클래스를 만들어주자.

public class SearchLogRedis {

  private String name;

  private String createdAt;
}

Redis에 데이터를 적절하게 직렬화 할 수 있도록 config 파일을 작성해주자.

@Configuration
public class RedisConfig {

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

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

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

  @Bean
  public RedisTemplate<String, SearchLogRedis> SearchLogRedis() {
    RedisTemplate<String, SearchLogRedis> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory());
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setHashKeySerializer(new StringRedisSerializer());
    redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(SearchLogRedis.class));
    redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(SearchLogRedis.class));

    return redisTemplate;
  }
}

일반적으로 String을 저장할 때는 spring-redis에서 기본적으로 제공하는 RedisTemplate을 사용하면되지만 우리는 SearchLogRedis라는 클래스를 Redis에 저장할 것이기 때문에 위와 같이 빈을 등록해준다.

SearchLogRedis를 value로 등록한 RedisTemplate을 클래스에 주입해주고

private final RedisTemplate<String, SearchLogRedis> redisTemplate;

검색기록을 추가하는 메소드를 다음과 같이 만들어주자.

  public void saveRecentSearchLog(String name) {
    Member loginMember = memberService.getLoginMember();
    String now = LocalDateTime.now().toString();

    String key = searchLogKey(loginMember.getId());
    SearchLogRedis value = SearchLogRedis.builder().
        name(name).
        createdAt(now).
        build();

    Long size = redisTemplate.opsForList().size(key);
    if (size == (long) RECENT_KEYWORD_SIZE) {
      redisTemplate.opsForList().rightPop(key);
    }

    redisTemplate.opsForList().leftPush(key, value);
  }

현재 해당 key의 list가 RECENT_KEYWORD_SIZE(10개)와 일치하면 rightPop을 통해 가장 오래된 데이터를 삭제해주고
leftPush를 통해 새로운 검색어를 추가해준다.

처음에 개발했을 때는 검색을 메소드를 실행하면 자동으로 saveRecentSearchLog 메소드를 실행하게 구현했다. 하지만 이렇게 구현하니 클라이언트 개발자의 실수로 최근검색어가 중복해서 들어가기도 했다. api를 여러번 호출하더라도 검색과 최근검색어 추가 api를 분리하는 편이 유지보수 측면에서 더 나은 것으로 판단하여 분리하게 되었다.

최근 검색어를 반환하는 메소드는 다음과 같다.

  public List<SearchLogRedis> getRecentSearchLogs() {
    Member loginMember = memberService.getLoginMember();
    String key = searchLogKey(loginMember.getId());
    List<SearchLogRedis> logs = redisTemplate.opsForList().
        range(key, 0, RECENT_KEYWORD_SIZE);

    return logs;
  }

특정 key의 list를 받아와서 반환해주면된다.

최근 검색어 삭제 메소드는 다음과 같다.

  public void deleteRecentSearchLog(String name, String createdAt) {
    Member loginMember = memberService.getLoginMember();
    String key = searchLogKey(loginMember.getId());
    SearchLogRedis value = SearchLogRedis.builder()
        .name(name)
        .createdAt(createdAt)
        .build();

    long count = redisTemplate.opsForList().remove(key, 1, value);

    if (count == 0) {
      throw new BadRequestException(SEARCH_LOG_NOT_EXIST.getErrorResponse());
    }
  }

value에 날짜 정보도 같이 추가해준 이유는 삭제를 위해서였다. 처음에는 검색어의 list만 value에 저장했는데 클라이언트 개발자께서 index도 같이 저장해주면 좋겠다고 하셨다. 하지만 push pop구조인 상황에서 index를 별도로 저장하긴 애매하고 primary key가 필요했다. 한 사람이 검색을 1ms 단위로 할리도 없으니 LocalDateTime을 통해서 날짜 정보를 같이 저장해주기로 했다.

Redis 보안사고 이야기

Redis를 클라우드 서비스에 띄웠었다. 토이 프로젝트이기 때문에 ACL과 같은 보안설정도 하지 않았고 redis.conf에서도 모든 ip에 대해서 허용했었다. 하지만 주기적으로 데이터가 사라지고 이에 대해서 피로를 느꼈다. 알아보니 악성 크롤링 매크로들이 6379포트에 대해서 주기적으로 redis를 flush하고 채굴을 하고 있었다. 이로인해 보안설정을 다시 하였고 그 뒤로는 데이터가 유실되는 일이 없었다.

그래도 나 같은 사람들이 있는지 NCP에서 바로 메일을 보내주었고 토이프로젝트라고 보안조치를 허술하게 하면 안된다는 큰 교훈을 얻었다.

profile
백엔드 개발자가 되기위한 여정
post-custom-banner

0개의 댓글