Skip to content

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

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

Merged
merged 18 commits into from
Mar 25, 2025
Merged
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 @@ -59,7 +59,11 @@ public void resetWeight() {
this.weight = 1.0;
}

public void incrementTotalSearch() {
this.totalSearch++;
public void updateLastSearchedAt(LocalDateTime now) {
this.lastSearchedAt = now;
}

public void increaseTotalSearchBy(int count) {
this.totalSearch += count;
}
}
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,46 @@
package in.koreatech.koin.domain.community.article.model;

import in.koreatech.koin.domain.community.article.model.redis.PopularKeywordTracker;
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.ArrayList;
import java.util.List;

@Component
@RequiredArgsConstructor
public class KeywordRankingManager {

private final PopularKeywordTracker popularKeywordTracker;
private final ArticleSearchKeywordRepository keywordRepository;

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

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

return primaryKeywords;
}

private List<String> getBackupKeywords(int count) {
LocalDateTime fromDate = LocalDateTime.now().minusWeeks(1);
Pageable pageable = PageRequest.of(0, count);

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일주일동안 검색된 keyword의 개수가 추가로 필요한 count개수에 못미치더라도 해당 갯수만큼만 반환하게 되는데
의도한 부분인가요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의도한 부분 맞습니다! 항상 클라이언트에서 요구하는 것만큼의 키워드를 반환하기 위함이였습니다

if (topKeywords.isEmpty()) {
topKeywords = keywordRepository.findTopKeywordsByLatest(pageable);
}
return topKeywords;
}
Comment on lines +15 to +45
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

클래스 갯수가 늘어나더라도 크기를 작게 분리하는 것이 유지보수에 많은 도움이 된다고 생각합니다. 👍

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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.Set;
import java.util.stream.Collectors;

@Component
@RequiredArgsConstructor
public class PopularKeywordTracker {

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

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

private final RedisTemplate<String, Object> redisTemplate;

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

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
Expand Up @@ -5,29 +5,32 @@
import java.util.Optional;

import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;

import in.koreatech.koin.domain.community.article.model.ArticleSearchKeyword;
import jakarta.persistence.LockModeType;

public interface ArticleSearchKeywordRepository extends Repository<ArticleSearchKeyword, Integer> {

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

@Lock(LockModeType.PESSIMISTIC_WRITE)
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
FROM ArticleSearchKeyword k
ORDER BY k.weight DESC, k.lastSearchedAt
ORDER BY k.totalSearch DESC, k.lastSearchedAt
""")
List<String> findTopKeywordsByLatest(Pageable pageable);

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,32 +12,33 @@
@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 0 * * * *")
public void getBusNoticeArticle() {
try {
articleService.resetWeightsAndCounts();
articleSyncService.updateBusNoticeArticle();
} catch (Exception e) {
log.error("많이 검색한 키워드 초기화 중에 오류가 발생했습니다.", e);
log.error("버스 공지 게시글 조회 중에 오류가 발생했습니다.", e);
}
}

@Scheduled(cron = "0 0 * * * *")
public void getBusNoticeArticle() {
@Scheduled(cron = "0 0 */6 * * *")
public void synchronizeKeywords() {
try {
articleService.updateBusNoticeArticle();
articleSyncService.resetWeightsAndCounts();
articleSyncService.synchronizeKeywords();
} catch (Exception e) {
log.error("버스 공지 게시글 조회 중에 오류가 발생했습니다.", e);
log.error("Redis에서 MySQL로 키워드 동기화 중 오류가 발생했습니다.", e);
}
}
}
Loading
Loading