본문 바로가기

Dot Programming/JPA

[JPA] 관심 카테고리 게시글 + 좋아요 Querydsl로 한방 쿼리 만들기 (N+1 문제 해결)

    멤버 관심 카테고리 게시글의 좋아요 여부 조회하기 - N+1 쿼리 발생

    카테고리별 좋아요 누른 게시글 조회 (N+1 문제)


    개인프로젝트(techdot)을 진행하면서 유저가 지정해놓은 관심 카테고리 목록에서 좋아요한 게시글 목록을 조회하는 쿼리를 작성하던 도중 서비스 로직에서 반복문으로 인해 N+1문제가 발생하였다. 잘못된 설계로 쿼리가 아닌 비즈니스 로직에서 발생한 당황스러운 N+1 문제였다.

    1. 관심 카테고리에 속하는 게시글 조회 (select postDto ... where interest_member_id = member_id)
    2. 멤버의 관심 카테고리 조회 (select category_name ... where interest_member_id = member_id)
    3. [N+1 문제 발생] 카테고리 별로 멤버가 좋아요한 게시글 조회 (select post_id ... where like_member_id = member id and category_name = this.category_name)
    // 멤버의 관심 카테고리에 속한 게시글 가져오기
    public List<PostQueryDto> getPostsByMemberInterests(Long memberId, Pageable pageable) {
    	// 첫 번째 쿼리 : 관심 카테고리 조회
    	List<PostQueryDto> allInterestPosts = postRepositoryQuery.findQueryDtoByInterestsMemberId(memberId,
    		pageable);
    
    	// 두 번째 쿼리 : member 관심 카테고리 조회
    	List<InterestCategoryResponseDto> interestCategoriesByMember = interestService.getInterestCategoriesByMember(memberId);
    
    	// 세 번째 쿼리 보내는 로직 (N+1)
    	for (InterestCategoryResponseDto categoryName : interestCategoriesByMember) {
    		checkMemberLikePosts(allInterestPosts, memberId, categoryName.getCategoryName().toString());
    	}
    
    	return allInterestPosts;
    }
    
    // 멤버가 좋아하는 게시글인지 체크하기 (N+1)
    private void checkMemberLikePosts(List<PostQueryDto> allPosts, Long memberId, String categoryName) {
    	// N번 쿼리 발생 : member가 좋아요 누른 게시글 Id 조회
    	List<Long> likePosts = postRepositoryQuery.findIdByLikesMemberId(memberId, categoryName);
    
    	// 	좋아요 누른 게시글 정보 업데이트
    	allPosts.stream().filter(post -> likePosts.contains(post.getPostId()))
    		.forEach(post -> post.setIsMemberLike(true));
    }

     

    첫 번째 쿼리 - 관심 카테고리에 속하는 게시글 기존 PostQueryDto에 담아서 조회

    • interest_id = member_id (멤버가 등록한 관심 카테고리 게시글만)
    select
            post0_.post_id as col_0_0_,
            post0_.title as col_1_0_,
            post0_.content as col_2_0_,
            post0_.link as col_3_0_,
            post0_.writer as col_4_0_,
            post0_.type as col_5_0_,
            post0_.thumbnail_image as col_6_0_,
            category1_.name as col_7_0_ 
        from
            post post0_ 
        inner join
            category category1_ 
                on post0_.category_id=category1_.id 
        inner join
            interests interests2_ 
                on category1_.id=interests2_.category_id 
        where
            interests2_.member_id=? limit ?

     

    두 번째 쿼리 - 멤버 관심 카테고리 목록 조회

    • interest_member_id = member_id
    select
            category1_.name as col_0_0_ 
        from
            interests interest0_ 
        inner join
            category category1_ 
                on interest0_.category_id=category1_.id 
        where
            interest0_.member_id=?

     

    N번 쿼리 - 카테고리 별로 멤버가 좋아요한 게시글 조회

    • category_name = 2번째퀴리에서 얻은 categoryName
    • like_member_id = member_id
    • 멤버가 관심있는 카테고리 갯수만큼 쿼리 발생 (N+1)
    select
            post0_.post_id as col_0_0_ 
        from
            post post0_ 
        inner join
            category category1_ 
                on post0_.category_id=category1_.id 
        inner join
            likes likes2_ 
                on post0_.post_id=likes2_.post_id 
        where
            likes2_.member_id=? 
            and category1_.name=?

     

    QueryDsl 한방 쿼리 만들기 - N+1 문제 해결

    이는 기존에 관심 카테고리의 게시글만 조회(1번 쿼리)하는 기능에서 좋아요 기능을 확장(2,3번 쿼리)하다가 카테고리의 수에 따라 좋아요 누른 게시글을 조회하다가 N+1 문제가 발생하게 되었다. 조회하는 모든 값이 Post와 연관되어 있는 값들인기 때문에 게시글 불러올 때 한 번에 불러올 수 있지 않을까? 라는 생각이 들어서 이를 한방 쿼리로 리팩토링을 시도해보았다.

     

    한방 쿼리 만드는 과정

    1. 먼저 2번 쿼리에서 관심 카테고리 조회는 1번에 있는 로직과 중복이 되기 때문에 1,2번 쿼리를 합친 후에 그 내부에 3번 쿼리를 넣어주었다.
    2. 1번 쿼리 내부에 쿼리를 발생시켜 like_member_id와 현재 member_id와 일치하면서 조회하고 있는 like_post_id와 현재 조회된 post_id가 일치하는 값이 있으면 true를 반환하도록 하였다.


    그 결과 깔끔하게 N+1가 발생하던 쿼리를 1개의 쿼리로 축소시켜줄 수 있었다. 서비스 계층의 비즈니스 로직도 리팩토링으로 인해 자연스레 여러 개의 메서드가 하나의 메서드로 정리가 되었고 반복문도 제거할 수 있었다.

    // Service 코드 - 멤버의 관심 카테고리 게시글 가져오기
    public List<PostQueryResponseDto> getPostsByInterestsMemberId(Long memberId, Pageable pageable) {
        List<PostQueryResponseDto> allInterestPosts = postRepository.findAllDtoByInterestsMemberId(memberId, pageable);
        return allInterestPosts;
    }
    
    // Repository 코드 - QueryDSL 쿼리 
    @Override
    public List<PostQueryResponseDto> findAllDtoByInterestsMemberId(Long memberId, Pageable pageable){
        JPQLQuery<PostQueryResponseDto> query = from(post)
            .select(Projections.constructor(PostQueryResponseDto.class,
                post.id, post.title, post.content, post.link, post.writer, post.type,
                post.thumbnailImage, post.uploadDateTime, category.name, getBooleanExpressionIsMemberLike(memberId)))
            .join(post.category, category)
            .join(category.interests, interest)
            .where(interest.member.id.eq(memberId));
        addSorting(pageable.getSort(), query);
        return getPagingResults(pageable, query);
    }


    한방 쿼리 발생 select 문은 다음과 같다. 

    select
            post0_.post_id as col_0_0_,
            post0_.title as col_1_0_,
            post0_.content as col_2_0_,
            post0_.link as col_3_0_,
            post0_.writer as col_4_0_,
            post0_.type as col_5_0_,
            post0_.thumbnail_image as col_6_0_,
            category1_.name as col_7_0_,
            (select
                count(like3_.id) 
            from
                likes like3_ 
            where
                like3_.member_id=? 
                and like3_.post_id=post0_.post_id)>0 as col_8_0_ 
        from
            post post0_ 
        inner join
            category category1_ 
                on post0_.category_id=category1_.id 
        inner join
            interests interests2_ 
                on category1_.id=interests2_.category_id 
        where
            interests2_.member_id=? limit ?

     

    Jmeter 성능 비교

    Jmeter로 쓰레드 50개를 생성하여 루프 3번을 돌려 두 개의 방식을 비교해보았다. 

    • [query optimization] 한 방 쿼리 Throughput 28.7/s
    • [query] N+1 쿼리 Throughput 26.7/s
    • 성능: opt > 기존

     

    카테고리의 수가 최대 10개 이하로 많은 컬렉션이 생기지 않기 때문에 한방 쿼리가 조금 더 빠르게 동작했다.

    쓰레드 50개, 2번 루프

     

    쓰레드 개수 50개로 요청이 1,000개가 넘어가면 처리량은 거의 같은 값으로 수렴했다. 그래도 최적화된 한 방 쿼리가 Sample time(load, response, elapsed)에서는 계속해서 약간 더 앞서는 모습을 보여줬다. 

    • [query optimization] 한 방 쿼리 Sample 평균 753
    • [query] N+1 쿼리 Sample 평균 775
    • 성능: opt > 기존

    쓰레드 50개, 20번 루프

     

    정리

    기존에 Post와 Category의 필드를 DTO로 직접 조회하는 쿼리에 좋아요 기능을 추가하면서 비즈니스 로직에 새롭게 발생한 N+1 문제였다. 게다가 초기 설계한 쿼리는 멤버의 관심 카테고리를 2번 중복으로 조회를 하는 문제를 안고 있었다.

     

    게시글, 카테고리, 좋아요 테이블은 모두 연관관계를 갖고 있기 때문에 기존 게시글을 조회하는 쿼리 안에 좋아여 여부를 조회하는 내부 쿼리를 넣어줘서 한 방 쿼리를 만들 수 있었다. 결과적으로 성능을 유지 혹은 조금 개선한 상태로 코드를 단순화하고 중복 처리를 제거함으로써 품질을 향상시킬 수 있어서 만족스러운 리팩토링 결과였다.