본문 바로가기

프로젝트/UBLE

[UBLE] 실시간 인기 검색어 순위 구현

프로젝트 중 구현했던 실시간 인기 검색어 조회 기능에 대해 정리해보고자 한다.


💡 내용 상세

  • 실시간 인기 검색어는 최대 10개를 반환하며, 순위 변동(상승, 하락, 동일, 신규) 정보가 함께 제공된다.
  • 실시간 검색어는 검색창 아래에 뜨기 때문에 API  호출 횟수가 매우 많을 것이라 판단하였다. 따라서 1시간마다 스케줄러로 값을 갱신하여 캐싱해둔 후, API 호출 시에는 저장된 값을 바로 꺼내주도록 구현하였다.
  • 정각으로 바뀌는 시간의 오류를 예방하기 위해 TTL은 90분으로 설정했으며, 조회 API에서도 hit 여부를 검증하도록 하였다.
  • 단기 프로젝트였던 만큼 유저가 많지 않았기 때문에 결과의 품질을 유지하기 위해 최근 3시간 이내의 검색 로그를 기준으로 값을 측정했다.
  • SearchLog는 Elasticsearch에 저장되어 있기 때문에 Aggregations를 사용하였다.

🛠️ 구현

먼저, Redis 연결 정보를 추가한다.

 

📌 application.yml

  • ssl enabled의 경우, 원활한 개발을 위해 CD 과정에서 true로 변경하도록 설정해두었다.
data:
  redis:
    host: ${REDIS_HOST}
    port: ${REDIS_PORT}
    ssl:
      enabled: false

 

📌 RedisConfig

  • (임시) ssl 값이 false인 경우에는 검증을 하지 않도록 설정했다.
@Configuration
public class RedisConfig {

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

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

    @Value("${spring.data.redis.ssl.enabled}")
    private boolean sslEnable;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort);

        if(!sslEnable) {
            LettuceClientConfiguration lettuceClientConfiguration = LettuceClientConfiguration.builder().useSsl().disablePeerVerification().build();
            return new LettuceConnectionFactory(config, lettuceClientConfiguration);
        }
        return new LettuceConnectionFactory(config);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }
}

다음으로 실제 검색어 순위를 계산/캐싱/조회하는 로직을 구현하였다.

 

📌 KeywordRankRes

  • 검색 결과는 아래와 같은 정보를 포함한다.
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Schema(description = "인기 검색어 순위 정보 DTO")
public class KeywordRankRes {

    @Schema(description = "검색어", example = "맛집")
    private String keyword;

    @Schema(description = "순위", example = "1")
    private int rank;

    @Schema(description = "검색 횟수", example = "10")
    private long count;

    @Schema(description = "변화 종류", example = "UP")
    private RankChangeType change;

    @Schema(description = "등수 변화", example = "2")
    private int diff;

    public static KeywordRankRes of(String keyword, int rank, long count, RankChangeType change, int diff) {
        return KeywordRankRes.builder()
            .keyword(keyword)
            .rank(rank)
            .count(count)
            .change(change)
            .diff(diff)
            .build();
    }
}

📌 SearchController

/**
 * 인기 검색어 조회
 */
@Operation(summary = "인기 검색어 조회", description = "인기 검색어 조회")
@GetMapping("/popular-keywords")
public CommonResponse<TopKeywordListRes> getPopularKeywordList() {
    return CommonResponse.success(searchService.getPopularKeywordList());
}

📌 CustomSearchLogDocumentRepositoryImpl

  • Spring Data Elasticsearch를 활용해 값을 조회한다.
@Override
public ElasticsearchAggregations getPopularKeywordList() {
    // 최근 3시간 filter
    Query filter = DateRangeQuery.of(r -> r
        .field("createdAt")
        .gte("now-3H/H")
        .lte("now/H")
    )._toRangeQuery()._toQuery();

    // 통계 쿼리 생성
    Aggregation aggregation = Aggregation.of(a -> a
        .terms(t -> t
            .field("searchKeyword.raw")
            .size(10)
            .order(List.of(NamedValue.of("_count", SortOrder.Desc)))
        )
    );
    
    // 최종 쿼리
    NativeQuery query = NativeQuery.builder()
        .withQuery(q -> q
            .bool(b -> b.filter(List.of(filter)))
        )
        .withAggregation("top_keywords", aggregation)
        .withMaxResults(0)
        .build();

    // 조회 및 반환
    return (ElasticsearchAggregations) elasticsearchOperations.search(query, SearchLogDocument.class).getAggregations();
}

📌 SearchService

  • API가 호출되면 먼저 캐싱된 값이 존재하는지 확인한다. 만약 존재한다면 값을 가져와 바로 반환하고, 존재하지 않는다면 값을 새롭게 계산 후 캐싱 및 반환한다.
  • 변동 여부 계산을 위해 이전 정보도 함께 가져온다.
  • redisKey는 search:top:yyyy-MM-dd-HH 형식으로 설정해 중복되지 않도록 만들었다.
@Slf4j
@Service
@RequiredArgsConstructor
public class SearchService {

    private final SearchLogDocumentRepository searchLogDocumentRepository;
    private final UserRepository userRepository;
    private final RedisTemplate<String, Object> redisTemplate;
    private final ObjectMapper objectMapper = new ObjectMapper();

    private static final String REDIS_KEY_PREFIX = "search:top:";
    private static final Duration KEYWORD_TTL = Duration.ofMinutes(90);

    /**
     * top 10 실시간 인기 검색어 조회
     */
    public TopKeywordListRes getPopularKeywordList() {
        ZonedDateTime now = ZonedDateTime.now().truncatedTo(ChronoUnit.HOURS);
        String key = getRedisKey(now);

        // 캐시 조회
        List<KeywordRankRes> cached = loadKeywordListFromRedis(key);
        if (!cached.isEmpty()) {
            return new TopKeywordListRes(cached);
        }
        return cachePopularKeywordList();
    }

    public TopKeywordListRes cachePopularKeywordList() {
        ZonedDateTime now = ZonedDateTime.now().truncatedTo(ChronoUnit.HOURS);
        ZonedDateTime past = now.minusHours(1);

        String key = getRedisKey(now);
        String pastKey = getRedisKey(past);

        // 과거 정보 조회
        Map<String, Integer> pastRankMap = loadKeywordListFromRedis(pastKey).stream()
            .collect(Collectors.toMap(KeywordRankRes::getKeyword, KeywordRankRes::getRank));

        // 새로운 정보 조회
        List<StringTermsBucket> buckets = searchLogDocumentRepository.getPopularKeywordList().aggregationsAsMap()
            .get("top_keywords").aggregation().getAggregate().sterms().buckets().array();

        List<KeywordRankRes> rankList = IntStream.range(0, buckets.size())
            .mapToObj(i -> {
                StringTermsBucket b = buckets.get(i);
                String keyword = b.key().stringValue();
                int rank = i + 1;
                long count = b.docCount();

                RankChangeType change = RankChangeType.NEW;
                int diff = 0;

                if (pastRankMap.containsKey(keyword)) {
                    int pastRank = pastRankMap.get(keyword);
                    diff = Math.abs(pastRank - rank);

                    change = pastRank > rank ? RankChangeType.UP :
                        pastRank < rank ? RankChangeType.DOWN :
                        RankChangeType.SAME;
                }
                return KeywordRankRes.of(keyword, rank, count, change, diff);
            }).toList();

        saveKeywordListToRedis(key, rankList);
        return new TopKeywordListRes(rankList);
    }

    private void saveKeywordListToRedis(String key, List<KeywordRankRes> list) {
        try {
            String json = objectMapper.writeValueAsString(list);
            redisTemplate.opsForValue().set(key, json, KEYWORD_TTL);
        } catch (Exception e) {
            log.error("Redis 저장 실패 - key: {}, error: {}", key, e.getMessage(), e);
        }
    }

    private List<KeywordRankRes> loadKeywordListFromRedis(String key) {
        try {
            String json = (String) redisTemplate.opsForValue().get(key);
            if (json == null) return List.of();
            return Arrays.asList(objectMapper.readValue(json, KeywordRankRes[].class));
        } catch (Exception e) {
            return List.of();
        }
    }

    private String getRedisKey(ZonedDateTime time) {
        return REDIS_KEY_PREFIX + time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH"));
    }
}

📌 PopularKeywordScheduler

  • 매 정각마다 service의 갱신 메소드를 호출한다.
@Component
@RequiredArgsConstructor
@Slf4j
public class PopularKeywordScheduler {

    private final SearchService searchService;

    /**
     * 인기 검색어 캐싱 갱신
     */
    @Scheduled(cron = "0 0 * * * *")
    public void refreshKeywordRank() {
        searchService.cachePopularKeywordList();
        log.info("인기 검색어 Caching 완료");
    }
}

🛠️ 결과

처음에는 Redis에 결과를 저장하고, 다음부터는 캐싱된 결과를 불러오는 모습을 확인할 수 있다.

캐싱 결과


최종 반환되는 결과값은 아래와 같다. 실제 화면은 오른쪽처럼 구성되었다.

응답 결과 및 실제 화면

 


✏️ 마무리

  • 당연하게만 생각했던 인기 검색어 순위 조회 기능을 직접 만들어보며 다양한 경우와 방법들에 대해 고민해볼 수 있었다. 다른 서비스들은 몇 시간을 기준으로 갱신을 하는 지 등의 정보를 찾아보는 과정도 즐거웠다.
  • 다만 Spring과 Elasticsearch를 함께 사용하는 참고 자료가 많지 않아서 내가 구현한 방식이 일반적으로 많이 사용되는 방식이 맞는 지에 대한 확신이 부족하다... ES도 조금 더 깊이 공부해보고 싶다.
  • 스케줄러 작업 실패 등을 대비해 일반 조회 로직에도 검증 및 결과 재집계 로직을 추가했는데, 지금 생각해보니 굳이 스케줄러를 넣지 않았어도 될 것 같다는 생각이 든다... 특히 유저가 적어서 검색어/조회가 단 한번도 일어나지 않는 시간대가 훨씬 많았는데 1시간마다 서버에 작업이 발생하는 것이 무의미한 것 같다.