[Spring Boot] 인기 공연 순위 알고리즘 구현

2024. 10. 19. 20:55·Back-End/Spring Boot
728x90

 

들어가며

 

2024년 7월 말부터 10월 초까지 약 3개월 간 진행했던 프로젝트에 대해 정리할 만한 내용을 블로그에 기재하기로 했다. 프로젝트명은 InConcert로, 공연 소식을 확인하고 동행을 구하는 서비스이다. 크게 정리할 내용은 다음과 같다.

  • 쿼리 최적화
  • 공연 정보 스크래핑 (비동기 처리)
  • 인기 공연 순위 알고리즘 ➡️ 이번 게시글 주제!

이 글에서는 인기 공연 순위 알고리즘에 대해 설명하려고 한다.

 

 

배경

이전 글인 스크래핑 처리 과정을 보면 메인 페이지의 인기 공연을 Play DB에서 얻어온다는 것을 확인할 수 있다. 프로젝트를 진행하면서 공연의 순위를 판단하는 것이 Play DB에 100% 의존하고 있다고 생각했고, 우리 애플리케이션인 InConert만의 순위를 매겨보자고 생각하게 됐다.

 

 

순위 알고리즘 도입

InConcert 서비스에서 인기 순위를 매길 때, 사이트를 이용하는 사용자의 선호도를 취합해서 순위를 갱신하기로 했다. 조회수, 댓글 수, 좋아요 수를 통해 인기도를 측정하여 @Schedule 어노테이션을 통해 스크래핑 후에 순위를 계산하게 된다. 여기서 Play DB에서 제공하는 인기 순위를 포함하여 인기도를 계산한다.

인기도 계산
인기도 = (조회수 * 0.01) + (댓글 수 * 0.25) + (좋아요 수 * 0.25) - PlayDB 순위

 

Post Entity 수정
@Entity
@Table(name = "posts")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Post extends BaseEntity {
    // 이전 생략

    private double popularity;	// 인기도 필드 추가

    // 중간 생략

    // 인기도 업데이트 메소드
    public void updatePopularity(Double popularity) {
        this.popularity = popularity;
    }
}

 

Post 엔티티에 인기도 필드를 추가하고, Setter를 사용해서 Update 하는 것을 지양하기로 하여 메소드를 만들어주었다.

 

PerformanceService
@Service
@RequiredArgsConstructor
@Slf4j
public class PerformanceService {
    // 이전 생략

    // 스크래핑 후 Post로 저장
    @Transactional
    public void crawlPerformances(String type) {
        // 중간 생략
        
        // Post의 카테고리를 나눠 저장
        Long type1 = 0L, type2 = 0L, type3 = 0L, type4 = 0L;
        for (WebElement element : elements) {
            String poster = element.findElement(By.cssSelector("a span:nth-child(1) img")).getAttribute("src");
            String title = element.findElement(By.cssSelector("a span:nth-child(2)")).getText();
            String date = element.findElement(By.cssSelector("a span:nth-child(3)")).getText();
            String place = element.findElement(By.cssSelector("a span:nth-child(4)")).getText();

            if (!title.isEmpty() && !date.isEmpty() && !place.isEmpty()) {
                try {
                    Performance performance = Performance.builder()
                            .title(title)
                            .imageUrl(poster)
                            .date(date)
                            .place(place)
                            .type(type)
                            .build();

                    // performance 저장
                    performanceRepository.save(performance);
                    log.info(performance.getTitle() + " Performance 저장");

                    // 중복되지 않는 게시물만 저장
                    if(!infoRepository.existsByTitle(title)) {
                        ScrapedPostDTO scrapedPostDTO = convertToCrawledDTO(performance, Long.parseLong(type));

                        // PlayDB에서 순위 순으로 스크래핑 하기 때문에 한 개 공연을 얻은 후 순위를 증가함
                        Long typeByScore;
                        if(type.equals("1"))        typeByScore = type1++;
                        else if (type.equals("2"))  typeByScore = type3++;
                        else if (type.equals("3"))  typeByScore = type2++;
                        else                        typeByScore = type4++;
                        Post post = createPostFromCrawledDTO(scrapedPostDTO, adminUser, typeByScore);

                        // post 저장
                        infoRepository.save(post);
                        log.info("[" + post.getId() + "] " + post.getTitle() + " 게시글 저장");
                    }else{
                        log.error("[" + title + "] 중복된 게시글(제목)입니다");
                    }
                } catch (Exception e) {
                    log.error("Error saving performance or post: {}", e.getMessage());
                }
            } else {
                log.warn("Skipping element due to missing required information");
            }
        }
        driver.quit();
    }

    // 4시에 한번씩 새로 스크래핑
    @Scheduled(cron = "0 0 4 * * ?")
    @Transactional
    public void scheduleCrawling() {
        performanceRepository.deleteAll();
        startCrawlingAsync();
    }

    // 스크래핑이 끝난 5분 뒤 인기 순위 갱신
    @Scheduled(cron = "0 5 4 * * ?")
    @Transactional
    public void scheduleRanking() {
        updateRank();
    }

    // 매일 자정 공연 종료 판단 후 게시글 삭제
    @Scheduled(cron = "0 0 0 * * ?")
    @Transactional
    public void scheduleDeleteEndPost() {
        List<Post> posts = infoRepository.findAll();

        for (Post post : posts) {
            if(post.getEndDate().isBefore(LocalDate.now()))     infoRepository.delete(post);
        }
    }

    // 중간 생략

    // 스크래핑이 게시물을 Post로 변환
    private Post createPostFromCrawledDTO(ScrapedPostDTO scrapedPostDTO, User adminUser, Long typeByScore) {
        PostCategory postCategory = postCategoryRepository.findByTitleAndCategoryTitleWithCategory(
                scrapedPostDTO.getPostCategoryTitle(),
                scrapedPostDTO.getCategoryTitle()
        ).orElseThrow(() -> new PostCategoryNotFoundException(ExceptionMessage.POST_CATEGORY_NOT_FOUND.getMessage()));

        return Post.builder()
                .title(scrapedPostDTO.getTitle())
                .content(scrapedPostDTO.getContent())
                .endDate(scrapedPostDTO.getEndDate())
                .matchCount(scrapedPostDTO.getMatchCount())
                .thumbnailUrl(scrapedPostDTO.getThumbnailUrl())
                .postCategory(postCategory)
                .popularity(popularity(0, 0, 0, typeByScore))	// 초기 조회수, 댓글 수, 좋아요 수, Play DB 순위
                .user(adminUser)
                .build();
    }

    // 중간 생략

    // 인기 순위 갱신
    private void updateRank(){
        Long type1 = 0L, type2 = 0L, type3 = 0L, type4 = 0L;
        
        // 스크래핑 한 정보 불러오기
        List<Performance> performances = performanceRepository.findAll();

        for (Performance performance : performances) {
            // Post 저장 시 공연 이름이 게시글 제목으로 저장되기 때문에 findByTitle 이용
            Optional<Post> optionalPost = infoRepository.findByTitle(performance.getTitle());

            if(optionalPost.isPresent()){
                Post post = optionalPost.get();

                // Post의 카테고리마다 인기 순위 갱신
                switch (Integer.parseInt(performance.getType())){
                    case 1 -> post.updatePopularity(popularity(post.getViewCount(), post.getComments().size(), post.getLikes().size(), type1++));
                    case 2 -> post.updatePopularity(popularity(post.getViewCount(), post.getComments().size(), post.getLikes().size(), type2++));
                    case 3 -> post.updatePopularity(popularity(post.getViewCount(), post.getComments().size(), post.getLikes().size(), type3++));
                    case 4 -> post.updatePopularity(popularity(post.getViewCount(), post.getComments().size(), post.getLikes().size(), type4++));
                }
                infoRepository.save(post);
            }
        }
    }

    // 인기도 계산
    private double popularity(int viewCount, int commentCount, int likeCount, Long performanceId) {
        return (viewCount*0.01) + (commentCount*0.25) + (likeCount*0.25) - performanceId;
    }
}

 

스크래핑한 정보를 Performances 테이블에 저장 후, 이를 Post로 저장할 때 인기도를 반영하여 저장한다. Play DB에서의 인기순으로 진행되기 때문에 초기 순위는 Play DB와 동일하지만, 전체 게시글의 반응에 따라 다음 날 순위가 바뀌게 된다. Play DB의 순위를 제외하지 않은 이유는 보편적인 인기 순위도 필요하다고 생각했기 때문이다. 인기도가 같은 경우를 고려하여 게시글 생성 일시가 더 빠른 것을 불러오도록 Repository도 수정하였다.

 

InfoRepository 수정
@Repository
public interface InfoRepository extends JpaRepository<Post, Long> {
    // 다른 메소드 생략
    // 인기 공연 게시글 불러오기
    @Query("SELECT new com.inconcert.domain.post.dto.PostDTO(p.id, p.title, c.title, pc.title, p.thumbnailUrl, u.nickname, " +
            "p.viewCount, SIZE(p.likes), SIZE(p.comments), " +
            "CASE WHEN TIMESTAMPDIFF(HOUR, p.createdAt, CURRENT_TIMESTAMP) < 24 THEN true ELSE false END, p.createdAt) " +
            "FROM Post p " +
            "JOIN p.postCategory pc " +
            "JOIN pc.category c " +
            "JOIN p.user u " +
            "WHERE c.title = 'info' " +
            "AND p.createdAt = (SELECT p2.createdAt " +
                                "FROM Post p2 " +
                                "WHERE p2.postCategory = p.postCategory " +
                                "AND p2.postCategory = pc " +
                                "AND p2.postCategory.category = c " +
                                "ORDER BY p2.popularity DESC, p2.createdAt ASC " +	// 인기도가 같은 경우 게시글 생성 일시로 판단
                                "LIMIT 1) " +
            "AND pc.title = :postCategoryTitle")
    Optional<PostDTO> findPopularPostByPostCategoryTitle(@Param("postCategoryTitle") String postCategoryTitle);
}

 

외부 순위를 100%를 의존했던 기존과 달리 애플리케이션의 인기도를 생성하여 차별화를 두고자 하였다.

 

 

 

728x90
저작자표시 비영리 변경금지 (새창열림)

'Back-End > Spring Boot' 카테고리의 다른 글

[Spring Boot] Spring AOP  (0) 2024.10.10
[Spring Boot] 스크래핑 비동기 처리  (6) 2024.10.09
[Spring Boot] N+1 문제  (1) 2024.10.05
[Spring Boot] JPA 쿼리 최적화  (1) 2024.10.04
[Spring Boot] Setter vs Builder  (0) 2024.03.28
'Back-End/Spring Boot' 카테고리의 다른 글
  • [Spring Boot] Spring AOP
  • [Spring Boot] 스크래핑 비동기 처리
  • [Spring Boot] N+1 문제
  • [Spring Boot] JPA 쿼리 최적화
hxxzz
hxxzz
개발새발 안 되게 개발 노력 중
  • hxxzz
    개발새발
    hxxzz
  • 전체
    오늘
    어제
    • 분류 전체보기 (104)
      • Java (3)
      • Back-End (9)
        • Spring Boot (7)
        • DevOps (1)
        • Redis (1)
      • Computer Scrience (4)
        • Data Structrue (4)
        • Algorithm (0)
      • SQLD (3)
      • 코딩테스트 연습 (85)
        • Programmers (30)
        • 백준 (15)
        • etc. (0)
        • 99클럽 (40)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    자료구조
    프로그래머스
    SpringBoot
    java
    SQL
    BFS
    redission
    스택
    LeetCode
    Stack
    dfs
    개발자 취업
    SQLD
    99클럽
    Spring Boot
    til
    코딩테스트 준비
    jpa
    N+1 문제
    백준
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.1
hxxzz
[Spring Boot] 인기 공연 순위 알고리즘 구현
상단으로

티스토리툴바