사용자 맞춤형 서비스를 만들 때 추천 시스템은 빠질 수 없는 요소입니다. 앞서 엘라스틱서치 MLT를 활용하여 사용자가 검색한 결과와 유사도가 높은 뉴스를 추천하는 기능을 구현했었습니다.
이번에는 "나와 비슷한 취향을 가진 다른 사람들은 무엇을 좋아했을까?"라는 아이디어를 기반으로 뉴스 '추천' 기능을 구현해보겠습니다. 이 방식은 콘텐츠의 내용과는 무관하게, 사용자의 '좋아요' 정보를 활용하여 추천하는 기능입니다.
// 개인화 추천
@GetMapping("/news/recommend")
public List<NewsResponseDto> getRecommend(Authentication authentication) {
String userId = authentication.getName();
return recommendationService.recommendNewsBySimilarUsers(userId, 10); // 상위 10개 추천
}
@Service
@RequiredArgsConstructor
public class RecommendationServiceImpl implements RecommendationService {
private final NewsMapper newsMapper;
private final RedisTemplate<String, String> redisTemplate;
@Override
public List<NewsResponseDto> recommendNewsBySimilarUsers(String userId, int limit) {
// 1. 현재 사용자와 취향이 비슷한 사용자 조회
List<String> similarUserIds = newsMapper.searchSimilarUsers(userId, 5);
if (similarUserIds.isEmpty()) {
return Collections.emptyList();
}
// 2. 유사 사용자들이 좋아한 뉴스 ID 조회
List<Long> recommendedNewsIds = newsMapper.searchNewsIdsBySimilarUsers(similarUserIds);
if (recommendedNewsIds.isEmpty()) {
return Collections.emptyList();
}
// 3. 현재 사용자가 이미 좋아요한 뉴스 제외
List<Long> likedNewsIdsByUser = newsMapper.searchLikedNewsIdsByUser(userId);
List<Long> distinctRecommendNewsIds = new ArrayList<>();
Set<Long> set = new HashSet<>();
for (Long newsId : recommendedNewsIds) {
if (!likedNewsIdsByUser.contains(newsId) && set.add(newsId)) {
distinctRecommendNewsIds.add(newsId);
if (distinctRecommendNewsIds.size() >= limit) break;
}
}
if (distinctRecommendNewsIds.isEmpty()) {
return Collections.emptyList();
}
// 4. 뉴스 상세 조회 + Redis 데이터 결합
List<NewsResponseDto> recommendations = newsMapper.findNewsByIds(distinctRecommendNewsIds);
matchEsResultsWithRedisData(recommendations, userId);
return recommendations;
}
// Redis에 저장된 좋아요 수 & 상태 매칭
private void matchEsResultsWithRedisData(List<NewsResponseDto> newsList, String userId) {
for (NewsResponseDto news : newsList) {
String likeKey = "news:like:" + news.getNewsId();
Long likeCount = redisTemplate.opsForSet().size(likeKey);
news.setLikeCount(likeCount != null ? likeCount.intValue() : 0);
if (userId != null) {
news.setLiked(Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(likeKey, userId)));
} else {
news.setLiked(false);
}
}
}
}
<select id="searchSimilarUsers" resultType="string">
SELECT u2.user_id
FROM user_news_like u1
JOIN user_news_like u2 ON u1.news_id = u2.news_id AND u1.user_id != u2.user_id
WHERE u1.user_id = #{userId}
GROUP BY u2.user_id
ORDER BY COUNT(*) DESC
LIMIT #{limit}
</select>
<select id="searchNewsIdsBySimilarUsers" resultType="long">
SELECT DISTINCT news_id
FROM user_news_like
WHERE user_id IN
<foreach collection="list" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
<select id="searchLikedNewsIdsByUser" resultType="long">
SELECT news_id
FROM user_news_like
WHERE user_id = #{userId}
</select>
이번에 구현한 서비스는 간단히 DB 데이터만 활용하여 구현하였습니다.
기능 구현 과정은 아래와 같이 논리적 설계만 잘하면 추천 기능을 구현할 수 있었습니다.
- 현재 사용자와 유사한 사용자들('좋아요'한 데이터가 같은 사용자)을 찾는다.
- 유사한 사용자들이 좋아한 뉴스를 모은다.
- 현재 사용자가 이미 좋아요한 뉴스는 제외한다.
- 추천 후보군을 DB에서 조회하고, Redis에 저장된 좋아요 수와 상태를 결합하여 반환한다.
'사용자 A'로 로그인하여 추천을 요청하면, A와 취향이 비슷한 '사용자 B'가 좋아했던 뉴스 중에서 A가 아직 보지 않은 73번, 76번 뉴스가 추천되어야 합니다.
/api/news/recommendations 요청을 보냅니다.
이번 구현에서는 유사 사용자 기반 추천(Collaborative Filtering)을 단순화해서 적용해봤습니다. 이전에 작업한 콘텐츠 기반 추천(Content-based Filtering) 방식과 더불어 조금 더 편리한 검색, 추천 서비스를 제공할 수 있는 기능을 구현해볼 수 있었습니다.