본문 바로가기
Troubleshooting

[JPA] N+1 문제

by 가드 2022. 11. 12.
728x90

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 관계로 간단하게 설정하였다.

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 페이징 처리에 대해서도 정리해야겠다.

300x250

댓글