ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • ShMarket - 이슈 #1 N+1문제와 그로 파생되는 문제 해결
    ShMarket 2021. 6. 20. 00:09

    N+1로 인해 의도하지 않은 쿼리들이 발생하는 현상을 접함.


    Board Entity 

    public class Board {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "board_id")
        private Long id;
        private String title;
        private String content;
        private String area;
        private String category;
        private Long thumbnailId;
        private String thumbnail;
        private LocalDateTime createDate;
        private LocalDateTime updateDate;
        private int read;
    
        @OneToMany(mappedBy = "board", cascade = CascadeType.ALL)
        private List<Comment> comments = new LinkedHashSet<>();
        
        @OneToMany(mappedBy = "board", cascade = CascadeType.ALL)
        private List<BoardAlbum> boardAlbums = new HashSet<>();
        
        @ManyToOne(fetch = FetchType.LAZY)
        private Member member;

     

    동네 게시글을 작성하기 위한 Entity인 Board를 위와 같이 생성해 사용하고 있던 중,

    프로그램을 실행해 쿼리를 확인하는데 의도치 않은 쿼리가 많이 발생하는 현상을 발견했습니다.

    이는 JPA를 사용할 때 자주 접할 수 있는 N+1 문제라는 것을 알게 되었습니다.


    N+1문제란

     

    조회된 부모의 수만큼 자식 테이블의 쿼리가 추가 발생해

    쿼리 1번으로 N건을 가져왔는데, 관련 컬럼을 얻기 위해 쿼리를 N번 추가 수행하는 현상입니다.

     

    즉, 하위 (자식)엔티티들을 첫 쿼리 실행 시 한 번에 가져오지 않고 Lazy Loading으로 필요한 곳에서 사용되어 쿼리가 실행될 때 발생하는 문제를 JPA의 N+1 문제라고 합니다.


    문제 상황

     

    게시글이 3개인 경우를 예로 들었을 때 위와 같은 Entity에서 발생하는 문제점은 

    전체 게시글(3개)을 조회하기 위해 모든 데이터를 조회하는 findAll을 실행하면 

    부모인 Board 1번

    자식인 BoardAlbums 3번,

    자식인 Comments 3번 

    총 7번의 쿼리가 발생합니다.

     

    쿼리 전문

    Hibernate: # 부모( Board ) 조회 1번 
        select 
            board0_.board_id as board_id1_2_,
            board0_.area as area2_2_,
            board0_.category as category3_2_,
            board0_.content as content4_2_,
            board0_.create_date as create_d5_2_,
            board0_.member_member_id as member_11_2_,
            board0_.read as read6_2_,
            board0_.thumbnail as thumbnai7_2_,
            board0_.thumbnail_id as thumbnai8_2_,
            board0_.title as title9_2_,
            board0_.update_date as update_10_2_ 
        from
            board board0_
            
    Hibernate: # 자식( BoardAlbum ) 조회 1번
        select
            boardalbum0_.board_board_id as board_bo4_3_0_,
            boardalbum0_.board_album_id as board_al1_3_0_,
            boardalbum0_.board_album_id as board_al1_3_1_,
            boardalbum0_.board_board_id as board_bo4_3_1_,
            boardalbum0_.filename as filename2_3_1_,
            boardalbum0_.url as url3_3_1_ 
        from
            board_album boardalbum0_ 
        where
            boardalbum0_.board_board_id=?
            
    Hibernate: # 자식( Comment ) 조회 1번
        select
            comments0_.board_board_id as board_bo6_6_0_,
            comments0_.comment_id as comment_1_6_0_,
            comments0_.comment_id as comment_1_6_1_,
            comments0_.board_board_id as board_bo6_6_1_,
            comments0_.content as content2_6_1_,
            comments0_.create_date as create_d3_6_1_,
            comments0_.nickname as nickname4_6_1_,
            comments0_.update_date as update_d5_6_1_ 
        from
            comment comments0_ 
        where
            comments0_.board_board_id=?
            
    Hibernate: # 자식( BoardAlbum ) 조회 2번
        select
            boardalbum0_.board_board_id as board_bo4_3_0_,
            boardalbum0_.board_album_id as board_al1_3_0_,
            boardalbum0_.board_album_id as board_al1_3_1_,
            boardalbum0_.board_board_id as board_bo4_3_1_,
            boardalbum0_.filename as filename2_3_1_,
            boardalbum0_.url as url3_3_1_ 
        from
            board_album boardalbum0_ 
        where
            boardalbum0_.board_board_id=?
            
    Hibernate: # 자식( Comment ) 조회 2번
        select
            comments0_.board_board_id as board_bo6_6_0_,
            comments0_.comment_id as comment_1_6_0_,
            comments0_.comment_id as comment_1_6_1_,
            comments0_.board_board_id as board_bo6_6_1_,
            comments0_.content as content2_6_1_,
            comments0_.create_date as create_d3_6_1_,
            comments0_.nickname as nickname4_6_1_,
            comments0_.update_date as update_d5_6_1_ 
        from
            comment comments0_ 
        where
            comments0_.board_board_id=?
            
    Hibernate: # 자식( BoardAlbum ) 조회 3번
        select
            boardalbum0_.board_board_id as board_bo4_3_0_,
            boardalbum0_.board_album_id as board_al1_3_0_,
            boardalbum0_.board_album_id as board_al1_3_1_,
            boardalbum0_.board_board_id as board_bo4_3_1_,
            boardalbum0_.filename as filename2_3_1_,
            boardalbum0_.url as url3_3_1_ 
        from
            board_album boardalbum0_ 
        where
            boardalbum0_.board_board_id=?
            
    Hibernate: # 자식( Comments ) 조회 3번
        select
            comments0_.board_board_id as board_bo6_6_0_,
            comments0_.comment_id as comment_1_6_0_,
            comments0_.comment_id as comment_1_6_1_,
            comments0_.board_board_id as board_bo6_6_1_,
            comments0_.content as content2_6_1_,
            comments0_.create_date as create_d3_6_1_,
            comments0_.nickname as nickname4_6_1_,
            comments0_.update_date as update_d5_6_1_ 
        from
            comment comments0_ 
        where
            comments0_.board_board_id=?

     

    1개를 조회했을 때 3번,

    3개를 조회했을 때 7번,

    10개를 조회했을 때 21번,

    ...

    즉 게시글 N개 * 2 +1 만큼의 쿼리가 발생함을 알 수 있었습니다.

    10000개라고 쳤을 때는 20001번의 쿼리가 발생하는 만큼 사용자가 많아져

    게시글이 많아졌을 때의 비용이 매우 비싸짐을 예상할 수 있습니다.


    N+1 문제 해결

     

    게시글뿐만 아닌 상품, 회원 등 N+1 문제가 발생하는 곳이 많이 존재하기 때문에 

    N+1 문제를 해결해 쿼리를 줄임으로 성능을 개선하고 비용을 줄이는 작업을 수행하기로 했습니다.

     

    N+1을 해결하는 방법으로는 몇 가지 방법들이 있습니다.

     

    1. Fetch Join 

    첫 번째 방법은 Fetch Join을 사용하는 방법입니다.

     

    조회할 때 같이 한 번에 가져오고 싶은 Entity 필드를 Join을 통해 가져오는 방식으로, 

    선택한 Entity 필드의 하위 Entity까지도 가져와 한 번의 쿼리로 조회할 수 있게 됩니다.

     

    BoardRepository

    public interface BoardRepository extends JpaRepository<Board, Long> {
    
        @Override
        @Query("SELECT b FROM Board b JOIN FETCH b.member JOIN FETCH b.boardAlbums JOIN FETCH b.comments")
        List<Board> findAll();
    
    }

     


    Multiple Bag Fetch Exception 문제 발생

     

    위처럼 Repository에 Fetch Join을 적용한 뒤 실행해 쿼리를 확인하려던 중 

    Multiple Bag Fetch Exception이 발생했습니다. 확인해본 결과 해당 Exception의 MultipleBag은

    여러 개의 Bag( List와 같이 중복을 허용하는 자료구조 ) 타입의 컬렉션을 페치 조인했을 때 발생하는 현상임을 알게 되었고,하이버네이트에서는 List -> Bag, Set -> Set으로 취급하기에 프로젝트에서는 OneToMany List 타입을 2개 사용했기에 발생함을 알게 되었습니다.

     

    이를 해결하기 위해 

    Bag ( List )타입인 Comments를 Set 타입으로 변경해 테스트해보았습니다.

     

    Board.java

        @OneToMany(mappedBy = "board", cascade = CascadeType.ALL)
        private Set<Comment> comments = new LinkedHashSet<>();
    
        @OneToMany(mappedBy = "board", cascade = CascadeType.ALL)
        private List<BoardAlbum> boardAlbums = new ArrayList<>();

     

    실행 쿼리를 살펴보면

    Multiple Bag Fetch Exception은 해결되었으며 

    1번의 SELECT와 3번의 JOIN을 통해 한번에 Entity들을 조회해 N+1 문제 또한 해결되었음을 확인할 수 있었습니다. 

    Hibernate: 
        select
            board0_.board_id as board_id1_2_0_,
            member1_.member_id as member_i1_9_1_,
            boardalbum2_.board_album_id as board_al1_3_2_,
            comments3_.comment_id as comment_1_6_3_,
            board0_.area as area2_2_0_,
            board0_.category as category3_2_0_,
            board0_.content as content4_2_0_,
            board0_.create_date as create_d5_2_0_,
            board0_.member_member_id as member_11_2_0_,
            board0_.nickname as nickname6_2_0_,
            board0_.thumbnail as thumbnai7_2_0_,
            board0_.thumbnail_id as thumbnai8_2_0_,
            board0_.title as title9_2_0_,
            board0_.update_date as update_10_2_0_,
            member1_.area as area2_9_1_,
            member1_.nickname as nickname3_9_1_,
            member1_.password as password4_9_1_,
            member1_.role as role5_9_1_,
            member1_.username as username6_9_1_,
            boardalbum2_.board_board_id as board_bo4_3_2_,
            boardalbum2_.filename as filename2_3_2_,
            boardalbum2_.url as url3_3_2_,
            boardalbum2_.board_board_id as board_bo4_3_0__,
            boardalbum2_.board_album_id as board_al1_3_0__,
            comments3_.board_board_id as board_bo6_6_3_,
            comments3_.content as content2_6_3_,
            comments3_.create_date as create_d3_6_3_,
            comments3_.nickname as nickname4_6_3_,
            comments3_.update_date as update_d5_6_3_,
            comments3_.board_board_id as board_bo6_6_1__,
            comments3_.comment_id as comment_1_6_1__ 
        from
            board board0_ 
        inner join
            member member1_ 
                on board0_.member_member_id=member1_.member_id 
        inner join
            board_album boardalbum2_ 
                on board0_.board_id=boardalbum2_.board_board_id 
        inner join
            comment comments3_ 
                on board0_.board_id=comments3_.board_board_id

    2. @Entity Graph

     

    두 번째 방법은 위에서 발생한 Multiple Bag Fetch Exception을 해결하기 위한 방도로 변경한 
    Board를 1번에서처럼 마찬가지로 적용한 뒤 Entity Graph 어노테이션을 사용하는 방법입니다.

     

    사실상 Entity Graph 또한 fetch join으로 
    @Entity Graph 어노테이션을 Repository의 메소드에 작성하고

    attributePaths ={} 내부에 가져올 Entity 필드를 지정하면 지정된 Entity들을 Eager 조회로 가져와 실행합니다.

    1번의 Join Fetch와 같이 Entity 필드의 하위 Entity 또한 가져올 수 있습니다.

     

    BoardRepository

    public interface BoardRepository extends JpaRepository<Board, Long> {
    
        @Override
        @EntityGraph(attributePaths = {"member", "boardAlbums", "comments"})
        List<Board> findAll();
    
    }

     

     

    실행 쿼리를 살펴보면 1번 방법인 join fetch와 유사한 결과가 나옴을 확인할 수 있습니다.

    Hibernate: 
        select
            board0_.board_id as board_id1_2_0_,
            member1_.member_id as member_i1_10_1_,
            boardalbum2_.board_album_id as board_al1_3_2_,
            comments3_.comment_id as comment_1_6_3_,
            board0_.area as area2_2_0_,
            board0_.category as category3_2_0_,
            board0_.content as content4_2_0_,
            board0_.create_date as create_d5_2_0_,
            board0_.member_member_id as member_11_2_0_,
            board0_.read as read6_2_0_,
            board0_.thumbnail as thumbnai7_2_0_,
            board0_.thumbnail_id as thumbnai8_2_0_,
            board0_.title as title9_2_0_,
            board0_.update_date as update_10_2_0_,
            member1_.area as area2_10_1_,
            member1_.nickname as nickname3_10_1_,
            member1_.password as password4_10_1_,
            member1_.role as role5_10_1_,
            member1_.username as username6_10_1_,
            boardalbum2_.board_board_id as board_bo4_3_2_,
            boardalbum2_.filename as filename2_3_2_,
            boardalbum2_.url as url3_3_2_,
            boardalbum2_.board_board_id as board_bo4_3_0__,
            boardalbum2_.board_album_id as board_al1_3_0__,
            comments3_.board_board_id as board_bo6_6_3_,
            comments3_.content as content2_6_3_,
            comments3_.create_date as create_d3_6_3_,
            comments3_.nickname as nickname4_6_3_,
            comments3_.update_date as update_d5_6_3_,
            comments3_.board_board_id as board_bo6_6_1__,
            comments3_.comment_id as comment_1_6_1__ 
        from
            board board0_ 
        left outer join
            member member1_ 
                on board0_.member_member_id=member1_.member_id 
        left outer join
            board_album boardalbum2_ 
                on board0_.board_id=boardalbum2_.board_board_id 
        left outer join
            comment comments3_ 
                on board0_.board_id=comments3_.board_board_id

     

    둘의 차이점으로는

    1번 fetch join은 Inner Join

    2번 Entity Graph는 Outer Join을 사용한다는 것 외에는 차이점이 없이 1번의 쿼리로 마무리됨을 알 수 있었습니다.


    새로운 문제 발생

     

    위에서 발생했던 MultipleBagFetchException, N+1 문제를 차례대로 해결해 모든 문제가 해결된 줄 알았으나 

    테스트를 진행하던 중 이상한 점을 발견했습니다.

     

    게시글에 존재하는 사진의 수가 점점 증가하는 현상을 발견했고, 이를 분석하던 중 

    게시글에 댓글이 늘어날수록 게시글의 사진 수가 댓글 * 사진의 수로 표시되는 것임을 알게 되었습니다.

     

    이는 검색을 통해 알아본 결과 

    1번 Fetch Join이나 2번 Entity Graph를 복수의 컬렉션에 적용하는 경우 발생하며 

    From절에 2개 이상의 Table이 있을 때 두 Table 사이에 유효 join 조건을 적지 않았을 때 해당 테이블에 대한 모든 데이터를 전부 결합하여 Table에 존재하는 행 개수를 곱한 만큼의 결괏값이 반환되는 카테시안 곱 현상 임을 알게 되었습니다. 

     

    카테시안 곱으로 인해 발생했던 문제 

    초기 데이터 앨범이 3개
    댓글 1개

     

    댓글 2개로 추가

     

    댓글 2개일 때 사진이 2배로 늘어남.

     

    댓글 3개일 때

    카테시안 곱 문제 해결

     

    Board.Java의 컬렉션을 List -> Set으로 모두 변경 

        @OneToMany(mappedBy = "board", cascade = CascadeType.ALL)
        private Set<Comment> comments = new LinkedHashSet<>();
    
        @OneToMany(mappedBy = "board", cascade = CascadeType.ALL)
        private Set<BoardAlbum> boardAlbums = new LinkedHashSet<>();

     

    변경 후 같은 조건으로 조회했을 때 

    같은 조건인 3개의 사진과, 3개의 댓글로 조회했을 때 

    데이터가 증가되지 않고 정상적으로 동작하는 모습을 확인했고, 

    카테시안 곱 현상을 중복을 허용하지 않는 자료구조인 Set을 사용함으로써 해결할 수 있었습니다.

     

     

    댓글

Designed by Tistory.