diff --git a/src/main/java/in/koreatech/koin/domain/community/article/model/SearchLogEvent.java b/src/main/java/in/koreatech/koin/domain/community/article/model/SearchLogEvent.java new file mode 100644 index 000000000..6995bd8ef --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/model/SearchLogEvent.java @@ -0,0 +1,11 @@ +package in.koreatech.koin.domain.community.article.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class SearchLogEvent { + private final String query; + private final String ipAddress; +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/model/SearchLogEventListener.java b/src/main/java/in/koreatech/koin/domain/community/article/model/SearchLogEventListener.java new file mode 100644 index 000000000..c206e8111 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/model/SearchLogEventListener.java @@ -0,0 +1,82 @@ +package in.koreatech.koin.domain.community.article.model; + +import java.time.LocalDateTime; + +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.community.article.repository.ArticleSearchKeywordIpMapRepository; +import in.koreatech.koin.domain.community.article.repository.ArticleSearchKeywordRepository; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class SearchLogEventListener { + + private final ArticleSearchKeywordRepository articleSearchKeywordRepository; + private final ArticleSearchKeywordIpMapRepository articleSearchKeywordIpMapRepository; + + private static final double INITIAL_WEIGHT = 1.0; + private static final int MAX_DYNAMIC_WEIGHT_COUNT = 5; + private static final int FIXED_WEIGHT_LIMIT = 10; + private static final double FIXED_WEIGHT = 1.0 / Math.pow(2, 4); + + @EventListener + public void handleSearchLogEvent(SearchLogEvent event) { + String query = event.getQuery(); + String ipAddress = event.getIpAddress(); + + if (query == null || query.trim().isEmpty()) { + return; + } + + String[] keywords = query.split("\\s+"); + + for (String keywordStr : keywords) { + ArticleSearchKeyword keyword = articleSearchKeywordRepository.findByKeyword(keywordStr) + .orElseGet(() -> { + ArticleSearchKeyword newKeyword = ArticleSearchKeyword.builder() + .keyword(keywordStr) + .weight(INITIAL_WEIGHT) + .lastSearchedAt(LocalDateTime.now()) + .totalSearch(1) + .build(); + articleSearchKeywordRepository.save(newKeyword); + return newKeyword; + }); + + ArticleSearchKeywordIpMap map = articleSearchKeywordIpMapRepository.findByArticleSearchKeywordAndIpAddress( + keyword, ipAddress) + .orElseGet(() -> { + ArticleSearchKeywordIpMap newMap = ArticleSearchKeywordIpMap.builder() + .articleSearchKeyword(keyword) + .ipAddress(ipAddress) + .searchCount(1) + .build(); + articleSearchKeywordIpMapRepository.save(newMap); + return newMap; + }); + + updateKeywordWeightAndCount(keyword, map); + } + } + + private void updateKeywordWeightAndCount(ArticleSearchKeyword keyword, ArticleSearchKeywordIpMap map) { + map.incrementSearchCount(); + double additionalWeight = calculateWeight(map.getSearchCount()); + + if (map.getSearchCount() <= FIXED_WEIGHT_LIMIT) { + keyword.updateWeight(keyword.getWeight() + additionalWeight); + } + articleSearchKeywordRepository.save(keyword); + } + + private double calculateWeight(int searchCount) { + if (searchCount <= MAX_DYNAMIC_WEIGHT_COUNT) { + return 1.0 / Math.pow(2, searchCount - 1); + } else if (searchCount <= FIXED_WEIGHT_LIMIT) { + return FIXED_WEIGHT; + } + return 0.0; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java index a98721dfb..92335db43 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java @@ -10,6 +10,7 @@ import java.util.Optional; import java.util.stream.Collectors; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -23,9 +24,8 @@ import in.koreatech.koin.domain.community.article.dto.HotArticleItemResponse; import in.koreatech.koin.domain.community.article.exception.ArticleBoardMisMatchException; import in.koreatech.koin.domain.community.article.model.Article; -import in.koreatech.koin.domain.community.article.model.ArticleSearchKeyword; -import in.koreatech.koin.domain.community.article.model.ArticleSearchKeywordIpMap; import in.koreatech.koin.domain.community.article.model.Board; +import in.koreatech.koin.domain.community.article.model.SearchLogEvent; import in.koreatech.koin.domain.community.article.model.redis.ArticleHit; import in.koreatech.koin.domain.community.article.model.redis.ArticleHitUser; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; @@ -35,7 +35,6 @@ import in.koreatech.koin.domain.community.article.repository.redis.ArticleHitRepository; import in.koreatech.koin.domain.community.article.repository.redis.ArticleHitUserRepository; import in.koreatech.koin.domain.community.article.repository.redis.HotArticleRepository; -import in.koreatech.koin.global.concurrent.ConcurrencyGuard; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import in.koreatech.koin.global.model.Criteria; import lombok.RequiredArgsConstructor; @@ -64,6 +63,7 @@ public class ArticleService { private final HotArticleRepository hotArticleRepository; private final ArticleHitUserRepository articleHitUserRepository; private final Clock clock; + private final ApplicationEventPublisher eventPublisher; @Transactional public ArticleResponse getArticle(Integer boardId, Integer articleId, String publicIp) { @@ -136,7 +136,7 @@ public ArticlesResponse searchArticles(String query, Integer boardId, Integer pa articles = articleRepository.findAllByBoardIdAndTitleContaining(boardId, query, pageRequest); } - saveOrUpdateSearchLog(query, ipAddress); + eventPublisher.publishEvent(new SearchLogEvent(query, ipAddress)); return ArticlesResponse.of(articles, criteria); } @@ -154,90 +154,6 @@ public ArticleHotKeywordResponse getArticlesHotKeyword(Integer count) { return ArticleHotKeywordResponse.from(topKeywords); } - @ConcurrencyGuard(lockName = "searchLog") - private void saveOrUpdateSearchLog(String query, String ipAddress) { - if (query == null || query.trim().isEmpty()) { - return; - } - - String[] keywords = query.split("\\s+"); - - for (String keywordStr : keywords) { - ArticleSearchKeyword keyword = articleSearchKeywordRepository.findByKeyword(keywordStr) - .orElseGet(() -> { - ArticleSearchKeyword newKeyword = ArticleSearchKeyword.builder() - .keyword(keywordStr) - .weight(1.0) - .lastSearchedAt(LocalDateTime.now()) - .totalSearch(1) - .build(); - articleSearchKeywordRepository.save(newKeyword); - return newKeyword; - }); - - ArticleSearchKeywordIpMap map = articleSearchKeywordIpMapRepository.findByArticleSearchKeywordAndIpAddress( - keyword, ipAddress) - .orElseGet(() -> { - ArticleSearchKeywordIpMap newMap = ArticleSearchKeywordIpMap.builder() - .articleSearchKeyword(keyword) - .ipAddress(ipAddress) - .searchCount(1) - .build(); - articleSearchKeywordIpMapRepository.save(newMap); - return newMap; - }); - - updateKeywordWeightAndCount(keyword, map); - } - } - - /* - * 추가될 가중치를 계산합니다. 검색 횟수(map.getSearchCount())에 따라 가중치를 다르게 부여합니다: - * - 검색 횟수가 5회 이하인 경우: 1.0 / 2^(검색 횟수 - 1) - * 예: - * 첫 번째 검색: 1.0 - * 두 번째 검색: 0.5 - * 세 번째 검색: 0.25 - * 네 번째 검색: 0.125 - * 다섯 번째 검색: 0.0625 - * - 검색 횟수가 5회를 초과하면: 1.0 / 2^4 (즉, 0.0625) 고정 가중치를 부여합니다. - * - 검색 횟수가 10회를 초과할 경우: 추가 가중치를 부여하지 않습니다. - */ - private void updateKeywordWeightAndCount(ArticleSearchKeyword keyword, ArticleSearchKeywordIpMap map) { - map.incrementSearchCount(); - double additionalWeight = 0.0; - - if (map.getSearchCount() <= 5) { - additionalWeight = 1.0 / Math.pow(2, map.getSearchCount() - 1); - } else if (map.getSearchCount() <= 10) { - additionalWeight = 1.0 / Math.pow(2, 4); - } - - if (map.getSearchCount() <= 10) { - keyword.updateWeight(keyword.getWeight() + additionalWeight); - } - articleSearchKeywordRepository.save(keyword); - } - - @Transactional - public void resetWeightsAndCounts() { - LocalDateTime now = LocalDateTime.now(); - LocalDateTime before = now.minusHours(6).minusMinutes(30); - - List keywordsToUpdate = articleSearchKeywordRepository.findByUpdatedAtBetween( - before, now); - List ipMapsToUpdate = articleSearchKeywordIpMapRepository.findByUpdatedAtBetween( - before, now); - - for (ArticleSearchKeyword keyword : keywordsToUpdate) { - keyword.resetWeight(); - } - - for (ArticleSearchKeywordIpMap ipMap : ipMapsToUpdate) { - ipMap.resetSearchCount(); - } - } - @Transactional public void updateHotArticles() { List articleHits = articleHitRepository.findAll();