"나와 비슷한 사람들이 좋아한 뉴스는?" 협업 필터링 추천 기능 구현하기

dev.hyjang·2025년 9월 5일

사용자 맞춤형 서비스를 만들 때 추천 시스템은 빠질 수 없는 요소입니다. 앞서 엘라스틱서치 MLT를 활용하여 사용자가 검색한 결과와 유사도가 높은 뉴스를 추천하는 기능을 구현했었습니다.

이번에는 "나와 비슷한 취향을 가진 다른 사람들은 무엇을 좋아했을까?"라는 아이디어를 기반으로 뉴스 '추천' 기능을 구현해보겠습니다. 이 방식은 콘텐츠의 내용과는 무관하게, 사용자의 '좋아요' 정보를 활용하여 추천하는 기능입니다.


구현

1. 컨트롤러 계증에 '추천' API 엔드 포인트 추가

// 개인화 추천
@GetMapping("/news/recommend")
public List<NewsResponseDto> getRecommend(Authentication authentication) {
    String userId = authentication.getName();
    return recommendationService.recommendNewsBySimilarUsers(userId, 10); // 상위 10개 추천
}
  • NewsController에 GET /api/news/recommend 라는 API 엔드포인트를 만듭니다.
  • 이 엔드포인트는 Authentication 정보를 파라미터로 받아 사용자의 정보를 조회한 뒤 해당 정보를 넘겨 '추천' 결과를 반환합니다.

2. '좋아요' 데이터에 기반한 '추천' 메소드 작성

@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 데이터만 활용하여 구현하였습니다.
기능 구현 과정은 아래와 같이 논리적 설계만 잘하면 추천 기능을 구현할 수 있었습니다.

  1. 현재 사용자와 유사한 사용자들('좋아요'한 데이터가 같은 사용자)을 찾는다.
  2. 유사한 사용자들이 좋아한 뉴스를 모은다.
  3. 현재 사용자가 이미 좋아요한 뉴스는 제외한다.
  4. 추천 후보군을 DB에서 조회하고, Redis에 저장된 좋아요 수와 상태를 결합하여 반환한다.

테스트

테스트 시나리오

  • 사용자 A: 뉴스 70번, 71번, 69번, 74번 '좋아요'
  • 사용자 B: 뉴스 70번, 71번, 73번, 76번 '좋아요' (A와 1, 2번이 겹침 -> 유사도 높음)
  • 사용자 C: 뉴스 59번, 62번, 72번 '좋아요' (A와 겹치는 뉴스가 없음 -> 유사도 낮음)

'사용자 A'로 로그인하여 추천을 요청하면, A와 취향이 비슷한 '사용자 B'가 좋아했던 뉴스 중에서 A가 아직 보지 않은 73번, 76번 뉴스가 추천되어야 합니다.

API 요청

  1. '사용자 A'로 로그인 합니다.
  2. 새 요청을 열고, Method를 GET으로 설정합니다.
  3. GET /api/news/recommendations 요청을 보냅니다.

결과 조회

  • 위 테스트 시나리오에서 예상한 결과대로 반환된 뉴스 목록은 73번, 76번 뉴스로 확인하였습니다.
  • 사용자가 이미 '좋아요'를 누른 뉴스(70, 71, 69, 74번)는 추천 목록에서 제외되어 있는 것을 확인하였습니다.

이번 구현에서는 유사 사용자 기반 추천(Collaborative Filtering)을 단순화해서 적용해봤습니다. 이전에 작업한 콘텐츠 기반 추천(Content-based Filtering) 방식과 더불어 조금 더 편리한 검색, 추천 서비스를 제공할 수 있는 기능을 구현해볼 수 있었습니다.

profile
낭만감자

0개의 댓글