1. N+1 문제란?
JPA에서 요청이 1개의 쿼리로 처리되길 기대했는데 N개의 추가 쿼리가 발생하는 현상으로 1:N 또는 N:1 관계를 가진 Entity 조회할 때 발생하게 된다.
이 문제는 서버와 DB 간의 악영향을 끼치는 큰 문제인데 10만, 100만 개 등의 많은 양의 데이터를 N+1의 DB 조회가 일어난다고 가정해보면 분명 서비스의 큰 문제가 발생하게 될 것이다. 정말 무섭다.
예전에 N+1 문제로 한참을 고생했던 기억이 있어서 다시 되새길 겸 N+1문제에 대해 다시 정리해보려고 한다. (그만큼 중요하다고 생각되는 문제)
2. Test 설정
테스트 위해 table 2개를 만들었다. note와 memo table이고 두 table에 관계는 1:N 관계로 간단하게 설정하였다.
note table
id | color | size |
1 | black | 10 |
2 | white | 5 |
3 | yellow | 10 |
4 | green | 5 |
memo table
id | memo_text | note_id |
2 | Sample...1 | 1 |
3 | Sample...2 | 2 |
4 | Sample...3 | 2 |
5 | Sample...4 | 3 |
6 | Sample...5 | 3 |
7 | Sample...6 | 3 |
8 | Sample...7 | 4 |
9 | Sample...8 | 4 |
10 | Sample...9 | 4 |
11 | Sample...10 | 4 |
@Getter
@Setter
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Note {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String color;
private Integer size;
@OneToMany(fetch = FetchType.EAGER, mappedBy = "note")
private List<Memo> memoList;
}
@Getter
@Setter
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Memo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String memoText;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "note_id")
private Note note;
}
3. 테스트
3-1. FetchType.EAGER 테스트
테스트 코드
@Test
public void test() {
List<Note> list = noteRepository.findAll();
System.out.println("note list size = " + list.size());
}
테스트 로그
Hibernate: select note0_.id as id1_1_, note0_.color as color2_1_, note0_.size as size3_1_ from note note0_
Hibernate: select memolist0_.note_id as note_id3_0_0_, memolist0_.id as id1_0_0_, memolist0_.id as id1_0_1_, memolist0_.memo_text as memo_tex2_0_1_, memolist0_.note_id as note_id3_0_1_ from memo memolist0_ where memolist0_.note_id=?
Hibernate: select memolist0_.note_id as note_id3_0_0_, memolist0_.id as id1_0_0_, memolist0_.id as id1_0_1_, memolist0_.memo_text as memo_tex2_0_1_, memolist0_.note_id as note_id3_0_1_ from memo memolist0_ where memolist0_.note_id=?
Hibernate: select memolist0_.note_id as note_id3_0_0_, memolist0_.id as id1_0_0_, memolist0_.id as id1_0_1_, memolist0_.memo_text as memo_tex2_0_1_, memolist0_.note_id as note_id3_0_1_ from memo memolist0_ where memolist0_.note_id=?
Hibernate: select memolist0_.note_id as note_id3_0_0_, memolist0_.id as id1_0_0_, memolist0_.id as id1_0_1_, memolist0_.memo_text as memo_tex2_0_1_, memolist0_.note_id as note_id3_0_1_ from memo memolist0_ where memolist0_.note_id=?
Note 데이터 전체 조회 1번 발생, Note 하위 Entity Memo 조회 4번 발생 = 모두 5번 조회 발생
우리는 findAll에 의한 쿼리가 한 번만 발생하기를 기대했지만 관계에 의한 DB 조회가 5번이 발생된 것을 확인했다.
3-2. FetchType.LAZY 테스트
테스트 코드 (LAZY 설정으로 loop를 활용해 Memo 데이터에 접근하는 코드 추가)
@Transactional
@Test
public void test() {
List<Note> list = noteRepository.findAll();
for(Note note : list) {
System.out.println("memo list size = " + note.getMemoList().size());
}
}
테스트 로그
Hibernate: select note0_.id as id1_1_, note0_.color as color2_1_, note0_.size as size3_1_ from note note0_
Hibernate: select memolist0_.note_id as note_id3_0_0_, memolist0_.id as id1_0_0_, memolist0_.id as id1_0_1_, memolist0_.memo_text as memo_tex2_0_1_, memolist0_.note_id as note_id3_0_1_ from memo memolist0_ where memolist0_.note_id=?
Hibernate: select memolist0_.note_id as note_id3_0_0_, memolist0_.id as id1_0_0_, memolist0_.id as id1_0_1_, memolist0_.memo_text as memo_tex2_0_1_, memolist0_.note_id as note_id3_0_1_ from memo memolist0_ where memolist0_.note_id=?
Hibernate: select memolist0_.note_id as note_id3_0_0_, memolist0_.id as id1_0_0_, memolist0_.id as id1_0_1_, memolist0_.memo_text as memo_tex2_0_1_, memolist0_.note_id as note_id3_0_1_ from memo memolist0_ where memolist0_.note_id=?
Hibernate: select memolist0_.note_id as note_id3_0_0_, memolist0_.id as id1_0_0_, memolist0_.id as id1_0_1_, memolist0_.memo_text as memo_tex2_0_1_, memolist0_.note_id as note_id3_0_1_ from memo memolist0_ where memolist0_.note_id=?
EAGER와 조회 시점 차이 일 뿐 동일한 조회 결과가 나온다. (note 조회 1번, memo 조회 4번)
4. 해결 방안
4-1. Fetch Join 사용
패치 조인은 JPQL에서 성능 최적화 위한 기능으로 관계된 Entity를 한 번에 조회하는 역할을 해준다. 일반 조인일 경우 지정된 조회 결과만 가져올 수 있기 때문에 Entity, Collection<Entity>를 함께 가져오지 못한다.
NoteRepository.class에 fetch join query 추가
@Query("SELECT n from Note n join fetch n.memoList")
List<Note> findAllWithFetchJoin();
테스트 코드
public void test() {
List<Note> list = noteRepository.findAllWithFetchJoin();
for(Note note : list) {
System.out.println("memo list size = " + note.getMemoList().size());
}
}
테스트 로그
Hibernate: select note0_.id as id1_1_0_, memolist1_.id as id1_0_1_, note0_.color as color2_1_0_, note0_.size as size3_1_0_, memolist1_.memo_text as memo_tex2_0_1_, memolist1_.note_id as note_id3_0_1_, memolist1_.note_id as note_id3_0_0__, memolist1_.id as id1_0_0__ from note note0_ inner join memo memolist1_ on note0_.id=memolist1_.note_id
memo list size = 1
memo list size = 2
memo list size = 2
memo list size = 3
memo list size = 3
memo list size = 3
memo list size = 4
memo list size = 4
memo list size = 4
memo list size = 4
실행 결과 1번 호출로 Memo 리스트까지 모두 포함해서 조회가 되었다. 엇?! 근데 뭔가 이상하다. note 리스트 크기가 4개여야 하는데 memo row 수만큼 생성되었다. 이것이 Fetch Join 조심해야 될 부분인 카타시안 곱의 중복 문제이다.
중복을 제거할 수 있도록 distinct를 query에 추가 설정하자
@Query("SELECT DISTINCT n FROM Note n JOIN FETCH n.memoList")
List<Note> findAllWithFetchJoin();
Hibernate: select distinct note0_.id as id1_1_0_, memolist1_.id as id1_0_1_, note0_.color as color2_1_0_, note0_.size as size3_1_0_, memolist1_.memo_text as memo_tex2_0_1_, memolist1_.note_id as note_id3_0_1_, memolist1_.note_id as note_id3_0_0__, memolist1_.id as id1_0_0__ from note note0_ inner join memo memolist1_ on note0_.id=memolist1_.note_id
memo list size = 1
memo list size = 2
memo list size = 3
memo list size = 4
엔티티 중복 제거로 인하여 정상적인 note size와 memo list size를 가져올 수 있다.
참고사항으로 List 대신에 LinkedHashSet을 이용해도 동일한 결과를 가질 수 있다. (LinkedHashSet 사용은 순서 보장을 하기 위함)
이것도 만능 아니다. 문제가 여전히 남아있다.
- JPA에서 제공하는 Pageable이 사용이 안돼서 페이징 처리가 불가능하다. (Batch Size 설정으로 우회할 수는 있다.)
- 관계 Entity가 두 개 이상일 경우 Fetch Join이 사용이 불가능하다. (Note 하위에 Memo 말고 다른 Entity가 추가로 있을 경우 MultipleBagFetchException 발생)
4-1. EntityGraph 사용
EntityGraph 사용 시 attributePaths에 정의된 객체를 left join이 자동적으로 붙게 되어 하나의 쿼리에서 연관된 데이터를 조회할 수 있다. 결국 Fetch Join과 다른 방식의 사용이지만 같은 동일한 결과를 추출해 낼 수 있다.
@EntityGraph(attributePaths = {"memoList"})
@Query("SELECT DISTINCT n from Note n")
List<Note> findAllWithEntityGraph();
추가사항
N+1에서 해결방안으로 Fetch Join과 EntityGraph 사용에 대해서 정리해봤는데 실제 서비스에서 Collection Data는 페이지 처리하는 기능이 많을 것이다.
@ManyToOne, @OneToOne 관계의 경우 테이블을 조인해도 데이터 수가 변하지 않기 때문에 Paging 처리도 잘 되며 distinct 중복처리에도 영향을 주지 않는다.
@OneToMany, @ManyToMany 컬렉션 관계는 데이터 수가 변하기 때문에 paging 처리가 불가능하다. JPA에서 Paging을 하면서 컬렉션 관계를 fetch join 하는 동작을 막아두었기 때문이다.
혹시 이 상황에서 강제로 Full Scan을 하여 메모리에 불러와서 페이징을 적용하는 방식은 매우 위험하다. (절대 불가!)
나중에 JPA 페이징 처리에 대해서도 정리해야겠다.
'Troubleshooting' 카테고리의 다른 글
[MySQL] Replication 동기화 시간 문제 (0) | 2022.11.08 |
---|---|
[Elastic Search] maximum shards open 에러 (0) | 2022.11.08 |
[Elastic Search] Rejecting mapping update to... (0) | 2022.11.07 |
댓글