-
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에 존재하는 행 개수를 곱한 만큼의 결괏값이 반환되는 카테시안 곱 현상 임을 알게 되었습니다.
카테시안 곱으로 인해 발생했던 문제
카테시안 곱 문제 해결
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을 사용함으로써 해결할 수 있었습니다.
'ShMarket' 카테고리의 다른 글
ShMarket - Redis 캐싱 전략과 장애 대비 (0) 2021.07.31 ShMarket - 푸시 알림과 성능 개선 (1) 2021.07.05 ShMarket - 조회 성능 최적화 기록기 (0) 2021.06.21 ShMarket - 프로젝트 소개 (0) 2021.06.19