이전 글
2024.01.02 - [PROJECT/KOSA] - [PROJECT] KOSA 최종 프로젝트 진행 (1) - 연관 게시물 : 연관에 대하여 [0/0]
2024.01.05 - [PROJECT/KOSA] - [PROJECT] KOSA 최종 프로젝트 진행 (1) - 연관 게시물 : Legacy와 현재 구현한 코드 [2/0]
2024.01.07 - [PROJECT/KOSA] - [PROJECT] KOSA 최종 프로젝트 진행 (1) - 연관 게시물 : 연관도 변화 [3/0]
⌨️ 캐시
1. 캐시
우선 왜 캐시를 사용했는지에 대해 얘기해 보자
1.1. 캐시의 사용목적
내가 생각하는 캐시의 사용목적은 이렇다
- 동일한 API 요청이 반복
- API 응답이 변하지 않음
- API 응답이 자주 업데이트 되지 않음
위와 같은 목적이 내가 고민하던 것과 딱 맞아떨어졌다.
1. Batch를 통한 일괄작업의 반복 -> 동일한 API 요청
2. API 응답이 새로운 연관게시물을 매핑해 주기 전까지는 바뀌지 않음
3. 2와 마찬가지로 일정 주기가 되기 전까지 응답이 업데이트되지 않음
이런 사용 용도를 가지고 목표에 부합하다 생각하여 캐시를 사용하게 되었다.
1.2. 캐시 사용의 장점
내가 필요로 한 목표는 '응답 시간의 단축'과 '서버 부하의 분담'이다.
처음 연관 게시물을 구현하며 느낀 것은
비즈니스 로직이 들어가는 순간 생각보다 조회에 시간이 걸린다는 것이다.
그러한 측면에서 우리는 데이터의 변동이 없다면 굳이 서버에 다시 요청을 보내지 않고 캐시를 통해 가져오는 것이 훨씬 이득일 수 있다.
2. 🌟문제 상황 발견
2.1. 캐시 로직의 분리 필요
다시 한번 이전의 코드를 볼 필요가 있을 것 같다.
@Slf4j
@Service
public class RecommendationService {
private final ArticleRepository articleRepository;
private final ExtractKeywordRepository extractKeywordRepository;
private final KeywordAndSimilarityService keywordAndSimilarityService;
private static final String RECOMMENDATIONS_CACHE_NAME = "recommendations";
public RecommendationService(ArticleRepository articleRepository, ExtractKeywordRepository extractKeywordRepository,
KeywordAndSimilarityService keywordAndSimilarityService) {
this.articleRepository = articleRepository;
this.extractKeywordRepository = extractKeywordRepository;
this.keywordAndSimilarityService = keywordAndSimilarityService;
}
@Cacheable(cacheNames = RECOMMENDATIONS_CACHE_NAME, key = "#articleId")
public List<RelatedArticle> getRecommendationsFromCache(Long articleId) {
return calculateAndCacheRecommendations(articleId);
}
@CacheEvict(cacheNames = RECOMMENDATIONS_CACHE_NAME, key = "#aritcleId")
public void evictRecommendationsCache(Long articleId) {
}
public List<RelatedArticle> calculateAndCacheRecommendations(Long articleId){
Article articleOrigin = articleRepository.findById(articleId).orElseThrow(()->new EntityNotFoundException("게시물이 존재하지 않습니다"));
String keywords = extractKeywordRepository.findById(articleId)
.map(ExtractKeyword::getKeywords)
.orElse("");
Map<String, Double> targetTFIDF = keywordAndSimilarityService.calculateTFIDF(keywords);
List<RelatedArticle> recommendations = articleRepository.findAll().stream()
.filter(article -> !article.getId().equals(articleId))
.map(article -> keywordAndSimilarityService.calculateAssociationScore(article, targetTFIDF, getWordFrequencies(article)))
.filter(relatedArticle -> relatedArticle.getRelevanceScore() >0)
.sorted(Comparator.comparing(RelatedArticle::getRelevanceScore).reversed()) // Sort by relevance score in descending order
.collect(Collectors.toList());
articleOrigin.setRelatedArticleList(recommendations);
cacheRecommendations(articleId, recommendations);
return recommendations;
}
void cacheRecommendations(Long articleId, List<RelatedArticle> relatedArticles){
}
// 게시물의 단어 빈도수를 가져오는 추가 메서드
private Map<String, Integer> getWordFrequencies(Article article) {
// 게시물의 단어 빈도수를 계산하는 로직을 구현
// AssociationCalculator와 유사한 방법을 사용할 수 있습니다
Map<String, Integer> wordFrequencies = new HashMap<>();
String[] words = Optional.ofNullable(article.getContent())
.map(content -> content.split("\\s+"))
.orElse(new String[0]);
for (String word : words) {
wordFrequencies.put(word, wordFrequencies.getOrDefault(word, 0) + 1);
}
return wordFrequencies;
}
}
문제는 getRecommendationsFromCache()와 calculateAndCacheRecommendations()에서 있었다.
현재 Controller에서 연관 게시물 호출을 하면 getRecommendationsFromCache() 메서드가 작동하여 calculateAndCacheRecommendations()를 호출한다.
즉 호출할 때 마다 계산을 해서 캐싱하고 그 리턴 값을 가져온다는 것이다.
자 그럼 목차 1에서 설명했던 캐시를 사용하는 이유가 없어진다. 캐시의 변경도 잦고, 매번 계산을 하니 말이다.
개발 당시에는 정신이 없어 놓쳤던 것 같다. 후에 회고를 위해 서버를 다시 기동시 키니 의문이 들었다 캐시에서 데이터를 가져오는데
왜 hibernate 쿼리가 나가지?? 그 의문은 결국 나의 실수를 바로 잡는 열쇠가 되었고 현재는 매우 빠른 속도로 작동을 한다.
나는 여기서 캐시된 데이터를 가져오는 것과 계산해서 저장하는 것을 분리하지 않았다는 것을 깨닫는다.
2.2. 변경된 서비스 로직
public List<RelatedArticle> calculateAndCacheRecommendations(Long articleId){
List<RelatedArticle> recommendations = getRecommendations(articleId);
cacheRecommendations(articleId, recommendations);
return recommendations;
}
private List<RelatedArticle> getRecommendationsDirectlyFromCache(Long articleId) {
log.info("Fetching recommendations directly from cache for articleId: {}", articleId);
return Optional.ofNullable(cacheManager.getCache(RECOMMENDATIONS_CACHE_NAME))
.map(cache -> cache.get(articleId, List.class))
.orElseGet(() -> {
log.info("Cache miss. Fetching recommendations from the original logic.");
return getRecommendations(articleId);
});
}
private List<RelatedArticle> getRecommendations(Long articleId){
Article articleOrigin = articleRepository.findById(articleId)
.orElseThrow(() -> new EntityNotFoundException("게시물이 존재하지 않습니다"));
String keywords = extractKeywordRepository.findById(articleId)
.map(ExtractKeyword::getKeywords)
.orElse("");
Map<String, Double> targetTFIDF = keywordAndSimilarityService.calculateTFIDF(keywords);
List<RelatedArticle> recommendations = articleRepository.findAll().stream()
.filter(article -> !article.getId().equals(articleId))
.map(article -> keywordAndSimilarityService.calculateAssociationScore(article, targetTFIDF,
getWordFrequencies(article)))
.filter(relatedArticle -> relatedArticle.getRelevanceScore() > 0)
.sorted(Comparator.comparing(RelatedArticle::getRelevanceScore).reversed())
.collect(Collectors.toList());
articleOrigin.setRelatedArticleList(recommendations);
return recommendations;
}
나머지는 그대로고 올려놓은 코드만 변경이 있다.
- 캐시 조회 메서드와 연관게시물을 계산하여 캐싱하는 메서드를 분리
- get 시 캐시에 데이터가 있으면 바로 조회하고 아니면 연산하는 것으로 구현
정도의 변경 사항이 있겠다.
그동안 캐시된 데이터가 왜 느리지? 하고 의문을 가지고 이래저래 바빠서 리팩토링을 미뤘다가 회고할 때가 되어서야 한다ㅎ..
덕분에 또 많이 배웠고 다음 리팩토링 방향에 대해 가닥이 잡혔다
3. 개선 방향
3.1 Redis 활용하여 클러스터링
다행하게도 Redis에서 클러스터링을 지원한다.
물론 당장 필요하지는 않지만 구직활동이 조금 잠잠해지면 Massive 하게 데이터를 넣어보고 속도를 체크한 후 클러스터링이 필요하다 판단되면 Redis를 활용하여 개선해보려 한다.
그다음에는 이 모든 것을 Elastic Search를 활용하여 퍼포먼스를 확실히 끌어올리는 식으로 점진적 개선을 해보려 한다.