N+1 문제 정면돌파 가보자잇

Kevin·2023년 7월 15일
1

JPA(Hibernate)

목록 보기
9/9
post-thumbnail

Board와 Reply는 1:N 관계이며, 즉시 로딩으로 설정하였다.

@Entity
@Table(name = "board_tb")
public class Board {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @OneToMany(mappedBy = "board", fetch = FetchType.EAGER)
    private List<Reply> replyList = new ArrayList<>();

    @Column(nullable = false)
    private String title;

    @Lob
    @Column(nullable = false)
    private String content;
}
@Table(name="reply_tb")
public class Reply {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    @ManyToOne
    private Board board;

    @Column(nullable = false)
    private String comment;
}

em.find() 메서드로 Board를 조회하면, 즉시 로딩으로 설정한 Reply도 함께 조회된다.
em.find()

이 때의 실행된 SQL문은 아래와 같다.
Hibernate: 
    select
        board0_.id as id1_0_0_,
        board0_.content as content2_0_0_,
        board0_.title as title3_0_0_,
        replylist1_.board_id as board_id3_1_1_,
        replylist1_.id as id1_1_1_,
        replylist1_.id as id1_1_2_,
        replylist1_.board_id as board_id3_1_2_,
        replylist1_.comment as comment2_1_2_,
        replylist1_.user_id as user_id4_1_2_ 
    from
        board_tb board0_ 
    left outer join
        reply_tb replylist1_ 
            on board0_.id=replylist1_.board_id 
    where
        board0_.id=?
  • 위 코드를 해석을 해보자면, 주어진 board_id에 해당하는 게시물(board_tb)과 이에 연결된 댓글(reply_tb)의 정보를 검색하여 반환한다는 뜻이며, 자세한 설명은 아래 적어두었다.

  • 외부 조인 → 조인문의 왼쪽에 있는 테이블의 모든 결과를 가져오고, 오른쪽 테이블은 조건에 맞는 결과만 가져온다.

  • 쿼리에 대한 자세한 설명

    1. select: 쿼리 결과로 반환될 열(칼럼)들을 선택합니다.

      • board0_.id as id1_0_0_: 게시물의 ID (board_tb의 id 열)
      • board0_.content as content2_0_0_: 게시물의 내용 (board_tb의 content 열)
      • board0_.title as title3_0_0_: 게시물의 제목 (board_tb의 title 열)
      • replylist1_.board_id as board_id3_1_1_: 댓글의 게시물 ID (reply_tb의 board_id 열)
      • replylist1_.id as id1_1_1_: 댓글의 ID (reply_tb의 id 열)
      • replylist1_.id as id1_1_2_: 댓글의 ID (reply_tb의 id 열)
      • replylist1_.board_id as board_id3_1_2_: 댓글의 게시물 ID (reply_tb의 board_id 열)
      • replylist1_.comment as comment2_1_2_: 댓글의 내용 (reply_tb의 comment 열)
      • replylist1_.user_id as user_id4_1_2_: 댓글의 사용자 ID (reply_tb의 user_id 열)
    2. from: 게시물 테이블(board_tb)을 선택합니다.

      • board0_는 게시물 테이블(board_tb)에 대한 별칭(alias)입니다.
    3. left outer join: 왼쪽 외부 조인을 수행합니다. 게시물 테이블(board_tb)과 댓글 테이블(reply_tb)을 board_tb의 id와 reply_tb의 board_id 열을 기준으로 조인합니다.

      • on board0_.id=replylist1_.board_id: board_tb의 id 열과 reply_tb의 board_id 열을 사용하여 조인합니다.
    4. where: 조건을 설정합니다.
      - board0_.id=?: board_tb의 id 값이 주어진 값과 일치하는 게시물을 선택합니다.


  • 위의 방식에서는 SQL을 두 번 실행, 즉 두번의 Select를 통해서 Board와 Reply를 가져오는 것이 아니라 조인을 사용해서 한 번의 SQL로 Board와 Reply를 함께 조회한다.


단 JPQL을 사용하면, 문제가 발생한다.
// when
em.createQuery("select b from Board b", Board.class)
	.getResultList();

이 때 실행된 SQL문들은 아래와 같다.
Hibernate: 
    select
        board0_.id as id1_0_,
        board0_.content as content2_0_,
        board0_.title as title3_0_ 
    from
        board_tb board0_
  • JPQL는 즉시 로딩과 지연 로딩에 대해서 전혀 신경쓰지 않고, JPQL만 사용해서 위와 같이 SQL문을 생성한다.
  • 위의 SQL문 생성 후 reply가 board에 즉시 로딩으로 설정되어있으므로, JPA는 다음 SQL문을 추가로 실행한다.
Hibernate: 
    select
        replylist0_.board_id as board_id3_1_1_,
        replylist0_.id as id1_1_1_,
        replylist0_.id as id1_1_0_,
        replylist0_.board_id as board_id3_1_0_,
        replylist0_.comment as comment2_1_0_,
        replylist0_.user_id as user_id4_1_0_ 
    from
        reply_tb replylist0_ 
    where
        replylist0_.board_id=?

  • 조회된 댓글이 하나이기에, 총 2번의 SQL문이 실행되지만, 만약 조회된 댓글이 N개이면 어떻게 될까?
		@BeforeEach
    public void set_up() {

        User user = userRepository.save(newUser("kevin"));

        Board board = boardRepository.save(newBoard("반갑꼬리!~", user));

        replyRepository.save(newReply("나두 반갑꼬리~!1", user, board));
        replyRepository.save(newReply("나두 반갑꼬리~!2", user, board));
        replyRepository.save(newReply("나두 반갑꼬리~!3", user, board));
        replyRepository.save(newReply("나두 반갑꼬리~!4", user, board));
        replyRepository.save(newReply("나두 반갑꼬리~!5", user, board));

        em.flush();
        em.clear();
    }

  • 위와 같이 @BeforeEach로 여러개의 댓글들을 미리 저장해두자.
Hibernate: 
    select
        replylist0_.board_id as board_id3_1_1_,
        replylist0_.id as id1_1_1_,
        replylist0_.id as id1_1_0_,
        replylist0_.board_id as board_id3_1_0_,
        replylist0_.comment as comment2_1_0_,
        replylist0_.user_id as user_id4_1_0_ 
    from
        reply_tb replylist0_ 
    where
        replylist0_.board_id=?

Hibernate: 
    select
        replylist1_.board_id as board_id3_1_1_,
        replylist1_.id as id1_1_1_,
        replylist1_.id as id1_1_0_,
        replylist1_.board_id as board_id3_1_0_,
        replylist1_.comment as comment2_1_0_,
        replylist1_.user_id as user_id4_1_0_ 
    from
        reply_tb replylist1_ 
    where
        replylist1_.board_id=?

Hibernate: 
    select
        replylist2_.board_id as board_id3_1_1_,
        replylist2_.id as id1_1_1_,
        replylist2_.id as id1_1_0_,
        replylist2_.board_id as board_id3_1_0_,
        replylist2_.comment as comment2_1_0_,
        replylist2_.user_id as user_id4_1_0_ 
    from
        reply_tb replylist2_ 
    where
        replylist2_.board_id=?

...
  • 위와 같이 처음 실행한 SQL의 결과 수만큼 추가로 SQL을 실행하게 된다.
  • 쉽게 설명하면, 1개의 게시글을 조회하면 2개의 쿼리, 5개의 게시글을 조회하면 6개의 쿼리가 날라가는 N+1 문제가 발생한다.
  • 엥? 근데 나는 왜 아래와 같이 쿼리가 작성되지?? → 바보야 글이 여러개인 상황으로 해야지;; 답은 default_batch_fetch_size: 100 가 설정되어있었기에 강제로 sql의 in절을 사용하게 된 것이다. ㅋㅋㅋㅋㅋㅋㅋㅋ
    Hibernate: 
        select
            replylist0_.board_id as board_id1_1_1_,
            replylist0_.reply_list_id as reply_li2_1_1_,
            reply1_.id as id1_2_0_,
            reply1_.board_id as board_id3_2_0_,
            reply1_.comment as comment2_2_0_ 
        from
            board_tb_reply_list replylist0_ 
        inner join
            reply_tb reply1_ 
                on replylist0_.reply_list_id=reply1_.id 
        where
            replylist0_.board_id in (
                ?, ?
            )

@Lob은 일반적인 데이터베이스에서 저장하는 길이인 255개 이상의 문자를 저장하고 싶을 때 지정한다.


그러면 지연 로딩을 설정하면 어떻게 될까?

Hibernate: 
    select
        board0_.id as id1_0_,
        board0_.content as content2_0_,
        board0_.title as title3_0_ 
    from
        board_tb board0_
  • 위 SQL 하나만 실행되게 된다. 그러나 지연 로딩은 이후 비즈니스 로직에서 주문 컬렉션을 실제 사용할 때 지연 로딩이 발생하며, 추가 쿼리가 발생한다.
    Hibernate: 
        select
            replylist0_.board_id as board_id1_1_0_,
            replylist0_.reply_list_id as reply_li2_1_0_,
            reply1_.id as id1_2_1_,
            reply1_.board_id as board_id3_2_1_,
            reply1_.comment as comment2_2_1_ 
        from
            board_tb_reply_list replylist0_ 
        inner join
            reply_tb reply1_ 
                on replylist0_.reply_list_id=reply1_.id 
        where
            replylist0_.board_id=?
  • 만약 모든 게시물들에 대해 연관된 댓글 컬렉션을 사용하여서, 지연 로딩을 터트리면 문제가 발생한다.
            List<Board> boards = em.createQuery("select b from Board b", Board.class)
                    .getResultList();
    
            System.out.println("============== N+1 시점 확인용 ===================");
    
            for (Board board : boards) {
                System.out.println("board = " + board.getReplyList().size());
    
    Hibernate: 
        select
            replylist0_.board_id as board_id1_1_0_,
            replylist0_.reply_list_id as reply_li2_1_0_,
            reply1_.id as id1_2_1_,
            reply1_.board_id as board_id3_2_1_,
            reply1_.comment as comment2_2_1_ 
        from
            board_tb_reply_list replylist0_ 
        inner join
            reply_tb reply1_ 
                on replylist0_.reply_list_id=reply1_.id 
        where
            replylist0_.board_id=?
    
    Hibernate: 
        select
            replylist0_.board_id as board_id1_1_0_,
            replylist0_.reply_list_id as reply_li2_1_0_,
            reply1_.id as id1_2_1_,
            reply1_.board_id as board_id3_2_1_,
            reply1_.comment as comment2_2_1_ 
        from
            board_tb_reply_list replylist0_ 
        inner join
            reply_tb reply1_ 
                on replylist0_.reply_list_id=reply1_.id 
        where
            replylist0_.board_id=?
    • 위 SQL문 처럼 게시글이 N개이면, 게시글에 따른 댓글도 N번 조회되며, 똑같은 N+1 문제를 낳게된다.

그렇다면 해결 방법은 뭐가 있을까?
  • fetch join을 사용하는게 제일 일반적인 방법이다.
    • fetch join은 SQL 조인을 사용해서 연관된 엔티티를 함께 조회하므로, N+1 문제가 발생하지 않는다.

      List<Board> boards = em.createQuery("select b from Board b join fetch b.replyList", Board.class)
                      .getResultList();
      
              System.out.println("============== N+1 시점 확인용 ===================");
      
              // then
              for (Board board : boards) {
                  System.out.println("board = " + board.getReplyList().size());
      Hibernate: 
          select
              board0_.id as id1_0_0_,
              reply2_.id as id1_2_1_,
              board0_.content as content2_0_0_,
              board0_.title as title3_0_0_,
              reply2_.board_id as board_id3_2_1_,
              reply2_.comment as comment2_2_1_,
              replylist1_.board_id as board_id1_1_0__,
              replylist1_.reply_list_id as reply_li2_1_0__ 
          from
              board_tb board0_ 
          inner join
              board_tb_reply_list replylist1_ 
                  on board0_.id=replylist1_.board_id 
          inner join
              reply_tb reply2_ 
                  on replylist1_.reply_list_id=reply2_.id
    • 단 1:N 조인의 경우 결과가 늘어나서, 중복된 결과가 나타날 수 있으니 JPQL의 DISTNICT를 사용해서 중복을 제거하는게 좋다.


  • 다른 방법은 내가 아까 실수로 사용했던 default_batch_fetch_size: N 을 설정하는 방법이다. 해당 설정을 통해서 연관된 엔티티를 조회할 때 batch size로 설정해둔 만큼 SQL의 IN절을 사용해서 조회한다.
    • 특정 필드 위만 @org.hibernate.annotations.BatchSize(size = N)로 설정할 수 있다.

      Hibernate: 
          select
              replylist0_.board_id as board_id1_1_1_,
              replylist0_.reply_list_id as reply_li2_1_1_,
              reply1_.id as id1_2_0_,
              reply1_.board_id as board_id3_2_0_,
              reply1_.comment as comment2_2_0_ 
          from
              board_tb_reply_list replylist0_ 
          inner join
              reply_tb reply1_ 
                  on replylist0_.reply_list_id=reply1_.id 
          where
              replylist0_.board_id in (
                  ?, ?
              )

김영한님은 이렇게 정리를 했다.
  • 기본 값이 즉시 로딩이 @OneToOne@ManyToOne은 지연 로딩으로 설정하고, 성능 최적화가 필요한 곳에서는 JPQL 페치 조인을 사용하자.
profile
Hello, World! \n

0개의 댓글