본문 바로가기

JAVA/JPA

[JPA] 성능 최적화하기_N+1 문제

회사에서 맡은 서비스맵을 성능적인 측면에서 개선할 수 있는 포인트들을 찾아 기록하기

N+1 문제 개선하기

하이버네이트의 연관관계를 사용할 때, 고려해야 할 가장 기본적인 요인이라고 생각한다.

즉시 로딩과 N+1

Member 엔티티와 Order 엔티티의 관계가 1:N, N:1 양방향 연관관계라고 가정하자.
이런한 상황에 특정 회원 하나를 em.find() 메소드를 조회하면 즉시 로딩으로 설정한 주문(Order) 정보도 함께 조회된다.

SELECT M.*, O.* FROM MEMBER M OUTER JOIN ORDERS O ON M.ID=O.MEMBER_ID

여기까지만 보면 즉시 로딩이 상당이 좋아보이지만, 문제는 JPQL을 사용할 때 발생한다.
다음 코드를 보면,

List<Member mebers =
    em.createQuery("select m from Member m", Member.class).getResultList();

JPQL을 실행하면 JPA는 이것을 분석해서 SQL을 생성한다.

SELECT * FROM MEMBER

SQL의 실행결과로 먼저 회원 엔티티를 애플리케이션에 로딩한다. 그런데 회원 엔티티와 연관된 주문 컬렉션이 즉시 로딩으로 설정되어 있으므로
JPA는 주문 컬렉션을 즉시 로딩하고 다음 SQL을 추가로 실행한다.

SELECT * FROM ORDERS WHERE MEMBER_ID=?

이때 조회된 회원이 하나면 총 2번의 SQL을 실행하지만, 조회된 회원이 5명면 어떻게 될까?
즉, 한번의 실행으로 회원과 연관된 주문엔티티를 찾기 위해 5번의 SQL을 추가로 실행한다.

SELECT * FROM MEMBER // 1번 실행으로 회원 5명 조회
SELECT * FROM ORDERS WHERE MEMBER_ID=1;
...
...
SELECT * FROM ORDERS WHERE MEMBER_ID=5;

지연 로딩과 N+1

지연 로딩으로 설정하면 데이터베이스에서 회원만 조회하기 때문에, JPQL에서는 N+1문제가 발생하지 않는다.
따라서 다음 SQL만 실행되고 연관된 주문 컬렉션은 지연 로딩된다.

SELECT * FROM MEMBER

문제는 다음처럼 모든 회원에 대해 연관된 주문 컬렉션을 사용할 때 발생한다.

for (Member member : members) {
     // 지연로딩 초기화
    System.out.println("member = " + member.getOrders().size());
}

주문 컬렉션을 초기화하는 수만큼 다음 SQL이 실행될 수 있다. 회원이 5명이면 회원에 따른 주문도 5번 조회된다.

SELECT * FROM MEMBER // 1번 실행으로 회원 5명 조회
SELECT * FROM ORDERS WHERE MEMBER_ID=1;
...
...
SELECT * FROM ORDERS WHERE MEMBER_ID=5;

이것도 결국 N+1 문제다. 지금까지 살펴본 것처럼 N+1 문제는 즉시 로딩과 지연 로딩일 때 모두 발생할 수 있다.
지금부터 N+1 문제를 피할 수 있는 폐치조인을 알아보자.

폐치 조인 사용

N+1 문제를 해결하는 가장 일반적인 방법은 폐치 조인의 사용이다.
폐치 조인은 SQL 조인을 사용해서 연관된 엔티티를 함께 조회하므로 N+1 문제가 발생하지 않는다
폐치 조인을 사용하는 JPQL

select m from Member m join fetch m.orders

실행된 SQL은 다음과 같다.

SELECT M.*, O.* FROM MEMBER M INNER JOIN ORDERS O ON M.ID = O.MEMBER_ID 

참고로 일대다 조인을 할 경우, 결과가 늘어나서 중복된 결과가 나타날 수 있다.
따라서 JPQL의 distinct를 사용해서 중복을 제거하는 것이 좋다

정리

즉시 로딩과 지연 로딩 중 추천하는 방법은 즉시 로딩은 사용하지 말고 지연 로딩만 사용하는 것이다.
즉시 로딩은 N+1 문제는 물론이고 비즈니스 로직에 따라 필요하지 않은 엔티티를 로딩하는 상황이 빈번하게 발생하며,
즉시 로딩의 가장 큰 문제는 성능 최적화가 어렵다는 점이다.

따라서 모두 지연 로딩을 설정하고 성능 최적화가 꼭 필요한 곳에는 JPQL 폐치 조인을 사용하자.

참조

  • 김영한님의 JPA프로그래밍