Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: 많이 조회한 게시글 키워드 구조 변경 #1061

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,8 @@ public void resetWeight() {
public void incrementTotalSearch() {
this.totalSearch++;
}

public void updateLastSearchedAt(LocalDateTime now) {
this.lastSearchedAt = now;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ public void incrementSearchCount() {
this.searchCount++;
}

public void incrementSearchCountBy(Integer additionalCount) {
if (additionalCount == null || additionalCount <= 0) {
return;
}
this.searchCount += additionalCount;
}

public void resetSearchCount() {
this.searchCount = 0;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package in.koreatech.koin.domain.community.article.model.redis;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import org.springframework.data.redis.core.ZSetOperations.TypedTuple;

import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Component
@RequiredArgsConstructor
public class RedisKeywordTracker {

private static final double FIXED_WEIGHT_AFTER_FIVE_SEARCHES = 0.0625;
private static final int MAX_SEARCH_COUNT_FOR_WEIGHT = 10;

private final RedisTemplate<String, Object> redisTemplate;

private static final String KEYWORD_SET = "popular_keywords";
private static final String IP_SEARCH_COUNT_PREFIX = "search:count:ip:";

public void updateKeywordWeight(String ipAddress, String keyword) {
if (keyword == null || keyword.isBlank() || ipAddress == null || ipAddress.isBlank()) {
return;
}

String ipSearchCountKey = IP_SEARCH_COUNT_PREFIX + ipAddress;

Long currentIpCount = redisTemplate.opsForHash().increment(ipSearchCountKey, keyword, 1);
if (currentIpCount == null) currentIpCount = 1L;

double additionalWeight = calculateWeight(currentIpCount);
if (additionalWeight > 0) {
redisTemplate.opsForZSet().incrementScore(KEYWORD_SET, keyword, additionalWeight);
}
}

private double calculateWeight(Long currentCount) {
if (currentCount <= 5) {
return 1.0 / Math.pow(2, currentCount - 1);
} else if (currentCount <= MAX_SEARCH_COUNT_FOR_WEIGHT) {
return FIXED_WEIGHT_AFTER_FIVE_SEARCHES;
}
return 0.0;
}

public Set<String> getTopKeywords(int limit) {
Set<TypedTuple<Object>> keywordTuples = redisTemplate.opsForZSet()
.reverseRangeWithScores(KEYWORD_SET, 0, limit - 1);

if (keywordTuples == null || keywordTuples.isEmpty()) {
return Set.of();
}

return keywordTuples.stream()
.map(TypedTuple::getValue)
.map(String::valueOf)
.collect(Collectors.toSet());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package in.koreatech.koin.domain.community.article.model.strategy;

import in.koreatech.koin.domain.community.article.repository.ArticleSearchKeywordRepository;
import lombok.RequiredArgsConstructor;

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.List;

@Component
@RequiredArgsConstructor
public class FallbackKeywordStrategy implements KeywordRetrievalStrategy {

private final ArticleSearchKeywordRepository keywordRepository;

@Override
public List<String> getTopKeywords(int count) {
LocalDateTime fromDate = LocalDateTime.now().minusWeeks(1);
Pageable pageable = PageRequest.of(0, count);

List<String> topKeywords = keywordRepository.findTopKeywords(fromDate, pageable);

if (topKeywords.isEmpty()) {
topKeywords = keywordRepository.findTopKeywordsByLatest(pageable);
}

return topKeywords;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package in.koreatech.koin.domain.community.article.model.strategy;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

@Component
@RequiredArgsConstructor
public class KeywordRetrievalContext {

private final RedisKeywordStrategy redisKeywordStrategy;
private final FallbackKeywordStrategy fallbackKeywordStrategy;

public List<String> retrieveTopKeywords(int count) {
List<String> primaryKeywords = redisKeywordStrategy.getTopKeywords(count);
List<String> finalKeywords = new ArrayList<>(primaryKeywords);

if (primaryKeywords.size() < count) {
int remainingCount = count - primaryKeywords.size();
List<String> secondaryKeywords = fallbackKeywordStrategy.getTopKeywords(remainingCount);
secondaryKeywords.stream()
.filter(keyword -> !finalKeywords.contains(keyword))
.forEach(finalKeywords::add);
}

return finalKeywords;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package in.koreatech.koin.domain.community.article.model.strategy;

import java.util.List;

public interface KeywordRetrievalStrategy {
List<String> getTopKeywords(int count);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package in.koreatech.koin.domain.community.article.model.strategy;

import in.koreatech.koin.domain.community.article.model.redis.RedisKeywordTracker;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

@Component
@RequiredArgsConstructor
public class RedisKeywordStrategy implements KeywordRetrievalStrategy {

private final RedisKeywordTracker redisKeywordTracker;

@Override
public List<String> getTopKeywords(int count) {
Set<String> redisKeywords = redisKeywordTracker.getTopKeywords(count);
return new ArrayList<>(redisKeywords);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@

public interface ArticleSearchKeywordRepository extends Repository<ArticleSearchKeyword, Integer> {

void save(ArticleSearchKeyword keyword);
ArticleSearchKeyword save(ArticleSearchKeyword keyword);

Optional<ArticleSearchKeyword> findByKeyword(String keywordStr);

@Query("""
SELECT k.keyword
FROM ArticleSearchKeyword k
WHERE k.lastSearchedAt >= :oneWeekAgo
WHERE k.lastSearchedAt >= :fromDate
ORDER BY k.weight DESC, k.lastSearchedAt DESC
""")
List<String> findTopKeywords(LocalDateTime oneWeekAgo, Pageable pageable);
List<String> findTopKeywords(LocalDateTime fromDate, Pageable pageable);

@Query("""
SELECT k.keyword
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import in.koreatech.koin.domain.community.article.service.ArticleService;
import in.koreatech.koin.domain.community.article.service.ArticleSyncService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

Expand All @@ -12,30 +12,30 @@
@RequiredArgsConstructor
public class ArticleScheduler {

private final ArticleService articleService;
private final ArticleSyncService articleSyncService;

@Scheduled(cron = "0 0 6 * * *")
public void updateHotArticles() {
try {
articleService.updateHotArticles();
articleSyncService.updateHotArticles();
} catch (Exception e) {
log.error("인기 게시글 업데이트 중에 오류가 발생했습니다.", e);
}
}

@Scheduled(cron = "0 0 0/6 * * *")
public void resetOldKeywordsAndIpMaps() {
@Scheduled(cron = "0 */5 * * * *")
public void syncKeywordsToDatabase() {
try {
articleService.resetWeightsAndCounts();
articleSyncService.synchronizeSearchKeywords();
} catch (Exception e) {
log.error("많이 검색한 키워드 초기화 중에 오류가 발생했습니다.", e);
log.error("Redis에서 MySQL로 키워드 동기화 중 오류가 발생했습니다.", e);
}
}

@Scheduled(cron = "0 0 * * * *")
public void getBusNoticeArticle() {
try {
articleService.updateBusNoticeArticle();
articleSyncService.updateBusNoticeArticle();
} catch (Exception e) {
log.error("버스 공지 게시글 조회 중에 오류가 발생했습니다.", e);
}
Expand Down
Loading
Loading