์ฌ์ฉ์์ ๊ฒ์ ํ๋์ ๋ฐํ์ผ๋ก ์ค์๊ฐ์ผ๋ก ์ธ๊ธฐ ๊ฒ์์ด๋ฅผ ์์งํ๊ณ ๋ถ์ํ์ฌ, ํธ๋ ๋ํ ํค์๋๋ฅผ ์ฌ์ฉ์์๊ฒ ์ ๊ณตํ๋ ๊ธฐ๋ฅ์
๋๋ค.
ElasticSearch ๊ธฐ๋ฐ ๊ฒ์ ๊ธฐ๋ฅ๊ณผ Redis, ์ค์ผ์ค๋ง์ ์กฐํฉํด ์ฑ๋ฅ๊ณผ ์ค์๊ฐ์ฑ์ ๋ชจ๋ ๊ณ ๋ คํ ๊ตฌ์กฐ์
๋๋ค.
ํด๋์ค: KeywordAspect
๋์ ์์ : ์ฌ์ฉ์๊ฐ ๊ฒ์๋ฌผ์ ๊ฒ์ํ ๋ ElasticSearchService.searchPosts(...) ๋ฉ์๋ ์คํ ํ
๊ธฐ๋ฅ:
ํ์ฌ ์๊ฐ ๊ธฐ์ค 5๋ถ ๋จ์ ํค (search_keywords:HHmm)์ ๋ํด ํค์๋์ ์กฐํ์๋ฅผ 1 ์ฆ๊ฐ
1์๊ฐ ๋์ ์ ํจํ TTL ์ค์ (expire)
๋์ ํค์๋ ์ง๊ณ (search_keywords:total)๋ ํจ๊ป ์ฆ๊ฐ์์ผ ์ ์ฒด ๊ฒ์๋ ์ ์ง
๋ง์ง๋ง ์ฌ์ฉ ์๊ฐ์ ํด์ ๊ตฌ์กฐ๋ก ๊ธฐ๋ก
search_keywords_last_used2025-05-15T14:25:00 ๋ฑ)ํด๋์ค: RankKeywordScheduler
์ค์ผ์ค๋ง: 5๋ถ๋ง๋ค ์คํ (@Scheduled(cron = "0 */5 * * * *"))
๊ธฐ๋ฅ:
์ต๊ทผ 10๋ถ ๊ฐ(ํ์ฌ -5๋ถ, -10๋ถ) ๊ฒ์๋ ํค์๋๋ค์ ์ง๊ณ
๊ฐ ํค์๋์ ๋ํด ๋ค์๊ณผ ๊ฐ์ ์ ์ ๊ณ์ฐ ์ํ:
์ ์ = (์ง์ 5๋ถ ๋๋น ์ฆ๊ฐ๋ * ๊ฐ์ค์น 2.0) + (๋์ ๊ฒ์๋ ๋ก๊ทธ๊ฐ * ๊ฐ์ค์น 1.2)
๊ณ์ฐ๋ ์ ์๋ Redis ZSet search_scores:computed์ ์ ์ฅ
ํด๋์ค: RankKeywordScheduler
์ค์ผ์ค๋ง: ๋งค์ผ ์์ ์คํ (@Scheduled(cron = "0 0 0 * * *"))
๊ธฐ๋ฅ:
ํด์(search_keywords_last_used)์ ๊ธฐ๋ก๋ ๋ง์ง๋ง ์ฌ์ฉ ์๊ฐ์ ๊ธฐ์ค์ผ๋ก
ํ๋ฃจ ์ด์ ์ฌ์ฉ๋์ง ์์ ํค์๋ ์ ๊ฑฐ:
search_keywords:total ์์ ์ ๊ฑฐsearch_scores:computed ์์ ์ ๊ฑฐsearch_keywords_last_used ์์๋ ์ ๊ฑฐ๋ถํ์ํ ๋ฉ๋ชจ๋ฆฌ ์ ์ ๋ฐฉ์ง ๋ฐ ํธ๋ ๋ ์ ํ์ฑ ํฅ์
search_scores:computed์์ ์ ์ ์์ผ๋ก ์ ๋ ฌ๋ ํค์๋ ์กฐํ| ํญ๋ชฉ | ์ค๋ช |
|---|---|
| ์ฑ๋ฅ | Redis ๊ธฐ๋ฐ ZSet/Hash ์ฌ์ฉ์ผ๋ก ๋น ๋ฅธ ์ฝ๊ธฐ/์ฐ๊ธฐ ๋ฐ ์ ๋ ฌ ๊ฐ๋ฅ |
| ์ค์๊ฐ์ฑ | 5๋ถ ๋จ์ ์๊ฐ ํค ์ฒ๋ฆฌ ๋ฐ ์ฃผ๊ธฐ์ ํธ๋ ๋ ๊ฐฑ์ ์ผ๋ก ์ต์ ๋ฐ์ดํฐ ๋ฐ์ |
| AOP ๋์ | ํต์ฌ ๋น์ฆ๋์ค ๋ก์ง๊ณผ ๋ถ๋ฆฌ๋์ด ๊ด์ฌ์ฌ ๋ถ๋ฆฌ๊ฐ ์ ๋์ด ์์ |
| ํจ์จ์ ์ธ ์ ๋ฆฌ | ๊ฒ์๋์ง ์๋ ํค์๋๋ฅผ ์๋ ์ ๋ฆฌํ์ฌ ๋ฉ๋ชจ๋ฆฌ ๋ญ๋น ๋ฐฉ์ง ๋ฐ ์ฑ๋ฅ ์ ์ง |
| ํ์ฅ์ฑ | ์ ์ ์ฐ์ ๋ฐฉ์ ๋ฐ ์ ๊ฑฐ ์ฃผ๊ธฐ ์ ์ฐํ๊ฒ ์กฐ์ ๊ฐ๋ฅ (๊ฐ์ค์น ์กฐ์ , ๊ธฐ๊ฐ ์ค์ ๋ฑ) |
| ํค ์ด๋ฆ | ์ค๋ช |
|---|---|
search_keywords:1425 | 14์ 25๋ถ ๊ธฐ์ค ๊ฒ์ ํค์๋ ZSet |
search_keywords:total | ์ ์ฒด ๋์ ๊ฒ์ ํค์๋ ZSet |
search_keywords_last_used | ๊ฐ ํค์๋๋ณ ๋ง์ง๋ง ๊ฒ์ ์๊ฐ ์ ์ฅ์ฉ Hash (field=keyword, value=ISO timestamp) |
search_scores:computed | ๊ณ์ฐ๋ ์ธ๊ธฐ ์ ์ ํค์๋ ZSet (UI์์ ์ธ๊ธฐ ํค์๋๋ก ๋ ธ์ถ) |
@Configuration
@RequiredArgsConstructor
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String hostname;
@Value("${spring.data.redis.password}")
private String password;
@Bean("keywordRedisConnectionFactory")
public RedisConnectionFactory keywordRedisConnectionFactory() {
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setPort(6379);
redisStandaloneConfiguration.setHostName(hostname);
redisStandaloneConfiguration.setPassword(password);
redisStandaloneConfiguration.setDatabase(4);
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
@Bean("keywordRedisTemplate")
public RedisTemplate<String, Object> keywordRedisTemplate(
@Qualifier("keywordRedisConnectionFactory") RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> sessionRedisTemplate = new RedisTemplate<>();
sessionRedisTemplate.setConnectionFactory(redisConnectionFactory);
sessionRedisTemplate.setKeySerializer(new StringRedisSerializer());
sessionRedisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
sessionRedisTemplate.setHashKeySerializer(new StringRedisSerializer());
sessionRedisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
return sessionRedisTemplate;
}
}
@Aspect
@Component
@RequiredArgsConstructor
public class KeywordAspect {
private final RedisTemplate<String, Object> keywordRedisTemplate;
@AfterReturning("execution(* com.eventorback.search.service.ElasticSearchService.searchPosts(..))")
public void recordKeyword(JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
if (args.length > 0 && args[1] instanceof String keyword) {
if (keyword.isEmpty()) {
return;
}
String timeKey = getCurrentTimeKey(); // ์: search_keywords:1425
keywordRedisTemplate.opsForZSet().incrementScore(timeKey, keyword, 1);
keywordRedisTemplate.expire(timeKey, Duration.ofHours(1));
// ๋์ ํค๋ ์ฆ๊ฐ
keywordRedisTemplate.opsForZSet().incrementScore("search_keywords:total", keyword, 1);
// ๋ง์ง๋ง ์ฌ์ฉ ์๊ฐ ์
๋ฐ์ดํธ
String now = LocalDateTime.now().toString();
keywordRedisTemplate.opsForHash().put("search_keywords:last_used", keyword, now);
}
}
private String getCurrentTimeKey() {
LocalDateTime now = LocalDateTime.now();
// 5๋ถ ๋จ์๋ก ๋ผ์ด๋ฉ
int minuteBlock = (now.getMinute() / 5) * 5;
now = now.withMinute(minuteBlock).withSecond(0).withNano(0);
// HHmm ํ์์ผ๋ก ๋ณํ (์: 1425)
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HHmm");
return "search_keywords:" + now.format(formatter);
}
}
@Component
@RequiredArgsConstructor
public class TopKeywordScheduler {
private final RedisTemplate<String, Object> keywordRedisTemplate;
private static final long UNUSED_DAYS_THRESHOLD = 1; // 1์ผ ๋์ ๊ฒ์ ์ ๋ ํค์๋ ์ ๊ฑฐ
@Scheduled(cron = "0 0 0 * * *") // ๋งค์ผ ์์ ๋ง๋ค ์คํ
public void cleanUpOldTopKeywords() {
Set<Object> keywords = keywordRedisTemplate.opsForZSet().range("search_keywords:total", 0, -1);
if (keywords == null) {
return;
}
LocalDateTime now = LocalDateTime.now();
for (Object obj : keywords) {
String keyword = String.valueOf(obj);
String lastUsedStr = (String)keywordRedisTemplate.opsForHash()
.get("search_keywords:last_used", keyword);
if (lastUsedStr == null) {
continue;
}
LocalDateTime lastUsed = LocalDateTime.parse(lastUsedStr);
if (Duration.between(lastUsed, now).toDays() >= UNUSED_DAYS_THRESHOLD) {
keywordRedisTemplate.opsForZSet().remove("search_keywords:total", keyword);
keywordRedisTemplate.opsForZSet().remove("search_keywords:score", keyword);
keywordRedisTemplate.opsForHash().delete("search_keywords:last_used", keyword);
}
}
}
@Scheduled(cron = "0 */5 * * * *") // ๋งค 5๋ถ๋ง๋ค
public void updateTopKeywords() {
String currentKey = getTimeKey(-1); // ์ง์ 5๋ถ ๊ตฌ๊ฐ
String previousKey = getTimeKey(-2); // ์ง์ ์ 5๋ถ ๊ตฌ๊ฐ
Set<Object> keywords = keywordRedisTemplate.opsForZSet()
.union(currentKey, previousKey); // ๋ ํค์ ์๋ ๋ชจ๋ ํค์๋ ๊ฐ์ ธ์ค๊ธฐ
if (keywords == null) {
return;
}
for (Object object : keywords) {
String keyword = String.valueOf(object);
double current = getScore(currentKey, keyword);
double previous = getScore(previousKey, keyword);
double total = getScore("search_keywords:total", keyword);
double delta = current - previous;
double score = delta * 2.0 + Math.log(total + 1) * 1.2;
keywordRedisTemplate.opsForZSet().add("search_keywords:score", keyword, score);
}
}
private String getTimeKey(int offset) {
// ํ์ฌ ์๊ฐ
LocalDateTime now = LocalDateTime.now();
// 5๋ถ ๋จ์ ์ ๊ทํ
int minuteBlock = (now.getMinute() / 5) * 5;
now = now.withMinute(minuteBlock).withSecond(0).withNano(0);
// offset ์ ์ฉ (์: -1 โ ์ด์ ๋ธ๋ก, +1 โ ๋ค์ ๋ธ๋ก)
now = now.plusMinutes(offset * 5L);
// ํค ํฌ๋งท: search_keywords:HHmm (์: search_keywords:1415)
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HHmm");
return "search_keywords:" + now.format(formatter);
}
private double getScore(String key, String keyword) {
Double score = keywordRedisTemplate.opsForZSet().score(key, keyword);
return score != null ? score : 0.0;
}
}
@Service
@RequiredArgsConstructor
public class KeywordServiceImpl implements KeywordService {
private final RedisTemplate<String, Object> keywordRedisTemplate;
public List<String> getTopKeywords() {
return keywordRedisTemplate.opsForZSet()
.reverseRange("search_keywords:score", 0, 9)
.stream()
.map(String::valueOf)
.toList();
}
}