๐Ÿ“Š Redis๋ฅผ ํ™œ์šฉํ•œ ์‹ค์‹œ๊ฐ„ ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด ๊ธฐ๋Šฅ ๊ตฌํ˜„

์ด๊ฒฝํ—Œยท2025๋…„ 5์›” 19์ผ
0

โœ… ๊ฐœ์š”

์‚ฌ์šฉ์ž์˜ ๊ฒ€์ƒ‰ ํ™œ๋™์„ ๋ฐ”ํƒ•์œผ๋กœ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด๋ฅผ ์ˆ˜์ง‘ํ•˜๊ณ  ๋ถ„์„ํ•˜์—ฌ, ํŠธ๋ Œ๋””ํ•œ ํ‚ค์›Œ๋“œ๋ฅผ ์‚ฌ์šฉ์ž์—๊ฒŒ ์ œ๊ณตํ•˜๋Š” ๊ธฐ๋Šฅ์ž…๋‹ˆ๋‹ค.
ElasticSearch ๊ธฐ๋ฐ˜ ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ๊ณผ Redis, ์Šค์ผ€์ค„๋ง์„ ์กฐํ•ฉํ•ด ์„ฑ๋Šฅ๊ณผ ์‹ค์‹œ๊ฐ„์„ฑ์„ ๋ชจ๋‘ ๊ณ ๋ คํ•œ ๊ตฌ์กฐ์ž…๋‹ˆ๋‹ค.


โš™๏ธ ์‹œ์Šคํ…œ ๊ตฌ์„ฑ

1. ๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ ๊ธฐ๋ก (AOP ๊ธฐ๋ฐ˜)

  • ํด๋ž˜์Šค: KeywordAspect

  • ๋™์ž‘ ์‹œ์ : ์‚ฌ์šฉ์ž๊ฐ€ ๊ฒŒ์‹œ๋ฌผ์„ ๊ฒ€์ƒ‰ํ•  ๋•Œ ElasticSearchService.searchPosts(...) ๋ฉ”์„œ๋“œ ์‹คํ–‰ ํ›„

  • ๊ธฐ๋Šฅ:

    • ํ˜„์žฌ ์‹œ๊ฐ ๊ธฐ์ค€ 5๋ถ„ ๋‹จ์œ„ ํ‚ค (search_keywords:HHmm)์— ๋Œ€ํ•ด ํ‚ค์›Œ๋“œ์˜ ์กฐํšŒ์ˆ˜๋ฅผ 1 ์ฆ๊ฐ€

    • 1์‹œ๊ฐ„ ๋™์•ˆ ์œ ํšจํ•œ TTL ์„ค์ • (expire)

    • ๋ˆ„์  ํ‚ค์›Œ๋“œ ์ง‘๊ณ„ (search_keywords:total)๋„ ํ•จ๊ป˜ ์ฆ๊ฐ€์‹œ์ผœ ์ „์ฒด ๊ฒ€์ƒ‰๋Ÿ‰ ์œ ์ง€

    • ๋งˆ์ง€๋ง‰ ์‚ฌ์šฉ ์‹œ๊ฐ์„ ํ•ด์‹œ ๊ตฌ์กฐ๋กœ ๊ธฐ๋ก

      • Redis Hash Key: search_keywords_last_used
      • field: ํ‚ค์›Œ๋“œ / value: ISO ํ˜•์‹์˜ ๋งˆ์ง€๋ง‰ ๊ฒ€์ƒ‰ ์‹œ๊ฐ (2025-05-15T14:25:00 ๋“ฑ)

2. ํŠธ๋ Œ๋“œ ๊ณ„์‚ฐ ์Šค์ผ€์ค„๋Ÿฌ

  • ํด๋ž˜์Šค: RankKeywordScheduler

  • ์Šค์ผ€์ค„๋ง: 5๋ถ„๋งˆ๋‹ค ์‹คํ–‰ (@Scheduled(cron = "0 */5 * * * *"))

  • ๊ธฐ๋Šฅ:

    • ์ตœ๊ทผ 10๋ถ„ ๊ฐ„(ํ˜„์žฌ -5๋ถ„, -10๋ถ„) ๊ฒ€์ƒ‰๋œ ํ‚ค์›Œ๋“œ๋“ค์„ ์ง‘๊ณ„

    • ๊ฐ ํ‚ค์›Œ๋“œ์— ๋Œ€ํ•ด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ ์ˆ˜ ๊ณ„์‚ฐ ์ˆ˜ํ–‰:

      ์ ์ˆ˜ = (์ง์ „ 5๋ถ„ ๋Œ€๋น„ ์ฆ๊ฐ€๋Ÿ‰ * ๊ฐ€์ค‘์น˜ 2.0) + (๋ˆ„์  ๊ฒ€์ƒ‰๋Ÿ‰ ๋กœ๊ทธ๊ฐ’ * ๊ฐ€์ค‘์น˜ 1.2)
    • ๊ณ„์‚ฐ๋œ ์ ์ˆ˜๋Š” Redis ZSet search_scores:computed์— ์ €์žฅ


3. ๋ฏธ์‚ฌ์šฉ ํ‚ค์›Œ๋“œ ์ •๋ฆฌ ์Šค์ผ€์ค„๋Ÿฌ

  • ํด๋ž˜์Šค: RankKeywordScheduler

  • ์Šค์ผ€์ค„๋ง: ๋งค์ผ ์ž์ • ์‹คํ–‰ (@Scheduled(cron = "0 0 0 * * *"))

  • ๊ธฐ๋Šฅ:

    • ํ•ด์‹œ(search_keywords_last_used)์— ๊ธฐ๋ก๋œ ๋งˆ์ง€๋ง‰ ์‚ฌ์šฉ ์‹œ๊ฐ์„ ๊ธฐ์ค€์œผ๋กœ

    • ํ•˜๋ฃจ ์ด์ƒ ์‚ฌ์šฉ๋˜์ง€ ์•Š์€ ํ‚ค์›Œ๋“œ ์ œ๊ฑฐ:

      • search_keywords:total ์—์„œ ์ œ๊ฑฐ
      • search_scores:computed ์—์„œ ์ œ๊ฑฐ
      • search_keywords_last_used ์—์„œ๋„ ์ œ๊ฑฐ
    • ๋ถˆํ•„์š”ํ•œ ๋ฉ”๋ชจ๋ฆฌ ์ ์œ  ๋ฐฉ์ง€ ๋ฐ ํŠธ๋ Œ๋“œ ์ •ํ™•์„ฑ ํ–ฅ์ƒ


4. Top N ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด ๋…ธ์ถœ

  • Redis ZSet search_scores:computed์—์„œ ์ ์ˆ˜ ์ˆœ์œผ๋กœ ์ •๋ ฌ๋œ ํ‚ค์›Œ๋“œ ์กฐํšŒ
  • ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ์ธ๊ธฐ ํ‚ค์›Œ๋“œ๋ฅผ ์Šฌ๋ผ์ด๋”ฉ UI ๋“ฑ์œผ๋กœ ์‹ค์‹œ๊ฐ„ ์‹œ๊ฐํ™”

์„ค๊ณ„ ํฌ์ธํŠธ

ํ•ญ๋ชฉ์„ค๋ช…
์„ฑ๋ŠฅRedis ๊ธฐ๋ฐ˜ ZSet/Hash ์‚ฌ์šฉ์œผ๋กœ ๋น ๋ฅธ ์ฝ๊ธฐ/์“ฐ๊ธฐ ๋ฐ ์ •๋ ฌ ๊ฐ€๋Šฅ
์‹ค์‹œ๊ฐ„์„ฑ5๋ถ„ ๋‹จ์œ„ ์‹œ๊ฐ„ ํ‚ค ์ฒ˜๋ฆฌ ๋ฐ ์ฃผ๊ธฐ์  ํŠธ๋ Œ๋“œ ๊ฐฑ์‹ ์œผ๋กœ ์ตœ์‹  ๋ฐ์ดํ„ฐ ๋ฐ˜์˜
AOP ๋„์ž…ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง๊ณผ ๋ถ„๋ฆฌ๋˜์–ด ๊ด€์‹ฌ์‚ฌ ๋ถ„๋ฆฌ๊ฐ€ ์ž˜ ๋˜์–ด ์žˆ์Œ
ํšจ์œจ์ ์ธ ์ •๋ฆฌ๊ฒ€์ƒ‰๋˜์ง€ ์•Š๋Š” ํ‚ค์›Œ๋“œ๋ฅผ ์ž๋™ ์ •๋ฆฌํ•˜์—ฌ ๋ฉ”๋ชจ๋ฆฌ ๋‚ญ๋น„ ๋ฐฉ์ง€ ๋ฐ ์„ฑ๋Šฅ ์œ ์ง€
ํ™•์žฅ์„ฑ์ ์ˆ˜ ์‚ฐ์ • ๋ฐฉ์‹ ๋ฐ ์ œ๊ฑฐ ์ฃผ๊ธฐ ์œ ์—ฐํ•˜๊ฒŒ ์กฐ์ ˆ ๊ฐ€๋Šฅ (๊ฐ€์ค‘์น˜ ์กฐ์ •, ๊ธฐ๊ฐ„ ์„ค์ • ๋“ฑ)

Redis ํ‚ค ๊ตฌ์กฐ ์˜ˆ์‹œ

ํ‚ค ์ด๋ฆ„์„ค๋ช…
search_keywords:142514์‹œ 25๋ถ„ ๊ธฐ์ค€ ๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ ZSet
search_keywords:total์ „์ฒด ๋ˆ„์  ๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ ZSet
search_keywords_last_used๊ฐ ํ‚ค์›Œ๋“œ๋ณ„ ๋งˆ์ง€๋ง‰ ๊ฒ€์ƒ‰ ์‹œ๊ฐ ์ €์žฅ์šฉ Hash (field=keyword, value=ISO timestamp)
search_scores:computed๊ณ„์‚ฐ๋œ ์ธ๊ธฐ ์ ์ˆ˜ ํ‚ค์›Œ๋“œ ZSet (UI์—์„œ ์ธ๊ธฐ ํ‚ค์›Œ๋“œ๋กœ ๋…ธ์ถœ)

์ฝ”๋“œ ์ •๋ฆฌ

RedisConfig.java

  • DB index 4๋ฒˆ์„ ์ „์šฉ์œผ๋กœ ํ• ๋‹นํ•˜์—ฌ ๋‹ค๋ฅธ Redis ๋ฐ์ดํ„ฐ์™€ ์ถฉ๋Œ์„ ๋ฐฉ์ง€ํ•ฉ๋‹ˆ๋‹ค.
@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;
	}

}

KeywordAspect.java

@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);
	}

}

TopKeywordScheduler.java

@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;
	}
}

KeywordServiceImpl.java

@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();
	}
}

0๊ฐœ์˜ ๋Œ“๊ธ€