프로젝트 중 구현했던 실시간 인기 검색어 조회 기능에 대해 정리해보고자 한다.
💡 내용 상세
- 실시간 인기 검색어는 최대 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시간마다 서버에 작업이 발생하는 것이 무의미한 것 같다.
'프로젝트 > UBLE' 카테고리의 다른 글
| [UBLE] 다중 서버 환경에서의 스케줄러 동시성 관리 (5) | 2025.08.21 |
|---|