1. N+1 문제
(1) 즉시로딩
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
@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) 지연로딩
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) 페치조인
@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) 기본개념
2) 설정방법
###############################################################################
# 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
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)
@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