성능 최적화(N+1문제)

Mina Park·2022년 11월 29일
0

1. N+1 문제

(1) 즉시로딩

  • (1-1) em.find() 메소드로 조회
    • 외부조인을 사용해서 한 번의 SQL 실행
public class Board extends BaseEntity {

	//...

	@OneToMany(mappedBy = "board", orphanRemoval = true, fetch = FetchType.EAGER)
	@OrderBy("createdAt desc")
	private List<BoardReply> boardReplyList = new ArrayList<>();
}
public class BoardReply extends BaseEntity {

	//...
    
    @ManyToOne(fetch = FetchType.LAZY) //bidirectional
	@JoinColumn(name = "board_id")
	private Board board;
}
    select
        board0_.id as id1_3_0_,
        board0_.created_at as created_2_3_0_,
        board0_.updated_at as updated_3_3_0_,
        board0_.created_by as created_8_3_0_,
        board0_.updated_by as updated_9_3_0_,
        board0_.board_type as board_ty4_3_0_,
        board0_.content as content5_3_0_,
        board0_.title as title6_3_0_,
        board0_.view_count as view_cou7_3_0_,
        boardreply1_.board_id as board_id7_4_1_,
        boardreply1_.id as id1_4_1_,
        boardreply1_.id as id1_4_2_,
        boardreply1_.created_at as created_2_4_2_,
        boardreply1_.updated_at as updated_3_4_2_,
        boardreply1_.created_by as created_5_4_2_,
        boardreply1_.updated_by as updated_6_4_2_,
        boardreply1_.board_id as board_id7_4_2_,
        boardreply1_.content as content4_4_2_ 
    from
        board board0_ 
    left outer join
        board_reply boardreply1_ 
            on board0_.id=boardreply1_.board_id 
    where
        board0_.id=? 
    order by
        boardreply1_.created_at desc
  • (1-2) JPQL 사용시
    • board 조회 => board 수만큼 즉시로딩이 걸려있는 boardReply 조회하기 위해 추가 SQL 실행
    • JPQL 실행시 JPA는 즉시로딩/지연로딩에 대해 신경 쓰지 않고 JPQL만 이용해서 SQL을 생성
      - 따라서 먼저 board 엔티티를 조회하고 이후에 추가적으로 연관 엔티티 조회
	@Transactional(readOnly = true)
	public Page<BoardListDTO> getBoardList(String keyword, Pageable pageable) {
		Page<Board> board = boardRepository.findAllPaging(keyword, pageable);
		Page<BoardListDTO> res = BoardListDTO.toBoardListDTO(board);

		return res;
	}
    @Query(value = "select b from Board b" +
            " where ((b.title like concat('%', :keyword, '%')" +
            " or b.content like concat('%', :keyword, '%'))" +
            " or (:keyword is null or :keyword = ''))" +
            " order by b.createdAt desc")
    Page<Board> findAllPaging(@Param("keyword") String keyword, Pageable pageable );

즉시로딩이라 하더라도 JPQL 사용시 N+1 문제가 발생할 수 있다



(2) 지연로딩

  • 지연로딩 초기화 수만큼 결국은 SQL이 추가 실행
public class Board extends BaseEntity {

	//...

	@OneToMany(mappedBy = "board", orphanRemoval = true)
	@OrderBy("createdAt desc")
	private List<BoardReply> boardReplyList = new ArrayList<>();
}
public class BoardReply extends BaseEntity {

	//...
    
    @ManyToOne(fetch = FetchType.LAZY) //bidirectional
	@JoinColumn(name = "board_id")
	private Board board;
}
	public static Page<BoardListDTO> toBoardListDTO(Page<Board> board) {
		return board.map(b -> BoardListDTO.builder()
				.id(b.getId())
				.title(b.getTitle())
				.createdAt(b.getCreatedAt())
				.replyCount(b.getBoardReplyList().size())  //지연로딩 초기화
				.build());
	}

(사진 생략 - (1) 케이스와 동일)

지연로딩의 경우 지연로딩 초기화로 인해 필연적으로 N+1 문제가 발생한다



(3) 페치조인

  • N+1 문제를 해결하는 가장 일반적인 방법(대부분의 성능 문제는 페치조인으로 해결 가능)
  • SQL 조인을 통해 연관된 엔티티를 함께 조회하기 때문
    @Query(value = "select b from Board b join fetch b.boardReplyList" +
            " order by b.createdAt desc")
    List<Board> findAllPaging();

가장 일반적인 N+1문제 해결책인 페치조인의 문제는 없을까?
일대다 조인의 경우 페이징처리 불가라는 문제가 있다

📌 [3-1] @OneToMany에서의 페치조인 사용시 페이징 처리 문제

    @Query(value = "select b from Board b" +
            " where ((b.title like concat('%', :keyword, '%')" +
            " or b.content like concat('%', :keyword, '%'))" +
            " or (:keyword is null or :keyword = ''))" +
            " order by b.createdAt desc")
    @EntityGraph(attributePaths = {"boardReplyList"}) //페치조인의 간편버전(left join 사용)
    Page<Board> findAllPaging(@Param("keyword") String keyword, Pageable pageable);

📌 보기에는 페이징 처리가 되는 것 같지만, hibernate에서 경고 발생

  • WARN 10856 --- [nio-8080-exec-7] o.h.h.internal.ast.QueryTranslatorImpl : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
  • 동작에는 문제가 없지만 메모리 낭비를 한다는 경고
  • fetch join과 pagination을 같이 할 경우 모든 데이터를 전부 가져온 뒤 메모리에서 하이버네이트가 페이징 처리를 하기 때문에 메모리 부하가 일어나는 것
  • 실제 실행 쿼리를 보면 pageable을 파라미터로 받고 있음에도 limit절이 찍히지 않음

📌 일대다 관계에서는 왜 이런 문제가 생길까?

  • 이유
    • xxxToMany의 경우 fetch join을 하게되면 데이터 수가 ‘Many’쪽에 맞춰져 데이터 수가 예측할 수 없이 늘어나게 됨 → hibernate는 메모리에서 페이징 처리를 하게 되는데, 페이징 기준을 잡지 못함 → 메모리 부하 장애 발생
  • 해결방법
    (1) xxxToOne 관계는 fetch join 사용하여 한꺼번에 가져오기(데이터 수가 늘어나지 않으므로)
    (2)컬렉션의 경우에는 지연로딩 사용하여 추가 쿼리로 가져오기(대신, N+1 문제를 해소하기 위해 IN절 쿼리를 사용하도록 batch size를 설정하여 데이터를 최대한 미리 가져오기)


(4) 하이버네이트 @BatchSize

1) 기본개념

  • 연관된 엔티티 조회시 지정한 size 만큼 SQL의 IN절을 사용하여 조회
  • 일대다 관계에서 페치조인 사용시 페이징처리가 불가한 문제를 해결할 수 있는 방법 중 하나 => 페치조인처럼 한 방에 모든 데이터를 가져오는건 아니지만, 쿼리 실행수를 상대적으로 대폭 감소 가능
  • 보통 100 ~ 1000 사이를 많이 쓴다고하는데 서비스 환경에 따라 다름

2) 설정방법

  • 글로벌 설정
    • application.yml에 batch size 설정
###############################################################################
# jpa 설정
###############################################################################
  jpa:
    show-sql: true
    open-in-view: false
    database-platform: org.hibernate.dialect.MariaDB103Dialect
    hibernate:
      ddl-auto: none
      naming:
        physical-strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy
        implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy
    properties:
      hibernate:
        default_batch_fetch_size: 100
        format_sql: true
  • 개별 설정
    • 해당 엔티티에 @BatchSize(size = 50) 와 같이 설정 가능
public class Board extends BaseEntity {

	//...

	@OneToMany(mappedBy = "board", orphanRemoval = true)
	@OrderBy("createdAt desc")
	private List<BoardReply> boardReplyList = new ArrayList<>();
}
public class BoardReply extends BaseEntity {

	//...
    
    @ManyToOne(fetch = FetchType.LAZY) //bidirectional
	@JoinColumn(name = "board_id")
	private Board board;
}
    @Query(value = "select b from Board b" +
            " where ((b.title like concat('%', :keyword, '%')" +
            " or b.content like concat('%', :keyword, '%'))" +
            " or (:keyword is null or :keyword = ''))" +
            " order by b.createdAt desc")
    Page<Board> findAllPaging(@Param("keyword") String keyword, Pageable pageable );

📌 결론적으로 batch size 옵션을 두면 컬렉션 조회시 실행 쿼리 수를 대폭 줄일 수 있다..

  • 지연로딩 only : 1번(기본 데이터 조회) + N번(데이터 갯수만큼 추가 쿼리)
  • 지연로딩 with batch size : 1번(기본 데이터 조회) + 1번(연관 데이터 조회)

❓ [의문점] application.yml에 batch size 옵션을 지정해두더라도 그와 다르게 IN절 쿼리 갯수가 찢어져서 날아가는 현상 => hibernate가 알아서 IN절 사용하는 갯수를 최적화하기 때문?

  • Ex) batch size는 100, 조회할 데이터가 16개, 페이지 크기 20
  • 예상 쿼리: 한 방에 IN절 16개를 써서 가져오기
  • 실제 쿼리: IN절 12개 + IN절 4개




(5) 하이버네이트 @Fetch(FetchMode.SUBSELECT)

  • 연관된 엔티티 조회시 서브쿼리를 사용하여 N+1 문제 해결
  • 즉시로딩으로 설정시 조회 시점, 지연로딩으로 설정시 지연로딩된 엔티티 사용 시점에 서브쿼리 실행
	@org.hibernate.annotations.Fetch(FetchMode.SUBSELECT)
	@OneToMany(mappedBy = "board", orphanRemoval = true, fetch = FetchType.EAGER)
	@OrderBy("createdAt desc")
	private List<BoardReply> boardReplyList = new ArrayList<>();



[정리] 모두 지연로딩으로 설정하고 성능 최적화가 꼭 필요한 곳에는 JPQL 페치 조인을 사용하자!

  • 지연로딩으로 인한 N+1 문제 발생시 해결
    • [case1] xxxToOne: 페치조인 사용
    • [case2] 컬렉션
      • 페이징 필요 O : 지연로딩 + batch size 옵션 지정으로 데이터 미리 가져오기
      • 페이징 필요 X : 페치조인 사용
  • JPA의 글로벌 페치 전략 기본값
    • @OneToOne, @ManyToOne: EAGER
    • @OneToMany, @ManyToMany: LAZY

0개의 댓글