본문 바로가기
👩‍💻TIL/JPA

[JPA] N+1 문제 발생 원인과 해결방법

by devuna 2022. 8. 31.
728x90

[JPA] N+1 문제 발생 원인과 해결방법

💡 원인

[JPA] 영속성 관리, 준영속 상태와 지연 로딩, N+1 문제 발생 원인

 

JPA를 사용하여 개발할 때, 성능상 주의해야 할 것이 N+1 문제이다.

 

📌 즉시 로딩과 N+1

일반적으로 제공되는 find() 메소드 등을 사용할 때는 jpa가 내부적으로 join문에 대한 쿼리를 만들어서 반환을 하기 때문에 즉시 로딩이 큰 문제가 없어 보이기도 합니다.

그러나 JPQL을 사용할 때, 문제가 발생합니다. 

JPA는 JPQL을 사용할 때는 글로벌 페치 전략을 참고하지 않고 오직 JPQL 만 사용하기 때문에,

즉시 로딩 / 지연 로딩 구분 없이 쿼리문만 따르게 됩니다. 

List<Classroom> classrooms = 
em.createQuery("select c from Classroom c", Classroom.class)
.getResultList(); //연관된 모든 엔티티를 조회한다.

위의 조회문을 실행하면, 조인이 일어나지 않고

1. ClassRoom을 조회하는 퀴리문 1개 실행

2. ClassRoom 안에 들어있는 연관된 Student를 조회하는 쿼리문도 N 번 실행된다.

따라서, 데이터의 갯수+1 개만큼 SQL이 호출되기 때문에 조회 성능에 아주 치명적인 문제가 발생한다.

 

📌 지연로딩과 N+1

지연 로딩으로 설정하면 JPQL에서는 N+1문제가 발생하지 않는다. 그러나  N+1문제로부터 자유로워지지는 않았다.

 

아래와 같이 "학생(student)"을 연관하여 가지는 "학급(classroom)" 이 있다고 할 때,

@Entity
public class Classroom {
@Id @GeneratedValue
private Long Id;

@ManyToOne(fetch = FetchType.EAGER) // 즉시로딩
private Student student; //학생

...

}

글로벌 페치 전략을 지연 로딩으로 설정하면, 연관된 엔티티를 실제 엔티티가 아닌 프록시 객체로 조회한다.

그리고 이 프록시 객체는 실제 사용하는 시점에 초기화된다. 

 

즉, 지연 로딩은 해당 연결 entity에 대해서 프록시로 걸어두고, 사용할 때 쿼리문을 날리기 때문에 처음 find 할 때는 N+1문제가 발생하지 않지만 추가로 classroom검색 후 classroom의 student을 사용해야 한다면 student를 조회하는 N+1개의 쿼리가 또 발생하게 된다.

SELECT * FROM CLASSROOM WHERE STUDENT_ID = 1;
SELECT * FROM CLASSROOM WHERE STUDENT_ID = 2;
SELECT * FROM CLASSROOM WHERE STUDENT_ID = 3;
SELECT * FROM CLASSROOM WHERE STUDENT_ID = 4;
...

 

💡 해결방법

1. 페치 조인 사용

1번에서 말한 글로벌 페치 전략을 즉시 로딩으로 설정하는 것은 지양해야 하므로,

사용할 수 있는 방법 중 하나는 fetch join이다. JPQL에서 join fetch 만 넣어 사용하면 N+1 문제가 발생하지 않는다.

 (N+1 문제없이 화면에 필요한 엔티티를 미리 로딩하는 현실적인 방법)

 

페치 조인 사용 전 

JPQL : select c from Classroom c;
SQL: select * from CLASSROOM;

페치조인 사용 후 

JPQL : select c from Classroom c join fetch c.student;

SQL: select * from CLASSROOM join STUDENT s on c.student_id = s.id

 페치조인 사용시, 일대다 조인을 하여 중복된 결과가 나타난다면 JPQL의 DISTINCT로 중복제거하는 것이 좋다.

2. 하이버네이트 @BatchSize

하이버네이트가 제공하는 org.hibernate.annotations.BatchSize 어노테이션을 사용하면, 연관된 엔티티를 조회할 때 지정한 size 만큼 SQL의 in 절을 사용하여 조회한다. 10명일 경우, 배치사이즈를 5로 주면 쿼리를 2번만 날리게 된다.

@org.hibernate.annotations.BatchSize(size = 5)
@OneToMany(mappedBy = "student", fetch=FetchType.EAGER)
private List<Classroom> classrooms = new ArrayList<Classroom>();

 

3. 하이버네이트 @Fetch(FetchMode.SUBSELECT)

하이버네이트가 제공하는 org.hibernate.annotations.Fetch 어노테이션에서 FetchMode를 SUBSELECT로 사용하면, 연관된 엔티티를 조회할 때 서브쿼리를 사용하여 N+1 문제를 해결한다.

 

 

 정리

@OneToOne, @ManyToOne : 디폴트 페치 전략은  즉시 로딩
@OneToMany, @ManyToMant : 디폴트 페치전략은 지연 로딩

 

즉시 로딩과 지연 로딩 중 추천하는 방법은 즉시 로딩은 사용하지않고, 지연로딩만 사용하는 것

즉시로딩은 불필요한 엔티티를 로딩하는 상황이 많고, 성능 최적화가 어렵다.

즉시 로딩이 연속적으로 발생하여 예상하지 못한 SQL이 실행될 수도 있다는 단점이 있다.

 

기본적으로 지연 로딩으로 설정해두고 성능 최적화가 필요한 부분에 JPQL 페치 조인을 사용하는 것을 추천!

(@OneToOne, @ManyToOne의 경우 fetch=FetchType.LAZY 옵션을 설정하여 지연로딩 전략을 사용하도록 변경하는 것이 좋다)

 

 

 

 

참고)

Java Orm JPA 프로그래밍, 김영한, 에이콘 출판

728x90

댓글