이전 글
https://romanc3.tistory.com/87
⌨️ 예전 구현 코드와 현재 코드를 비교
이번 포스팅에서는 이전에 구현했던, 연관게시물 서비스 로직과 지금 구현 완료한(* 운영적인 측면을 제외한 로직만) 코드를 비교하며 달라진 점에 대해 포스팅하려 한다.
다음 글들에서는 이전 코드를 리팩토링 하면서 고민한 것들 그리고 공부한 내용들을 차근히 풀어나가려 한다.
연관도를 매핑하는 기준은 이전 글에서 설명했듯이 둘 다 같은 기준으로 설정했다.
- 전체 게시물에서 60% 이상 나타나는 키워드는 연관도 검사에서 제외한다 (조사, 불명확한 단어들에 대한 배제)
- 전체 게시물에서 40% 이상 나타는 키워드가 2개 이상 포함되어 있으면 '연관되어 있다'라고 판단한다.
- 그런 키워드가 더 많이 나타날수록 우선순위가 높다(중복되는 키워드의 개수)
- 그런 키워드가 더 자주 나타날수록 우선순위가 높다(등장한 키워드의 빈도)
1. Legacy
전체 코드는 부끄럽게도 아래에서 확인이 가능하다.
https://github.com/ckaanf/KJS_Board.git
1.1. 문제 탐색
우선 처음 문제를 받고 내가 생각한 의사 결정 과정은 큰 줄기만 봐선 이렇다
1. 아티클을 키워드로 분해한다.
2. 분해한 키워드를 하나하나 전체 게시물을 탐색하며 비율을 구한다.
3. 40% 이상 나타나는 키워드가 2개인 경우 연관 게시물로 매핑해 준다.
여기까지도 저 당시에는 뜻대로 되지 않아 그 이상을 구현하지 못했다.
1.2. 로직
@Slf4j
@RequiredArgsConstructor
@Service
public class RefServiceImpl {
private final ArticleRepository articleRepository;
private final KeywordRepository keywordRepository;
//본문 조각화 키워드 저장
public List<String> saveArticleToWord(Long articleId) {
Article article = articleRepository.getReferenceById(articleId);
List<String> arr = Arrays.stream(article.getContent().split(" "))
.sorted()
.collect(Collectors.toList());
log.info("keyword: {}", arr);
for (int i = 0; i < arr.size(); i++) {
keywordRepository.save(new Keyword(articleId, arr.get(i)));
}
return arr;
}
public Set<String> getArticleKeyword(Long articleId) {
List<Keyword> keywordList = keywordRepository.getKeywordsByArticleId(articleId);
Set<String> refKeyword = new HashSet<>();
long allArticle = articleRepository.count();
for (int i = 0; i < keywordList.size(); i++) {
int cnt = 0;
long keywordRatio = articleRepository.countArticleByContentContaining(keywordList.get(i).getRefKeyword());
float ratio = (float) keywordRatio / allArticle;
if (ratio < 0.4) {
refKeyword.add(keywordList.get(i).getRefKeyword());
ratio = (float) keywordRatio / allArticle;
}
log.info(" 전체 게시글 : {}, 현재 키워드 {}, 키워드 비율 : {}", allArticle,keywordList.get(i).getRefKeyword(), ratio);
}
log.info("keyword 리스트 : {}", refKeyword);
return refKeyword;
}
}
1.3. 남 부끄러운 코드
저 당시에는 정말 열심히 했다 생각했지만.. 돌이켜보니 참 부끄럽다. 로직을 더 보여달라고 해도 보여줄 수가 없다 저게 전부이다..
첫 번째로 문제점은 게시물이 아닌 키워드에 포커스를 둔 점이다.
그렇다 보니 게시물끼리의 연관도가 아니라 그냥 단어 하나하나가 다른 게시물에 있는지 탐색하는 졸작이 되어버린 비운의 코드이다.
작금에 와선 이런 생각을 한다. 저 때 저렇게 만들어놓고 지금 어떻게 다시 도전할 생각을 했지..?
지금이라면 어떻게든 해낼 거란 자신이 있었나 보다.. 흠..
2. Present
2.1. 문제탐색
이 번 구현에서는 문제탐색부터 고민의 깊이가 달랐다.
우선 저번에 구현해내지 못했던 것 + 이전 글에서 말했던 이슈들..
새로운 게시물에 따른 연관도 변화라든지, 연관도 변화에 따른 연관 게시물 재매핑 등?
그리고 지난의 실수를 피드백 삼아 키워드가 아닌 게시물을 주체로 보고 이런 것을 해결하기 위한 방법이 뭐가 있을까 고민하던 찰나에
TF-IDF 알고리즘에 대해 알게 되었다.
저번에 놓친 부분은 게시물의 벡터화구나 즉 문자열을 연관도를 계산하기 위한 수학적 표현이 필요했다는 걸 깨달았다.
2.2. 로직
2.2.1. 게시물 벡터화와 연관도 계산
@Service
public class KeywordAndSimilarityService {
private final ArticleRepository articleRepository;
public KeywordAndSimilarityService(ArticleRepository articleRepository) {
this.articleRepository = articleRepository;
}
//TF-IDF 계산
@Cacheable(cacheNames = "tfidf", key = "#content")
public Map<String, Double> calculateTFIDF(String content) {
String[] words = content.split("\\s+");
// Calculate TF
Map<String, Integer> termFrequency = Arrays.stream(words)
.collect(Collectors.groupingBy(Function.identity(), Collectors.summingInt(e -> 1)));
// Calculate IDF
Map<String, Integer> documentFrequency = termFrequency.keySet().stream()
.collect(Collectors.toMap(
Function.identity(),
word -> (int) articleRepository.findAll().stream()
.map(article -> Optional.ofNullable(article.getContent()).orElse(""))
.filter(contentText -> contentText.contains(word))
.count()
));
int totalDocuments = articleRepository.findAll().size();
return termFrequency.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> {
double tf = (double) entry.getValue() / words.length;
double idf = Math.log((double) totalDocuments / (1 + documentFrequency.getOrDefault(entry.getKey(), 0)));
return tf * idf;
}
));
}
//코사인 유사도로 계산할 때
public double calculateCosineSimilarity(String content1, String content2) {
Map<String, Double> tfidf1 = calculateTFIDF(content1);
Map<String, Double> tfidf2 = calculateTFIDF(content2);
double dotProduct = 0;
for (String word : tfidf1.keySet()) {
dotProduct += tfidf1.getOrDefault(word, 0.0) * tfidf2.getOrDefault(word, 0.0);
}
double magnitude1 = Math.sqrt(tfidf1.values().stream().mapToDouble(value -> value * value).sum());
double magnitude2 = Math.sqrt(tfidf2.values().stream().mapToDouble(value -> value * value).sum());
double similarity = dotProduct / (magnitude1 * magnitude2);
double similarityThreshold = 0.5;
return similarity >= similarityThreshold ? similarity : 0.0;
}
//단어 빈도수 계산
public Map<String, Integer> calculateWordFrequencies() {
List<Article> articles = articleRepository.findAll();
int totalPosts = articles.size();
return articles.stream()
.map(Article::getContent)
.flatMap(content -> Arrays.stream(content.split("\\s+")))
.collect(Collectors.groupingBy(Function.identity(), Collectors.summingInt(e -> 1)))
.entrySet().stream()
.filter(entry -> entry.getValue() <= totalPosts * 0.6)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
//연관도 계산
public RelatedArticle calculateAssociationScore(Article article, Map<String, Double> targetTFIDF,
Map<String, Integer> wordFrequencies) {
float score = 0;
// content가 null이면 에러가 나서 해결하기 위한 방어코드
// content 는 @NotNull 이기 때문에 날 수 없긴 합니다.
String[] words = Optional.ofNullable(article.getContent())
.map(content -> content.split("\\s+"))
.orElse(new String[0]);
int totalWords = words.length;
int rareWordCount = 0;
for (String word : words) {
float wordFrequency = wordFrequencies.getOrDefault(word, 0);
double targetTFIDFValue = targetTFIDF.getOrDefault(word, 0.0);
if (wordFrequency / totalWords <= 0.6 && wordFrequency > 0) {
rareWordCount++;
score += targetTFIDFValue > 0 ? 1 : 0;
}
if (wordFrequency / totalWords <= 0.4 && wordFrequency > 0) {
score += targetTFIDFValue > 0 ? 1 + wordFrequency / totalWords : 0;
}
}
score *= 1 + (float) rareWordCount / totalWords;
return new RelatedArticle(article.getId(), article, score);
}
}
내가 봐도 예전 코드랑 비교했을 때 같은 사람이 짰나..? 싶을 정도로 너무나도 달라졌다.
그 바탕엔 꾸준한 Java 공부 그리고 알고리즘 코딩테스트 공부가 많은 도움이 됐다 생각한다.
이번 구현을 통해 많은 것을 배웠는데
1. 메서드를 분리해서 사용하는 것의 편의성
2. 복잡한 로직을 읽기 쉽게 구현하는 스트림의 활용
3. flatMap의 활용이다.
특히 2번 3번은 이번 구현을 통해 많이 성장했다고 느낀다.
나 스스로가 생각했을 때 코드의 주석만 읽는다면, 각 코드의 구현은 어렵지 않게 읽힐 거라 생각한다. 내가 구현해서 그런지 몰라도..
다른 팀원에게 한번 읽어달라고 하고 피드백을 받고 코멘트를 추가하겠다 :)
그나마 좀 익숙하지 않은 것이 Collectors API 일 거라 추측되는데, 한번 Docs를 살펴보면 진짜 많은 도움이 될 거다.. 꽤 좋은 메서드가 많다 ㅎ..
2.2.2. 연관 게시물 매핑
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 -> {
double similarity = keywordAndSimilarityService.calculateCosineSimilarity(keywords,
extractKeywordRepository.findById(article.getId())
.map(ExtractKeyword::getKeywords)
.orElse(""));
return new RelatedArticle(article.getId(), articleOrigin, similarity);
}).toList();*/
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;
}
// 게시물의 단어 빈도수를 가져오는 추가 메서드
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;
}
2.2.1의 로직을 통해서 우리는 게시물 간의 연관도를 계산할 수 있었다.
이를 바탕으로 2.2.2의 연관 게시물을 매핑해 주는 로직을 통해 새로운 게시물이 작성될 때 그에 맞는 연관 게시물을 설정해 줄 수 있다.
정확도는 꽤 높았다. 평문 text 기반으로써는 거의 비슷한 글감들을 매핑해 줬다. 그러나 역시 유사도 판단의 팩터가 부족하기에 가끔씩 이상
한 게시물이 매핑되는 부분에서 아쉬움을 느꼈다.
-> 아쉬움을 느낀 이상 개선 사항을 하나 체크할 수 있어서 역설적으로 좋은 기분이 먼저 들었다.
3. 느낀 점
누군가 끊임없이 공부해야 하는 직군은 필연적으로 '우매함의 봉우리'와 '낙담의 골짜기'를 마주할 수밖에 없다고 하였다.
더닝-크루거 효과라고 하는 우매함의 봉우리는 이미 많이들 익숙하실 거라 생각한다.
낙담의 골짜기란 '아주 작은 습관의 힘'이라는 책에서 본 내용인데
우리는 어떤 행위를 할 때 그 결과가 직선적으로 나타날 것을 기대하지만 실제로는 서서히 발현된다는 개념이다.
낙담의 골짜기에 빠져 허우적대는 시간 동안에도, 나는 끊임없이 관련 정보들을 찾아보고 영감을 얻곤 했다,
언젠간 다 기초 체력이 될 거란 생각에 말이다.
그런 꾸준한 습관이 이번 프로젝트에서 하나의 결실이 맺어진 것 같아서 굉장히 기뻤다.
전체적인 개요와 Before - After를 살펴봤으니 다음 글부터는 구현하면서 고민한 사항과 내가 내린 결론에 대해 구체적으로 살펴보도록 하자