N+1이란?
연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수 N번 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어와 서버와 데이터의 접촉이 N번 발생하는 현상이다.
예시로 게시물과 해시태그를 예시로 들어 확인해본다.
가정
- 한 게시물에는 여러개의 해시태그를 들 수 있다.
- 해시태그는 한 게시물에만 속한다.
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "board_id")
private Long id;
private String content;
@OneToMany(mappedBy = "board", fetch = FetchType.EAGER)
private List<HashTag> hashTags = new ArrayList<>();
public Board(String content) {
this.content = content;
}
}
@Entity
@Getter
@Setter
@NoArgsConstructor
public class HashTag {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne
private Board board;
public HashTag(String name) {
this.name = name;
}
}
테스트 케이스 가정
- 4개의 해시태그를 생성했다.
- 10개의 게시물을 생성한다.
- 각 게시물에는 4개씩 해시태그가 들어가있다.
@Test
@Commit
public void 게시물조회(){
ArrayList<HashTag> hashTags = new ArrayList<>();
for(int i=0; i<4; i++){
HashTag hashTag = new HashTag("hashtag" + i);
hashTags.add(hashTag);
}
hashTahRepository.saveAll(hashTags);
List<Board> boards = new ArrayList<>();
for(int i=0; i<10; i++){
Board board = new Board("board" + i);
board.setHashTags(hashTags);
boards.add(board);
}
em.clear();
System.out.println("------------------------------------------------------------------------");
List<Board> boardAll = boardRepository.findAll();
assertFalse(boardAll.isEmpty());
}
아래와 같이 단순 게시물을 조회했을 뿐인데 해시태그의 select 문이 나가는 것을 알 수 있다.
그러면 entity의 연관관계에서 fetch를 즉시로딩(EAGER)이 아니라 지연로딩(LAZY)라면 다른가??
@OneToMany(mappedBy = "board", fetch = FetchType.LAZY)
private List<HashTag> hashTags = new ArrayList<>();
위와같이 쿼리문이 한번 날라가게 되었지만
System.out.println("------------------------------------------------------------------------");
List<Board> boardAll = boardRepository.findAll();
System.out.println("------------------------------------------------------------------------");
boardAll.forEach(board -> {
board.getHashTags().size();
});
다음과같이 접근을 하게되면 아래와 같은 N+1의 문제가 다시 생긴다.
문제해결 방법
Fetch join
@Query("select b from Board b join fetch b.hashTags")
List<Board> findAllJoinFetch();
Hibernate: select board0_.board_id as board_id1_0_0_, hashtags1_.id as id1_2_1_, board0_.content as content2_0_0_, hashtags1_.board_board_id as board_bo3_2_1_, hashtags1_.name as name2_2_1_, hashtags1_.board_board_id as board_bo3_2_0__, hashtags1_.id as id1_2_0__ from board board0_ inner join hash_tag hashtags1_ on board0_.board_id=hashtags1_.board_board_id
다음과 같이 INNER JOIN으로 필요한 hashtag를 불러오는 쿼리를 볼 수 있다.
하지만, fetch join은 Entity연관관계에서 FetchType을 설정했다면 사용할수 없는 단점이 있다. 따라서, 현재 LAZY로 해놓은 상황에서는 select를 하는 순간 연관 관계의 데이터를 가져오기 때문에 Fetch Join으로 해결 할 수 없다. 그리고 페이징 쿼리를 사용할 수 없는 단점이 있다.
EntityGraph
@EntityGraph(attributePaths = "hashTags")
@Query("select b from Board b")
List<Board> findAllEntityGraph();
Hibernate: select board0_.board_id as board_id1_0_0_, hashtags1_.id as id1_2_1_, board0_.content as content2_0_0_, hashtags1_.board_board_id as board_bo3_2_1_, hashtags1_.name as name2_2_1_, hashtags1_.board_board_id as board_bo3_2_0__, hashtags1_.id as id1_2_0__ from board board0_ left outer join hash_tag hashtags1_ on board0_.board_id=hashtags1_.board_board_id
Fetch Join과는 다르게 Left Outer Join이 나가는 것을 볼 수 있다. @EntityGraph attributePaths의 역할은 가져올 필드명을 지정하면 면 지연로딩이아닌 즉시로딩으로 가져온다.
Fetch Join과 EntityGraph 사용시 주의점
Fetch Join과 EntityGraph는 JPQL을 사용해 Join문을 사용한다. 공통적으로 Catesian Product에 의해 Board수 만큼 Hashtag가 중복이 발생할 수 있다. 따라서, 중복에 대해서 주의해야한다.
중복발생시
- 컬렉션 Set을 사용한다.
- distinct를 사용한다.
BatchSize
@BatchSize(size = 5)
@OneToMany(mappedBy = "board", fetch = FetchType.LAZY)
private List<HashTag> hashTags = new ArrayList<>();
Hibernate: select hashtags0_.board_board_id as board_bo3_2_1_, hashtags0_.id as id1_2_1_, hashtags0_.id as id1_2_0_, hashtags0_.board_board_id as board_bo3_2_0_, hashtags0_.name as name2_2_0_ from hash_tag hashtags0_ where hashtags0_.board_board_id in (?, ?, ?, ?, ?)
Hibernate: select hashtags0_.board_board_id as board_bo3_2_1_, hashtags0_.id as id1_2_1_, hashtags0_.id as id1_2_0_, hashtags0_.board_board_id as board_bo3_2_0_, hashtags0_.name as name2_2_0_ from hash_tag hashtags0_ where hashtags0_.board_board_id in (?, ?, ?, ?, ?)
지연로딩이기에 Board를 조회해서 해시태그에 접근할 경우 다음과 같은 IN절을 배치 사이즈만큼 인자로 넘어가서 2번이 날라가는 것을 알수 있다.
EX) BatchSize 20 해시태그 100개 IN절이 5번 조회
FetchMode.SUBSELECT
@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "board", fetch = FetchType.LAZY)
private List<HashTag> hashTags = new ArrayList<>();
Hibernate: select hashtags0_.board_board_id as board_bo3_2_1_, hashtags0_.id as id1_2_1_, hashtags0_.id as id1_2_0_, hashtags0_.board_board_id as board_bo3_2_0_, hashtags0_.name as name2_2_0_ from hash_tag hashtags0_ where hashtags0_.board_board_id in (select board0_.board_id from board board0_)
다음과 같이 모든 로딩에 대해서 대응이 가능하고 성능최적화 부분에서 좋은 전략이다.