📌 JPA N + 1 문제란?
조회된 데이터 개수만큼, 연관 관계의 조회 쿼리가 추가로 발생하는 문제를 의미한다.
EX) 카테고리와 게시글
@Entity
@NoArgsConstructor
@Setter
@Getter
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@ManyToOne
private Category category;
public Board(String title, String content, Category category) {
this.title = title;
this.content = content;
this.category = category;
}
}
@Entity
@NoArgsConstructor
@Setter
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
@OneToMany(mappedBy = "category", fetch = FetchType.EAGER)
private List<Board> boardList = new ArrayList<>();
public Category(String content) {
this.content = content;
}
}
카테고리 10개와 각 카테고리마다 게시글 10개씩 데이터 추가
@Test
@DisplayName("initData")
@Rollback(value = false)
void test() {
List<Category> categoryList = new ArrayList<>();
for (int i = 0; i < 10; i++) {
Category category = new Category("Category" + i );
Category savedCategory = categoryRepository.save(category);
List<Board> boardList = new ArrayList<>();
for (int j = 0; j < 10; j++) {
boardList.add(new Board("Board" + j, "Board", savedCategory));
}
boardRepository.saveAll(boardList);
savedCategory.setBoardList(boardList);
categoryList.add(savedCategory);
}
categoryRepository.saveAll(categoryList);
}
카테고리 전체 조회 시, JpaRepository를 활용하여 findAll로 가져온다.
List<Category> categoryList = categoryRepository.findAll();
실행된 SQL문들을 확인하면, 이러한 형태로 총 11개의 쿼리문이 실행된다.
카테고리 10개만큼, 매핑된 게시글을 조회하기 때문에 10번의 쿼리가 더 발생하게 된다.
이를 JPA N + 1 문제라고 한다.
📌 문제 원인
JPA는 메서드 이름을 분석해서 JPQL이 생성되어 SQL이 실행된다.
따라서, 메서드 이름만을 분석하기 때문에 연관 관계는 파악하지 못한 채 SQL문이 실행된다.
1️⃣ EAGER 에서의 원인
: JPQL을 통한 SQL문으로 조회를 한 후, Fetch 전략을 가지고 하위 엔티티를 즉시 조회한다.
2️⃣ LAZY 에서의 원인
: JPQL을 통한 SQL문으로 조회를 한 후, Fetch 전략이 LAZY이므로 즉시 조회를 하지 않는다.
그렇지만, 추후 Getter와 같은 작업이 필요하게 되면, 하위 엔티티를 조회한다.
따라서, N + 1 문제가 발생한다.
📌 해결 방안
❓ Join Fetch
JpaRepository에서 제공해주지 않아서 직접 JPQL로 작성해주고 실행하게 되면,
@Query("select c from Category c join fetch c.boardList")
List<Category> findAllJoinFetch();
이렇게 하나의 쿼리만 실행되어 N + 1문제를 해결할 수 있다.
자세히 살펴보면,
SELECT DISTINCT category0_.id AS CATEGORY_ID,
boardlist1_.id AS BOARD_ID,
category0_.content AS CATEGORY_CONTENT,
boardlist1_.category_id AS BOARD_CATEGORY_ID,
boardlist1_.content AS BOARD_CONTENT,
boardlist1_.title AS BOARD_TITLE,
boardlist1_.category_id AS BOARD_CATEGORY_ID,
boardlist1_.id AS BOARD_ID
FROM category category0_
INNER JOIN board boardlist1_
ON category0_.id=boardlist1_.category_id
게시글과 카테고리를 INNER JOIN을 통해 가지고 오는 모습을 확인할 수 있다.
❔ 일반 Join과의 차이
SELECT DISTINCT category0_.id AS CATEGORY_ID,
category0_.content AS CATEGORY_CONTENT
FROM category category0_
INNER JOIN board boardlist1_
ON category0_.id=boardlist1_.category_id
일반적인 JOIN의 경우, 필요한 엔티티 컬럼만 SELECT 하므로 N + 1 문제가 해결되지 않는다.
☑️ Fetch Join의 단점
모든 연관 관계 데이터를 가져오므로, FetchType.LAZY가 무의미해진다.
❓ Entity Graph
@EntityGraph(attributePaths = {"boardList"})
@Query("select DISTINCT c from Category c")
List<Category> findAllEntityGraph();
@EntityGraph를 통해 attributePath에 설정해준 부분을 EAGER 전략으로 조회해올 수 있다.
따라서 boardList를 attributePaths에 넣어주어 설정해주면, 하나의 쿼리만 실행된다.
SELECT DISTINCT category0_.id AS CATEGORY_ID,
boardlist1_.id AS BOARD_ID,
category0_.content AS CATEGORY_CONTENT,
boardlist1_.category_id AS BOARD_CATEGORY_ID,
boardlist1_.content AS BOARD_CONTENT,
boardlist1_.title AS BOARD_TITLE,
boardlist1_.category_id AS BOARD_CATEGORY_ID,
boardlist1_.id AS BOARD_ID
FROM category category0_
LEFT OUTER JOIN board boardlist1_
ON category0_.id=boardlist1_.category_id
Join Fetch 방식과 다르게, LEFT OUTER JOIN을 통해 조회해온다.
☑️ Entity Graph의 단점
INNER JOIN보다 성능이 안좋은 OUTER JOIN을 사용하게 된다.
❔ DISTINCT의 필요성
: Fetch Join과 Entity Graph의 경우, 카테시안 곱이 발생하므로 중복 제거가 필요하다.
(+, 또는 List 대신 Set 이용하기)
❓FetchMode.SUBSELECT
지금까지는 하나의 쿼리로 해결했다면, FetchMode.SUBSELECT의 경우 두 번의 쿼리로 해결한다.
import org.hibernate.annotations.Fetch;
import org.hibernate.annotations.FetchMode;
...
@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "category", fetch = FetchType.EAGER)
private List<Board> boardList = new ArrayList<>();
기존 N + 1 문제가 발생했던 Test를 다시 보면, 쿼리가 두 번만 발생하여 문제가 해결되었다.
select category0_.id as id1_1_,
category0_.content as content2_1_
from category category0_
select boardlist0_.category_id as category4_0_1_,
boardlist0_.id as id1_0_1_,
boardlist0_.id as id1_0_0_,
boardlist0_.category_id as category4_0_0_,
boardlist0_.content as content2_0_0_,
boardlist0_.title as title3_0_0_
from board boardlist0_
where boardlist0_.category_id
in (select category0_.id from category category0_)
카테고리 리스트를 조회하는 쿼리문과 IN을 통해 카테고리 ID에 맞는 게시글들을 조회해오는 쿼리문 총 두 번의 쿼리가 발생한다.
FetchType이 EAGER인 경우, 조회해올 때 위의 쿼리가 실행되며, LAZY인 경우엔 하위 엔티티가 필요할 때 실행된다.
📌 Batch Size
@BatchSize(size = 10)
@OneToMany(mappedBy = "category", fetch = FetchType.EAGER)
private List<Board> boardList = new ArrayList<>();
SELECT category0_.id AS id1_1_, c
ategory0_.content AS content2_1_
FROM category category0_
SELECT boardlist0_.category_id AS category4_0_1_,
boardlist0_.id AS id1_0_1_,
boardlist0_.id AS id1_0_0_,
boardlist0_.category_id AS category4_0_0_,
boardlist0_.content AS content2_0_0_,
boardlist0_.title AS title3_0_0_
FROM board boardlist0_
WHERE boardlist0_.category_id
IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@BatchSize로 지정된 만큼 IN절을 사용하여 조회한다.
쿼리 두 번으로 N + 1 문제를 해결할 수 있다.
만약 Batch Size를 5로 설정해두었다면, 총 쿼리가 3번 실행되게 된다.
application.yml 에서의 전체적으로 설정도 가능하다.
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
데이터량에 따라서 어느 정도의 양이 Batch Size로 적당한지 파악이 필요하다.
참고)
N+1 문제