카테고리 없음

개인별 맞춤 추천 고도화 과정

suesoo 2025. 4. 15. 03:08

개인별 맞춤 추천 기능 구현

프로젝트를 진행하며 개인별 맞춤 추천 기능을 어떻게 구현할 것인지 고민하게 되었다.
요즘은 거의 모든 서비스 플랫폼에서 개인별 추천 기능이 있고, 스트리밍, 쇼핑몰, 뉴스, 음악 등 많은 곳에서 사용되고 있다.

이러한 개인별 맞춤 추천 기능은 사이트의 체류시간 증가와 정교한 마케팅을 위해 꼭  필요한 기능이고, 개인프로젝트에서 꼭 경험해보고 싶어서 구현을 결정하게 되었다!

1단계  카테고리 기반 추천

public Page<Book> findRecommendedBooks(User user, int page, int size) {
    Pageable pageable = PageRequest.of(page, size, Sort.by("viewCount").descending());
    Optional<Book> recentBook = rentRepository.findRecentRentedBookByUserId(user.getId());

    if (recentBook.isPresent()) {
        String category = recentBook.get().getCategory();
        return bookRepository.findByCategoryOrderByViewCountDesc(category, pageable);
    } else {
        return bookRepository.findAllByOrderByViewCountDesc(pageable);
    }
}
  1. 사용자가 최근 대여한 책을 한 권 조회
  2. 그 책의 카테고리(category) 를 뽑아
  3. 같은 카테고리 내에서 조회수(viewCount) 순으로 Top N 추천

처음에 카테고리 기반 추천으로 인기 도서를 보여 줄 수 있겠다 생각했지만 카테고리가 너무 큰 그룹이지 않을까라는 생각과 조금 더 세부적으로 취향이 반영되었으면 좋겠다고 생각하며 2단계 고도화 진행!

 

2단계  콘텐츠 기반 추천

@Service
@RequiredArgsConstructor
public class BookService {
    public Page<Book> findRedisRecommendedBooks(User user, int page, int size) {
        String key = RECOMMEND_PREFIX + user.getId();
        Pageable pg = PageRequest.of(page, size);
        Page<Book> cache = (Page<Book>) redisTemplate.opsForValue().get(key);
        if (cache != null) return cache;

        Optional<Book> rb = rentRepo.findRecentRentedBookByUserId(user.getId());
        Page<Book> rec;
        if (rb.isPresent()) {
            Book b = rb.get();
            rec = bookRepo.findByWriterOrKeywordWeighted(
                b.getWriter(), b.getKeyword(), b.getId(), pg);
        } else {
            rec = bookRepo.findAllByViewCountDesc(pg);
        }
        redisTemplate.opsForValue().set(key, rec, Duration.ofHours(3));
        return rec;
    }
}

 

@Query("SELECT b FROM Book b " +
            "WHERE b.id <> :excludeId AND " +
            "(LOWER(b.writer) = LOWER(:writer) OR LOWER(b.keyword) LIKE LOWER(CONCAT('%', :keyword, '%'))) " +
            "ORDER BY " +
            "CASE " +
            "WHEN LOWER(b.writer) = LOWER(:writer) AND LOWER(b.keyword) LIKE LOWER(CONCAT('%', :keyword, '%')) THEN 1 " +
            "WHEN LOWER(b.writer) = LOWER(:writer) THEN 2 " +
            "WHEN LOWER(b.keyword) LIKE LOWER(CONCAT('%', :keyword, '%')) THEN 3 " +
            "ELSE 4 END, " +
            "b.viewCount DESC")
    Page<Book> findByWriterOrKeywordWeighted(@Param("writer") String writer,
                                             @Param("keyword") String keyword,
                                             @Param("excludeId") Long excludeId,
                                             Pageable pageable);

- 캐시 키: recommend::user::{userId} / TTL: 3시간 fallback: 최근 대여 이력이 없으면 findAllByViewCountDesc 구현
- Redis에 recommend::user::{userId} 키로 저장된 추천 결과가 있는지 먼저 확인 후 존재하지 않으면, RentRepository를 통해 사용자의 최신 대여 도서를 DB에서 조회한 후 JPQL weighted  쿼리를 실행해 추천 리스트를 다시 계산한 결과를 Redis에 저장하여, 이후 같은 요청 시에 빠르게 온다.
- 캐시 값이 저장되어 있으면 DB를 전혀 조회하지 않고 Redis 데이터반환
 


2단계에서는 “같은 작가(writer)”, “유사 키워드(keyword)” 까지 고려해 추천 정확도를 높이고 더 좁고 정확하게 필터링될 수 있도록 구현하였다. 그리고 관련도 가중치를 부여한 쿼리로 같은 작가 + 키워드 일치 → 같은 작가만 → 키워드만 순으로 점수를 매겨 순서를 매겨서 기존 1단계 보다 높은 관련도를 제공하게 구현했다! 
그리고 콘텐츠 기반 추천은 쿼리가 무거워지는 만큼, Redis 캐시로 3시간 단위 캐싱을 도입해 성능을 보완하고 캐시를 통해 반복 호출 시 DB 부담을 줄이고, 응답 속도를 안정화시켰다.



마지막으로

개인별 맞춤 추천을 구현하면서 어려운 점이 많았다.

첫 번째로, 가중치부여를 위해 쿼리를 적용할 때 CASE 절을 사용하자 쿼리가 엄청 길어지고 가독성이 떨어졌다. 이를 해결하기 위해 라인 단위로 줄 바꿈을 적용했지만 조금 더 가독성 있는 코드를 구현하는 방법이 있는지 더 찾아보고 싶다!
두 번째로,
Redis 캐시를 적용하는 단계에서는 기본적으로 생성된 RedisTemplate <byte [], byte []="">는 Page 같은 복합 객체를 직렬화·역직렬화되지 않았고 이를 해결하기 위해 RedisTemplate <string, object="">를 새로 정의하고, 키에는 StringRedisSerializer, 값에는 GenericJackson2 JsonRedisSerializer를 설정해 JSON 형식으로 안전하게 캐싱하도록 변경 </string,></byte [],>하도록 변경했다!

 @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
     RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());

        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());

        return template;
}


세 번째로, 캐시 무효화 전략에 대한 고민이었다. 추천 로직이 바뀔 때마다 기존에 저장된 캐시가 여전히 남아 잘못된 결과를 반환할 수 있기 때문에, TTL(Time To Live)을 3시간으로 설정해 자동 만료가 이루어지도록 했습니다. 필요할 경우 수동으로 redisTemplate.delete(key)를 호출해 즉시 캐시를 비울 수 있게 구현했다.
이렇게 코드 가독성과 유지보수성에 대한 고민과 사용자 경험 개선에 대한 고민으로 하나씩 해결 가며 개선해 나가는 것은 중요한 과정이며
앞으로 끊임없이 고민하며 개선해 나가야겠다고 생각했고 가장 중요한 부분이 아닐까라는 생각을 하게 되었다.
이처럼 코드 품질과 사용자 경험 모두를 놓치지 않고 하나씩 해결해 나가는 과정이야말로, 앞으로도 어떤 기능을 개발하든 가장 중요한 기본 원칙이라는 것을 깊이 깨달았다.
여기서 더 개선하여  QueryDSL이나 Custom Repository로 변경하는 것을 고려해 보고 Elasticsearch 연동을 통한 자연어 검색기능도 도전해보고 싶다! 지속적으로 공부하고 개선해 봐야겠다!☺️