[JPA] 성능 최적화 - N+1 problem

rul9office·2021년 12월 27일
1

JPA로 애플리케이션을 개발할 때 발생할 수 있는 성능 문제들이 있다.

소위 N+1 문제라고도 불리는 이 문제는 연관관계가 설정된 엔티티를 조회할 때 가장 주의 해야하는 부분이므로 개발자 기술 면접 단골 질문이기도 하다. (실제로 오늘 면접에서 받은 질문이다.)

이전에 공부했던 내용이고 면접 준비를 하면서도 생각했던 부분이라 답변을 하긴 했지만 다소 시원치 않았던 것 같다. 아직 내 것이 안됐다는 반증인 것 같다.

좀 더 정확히 공부해서 제대로 기억해두고자 한다.

회원 엔티티

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<Order>();
}

주문 엔티티

@Entity
@Table(name = "ORDERS")
public class Order {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    private Member member;
}

회원과 주문 정보는 1:N, N:1 양방향 연관관계다. 회원이 참조하는 주문정보인 Member.orders은 즉시 로딩으로 설정했다.

즉시 로딩과 N+1

특정 회원 하나를 em.find() 메소드로 조회하면 즉시 로딩으로 설정한 주문 정보도 함께 조회한다.

em.find(Member.class, id);

여기서 실행되는 SQL은 다음과 같다.

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

이렇게 조인을 사용하여 한 번에 회원 정보와 주문 정보를 가져오게 되니 즉시 로딩이 좋아보이는데, 문제는 JPQL을 사용할 때 발생하게 된다.
JPQL을 실행하면 JPA는 이를 분석해서 SQL을 생성한다.
이 때는 즉시 로딩이나 지연 로딩을 전혀 신경쓰지 않고 JPQL 만 사용하여 SQL을 생성한다.

SELECT * FROM MEMBER

이 쿼리의 실행 결과로 먼저 회원 엔티티를 애플리케이션에 로딩한다.

그런데 회원 엔티티와 연관된 주문 컬렉션이 즉시 로딩으로 설정되어있으므로 JPA는 주문 컬렉션을 즉시 로딩하려고 다음 SQL을 추가로 실행한다.

SELECT * FROM ORDERS WHERE MEMBER_ID=?

조회된 회원이 하나면 이렇게 총 2번은 SQL이 실행하지만, 조회된 회원이 여러 명이면 어떻게 될까? 삼천명이면? 오천만명이면? ....

SELECT * FROM MEMBER // 1번 실행으로 N명의 회원 조회 
SELECT * FROM ORDERS WHERE MEMBER_ID=1
SELECT * FROM ORDERS WHERE MEMBER_ID=2
SELECT * FROM ORDERS WHERE MEMBER_ID=3
SELECT * FROM ORDERS WHERE MEMBER_ID=4
...
..

회원 조회 SQL로 회원 엔티티를 조회하고 조회한 각각의 회원 엔티티와 연관된 주문 컬렉션을 즉시 조회하려고 총 N번의 SQL를 추가로 실행했다.

이렇게 처음 실행한 SQL의 결과 수만큼 추가로 SQL을 실행하는 것을 N+1 문제라고 한다.

즉시로딩은 JPQL을 실행할 때 N+1 문제가 발생할 수 있다.

지연 로딩과 N+1

회원과 주문을 지연 로딩으로 설정해보자.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<Order>();
}

지연로딩으로 설정하면 JPQL에서는 N+1 문제가 발생하지 않는다.

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

지연 로딩이므로 데이터 베이스에서 회원만 조회된다. 따라서 아래의 SQL만 실행되고 연관된 주문 컬렉션은 지연 로딩된다.

SELECT * FROM MEMBER

이후 비즈니스 로직에서 주문 컬렉션을 실제로 사용할 때 지연 로딩이 발생한다.

firstMember = members.get(0);
firstMember.getOrders().size(); // 지연 로딩 초기화

member.get(0)으로 회원 하나만 조회해서 사용했으므로 firstMember.getOrders().size() 를 호출하면서 실행되는 SQL은 다음과 같다.

SELECT * FROM ORDERS WHERE MEMBER_ID=?

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

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

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

SELECT * FROM MEMBER // 1번 실행으로 N명의 회원 조회
SELECT * FROM ORDERS WHERE MEMBER_ID=1
SELECT * FROM ORDERS WHERE MEMBER_ID=2
SELECT * FROM ORDERS WHERE MEMBER_ID=3
SELECT * FROM ORDERS WHERE MEMBER_ID=4
...
..

이것도 결국 같은 N+1 문제가 된다.

문제를 피할 수 있는 방법

fetch join 사용

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

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

그러나 fetch join 을 사용하게 되면 연관관계 설정해놓은 FetchType을 사용할 수 없으므로, FetchType을 Lazy로 설정해놓는 것이 무의미해진다.

또한 페이징 쿼리를 사용할 수 없어서 페이징 단위로 데이터를 가져오는 것이 불가능하다.

EntityGraph 사용

@EntityGraph(attributePaths = "orders")
@Query("select m from MEmber m")
List<Member> findAllEntityGraph();

@EntityGraphattributePaths 에 쿼리 수행 시 가져올 필드명을 지정하면 Lazy가 아닌 Eager 조회로 가져오게 된다.

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

fetch join은 inner join으로 Entity Graph는 outer join 으로 가져오는 것을 알 수 있다.

일대다 조인을 했으므로 중복된 결과가 나타날 수 있다.

일대다 필드의 타입을 Set으로 선언할 수도 있다. Set은 중복을 허용하지 않는 자료구조이므로 중복 등록이 되지 않는다.

@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private Set<Order> orders = new LinkedHashSet<Order>();

Set이 순서가 보장되지 않으므로 LinkedHashSet을 사용하여 순서를 보장하면 된다.

Set 보다 List가 더 적합하다고 판단된다면 DISTINCT 를 사용해서 중복을 제거하면 된다.

Hibernate @BatchSize

하이버네이트가 제공하는 @BatchSize 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size 만큼 SQL의 IN 절을 사용해서 조회한다.

만약 조회한 회원이 10명이면 size = 5로 지정하면 2번의 SQL 만 추가로 실행한다.

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    
	
    @org.hibernate.annotations.BatchSize(size = 5)
    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<Order>();
}

즉시 로딩으로 설정하면 다음 SQL 이 2번 실행된다. 지연 로딩으로 설정하면 지연 로딩된 엔티티를 최초 사용하는 시점에 다음 SQL을 실행해서 5건의 데이터를 미리 로딩해둔다.

그리고 6번째를 사용하면 다음 SQL을 추가로 실행한다.

SELECT * 
  FROM ORDERS
 WHERE MEMBER_ID IN (?, ?, ?, ?, ?
) 

애플리케이션 전체에 기본으로 @BatchSize 를 지정할 수도 있다.

<property name="hibernate.default_batch_fetch_size" value="5" />

Hibernate @Fetch(FetchMode.SUBSELECT)

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

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    
	
    @org.hibernate.annotations.Fetch(FetchMode.SUBSELECT) 
    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<Order>();
}
select m from Member m where m.id > 10

위와 같은 SQL로 id 값이 10 을 초과하는 회원을 조회한다고 했을 때,
즉시 로딩으로 설정하면 조회 시점에, 지연 로딩으로 설정하면 지연 로딩된 엔티티를 사용하는 시점에 다음 SQL이 실행된다.

SELECT O 
  FROM ORDERS O
 WHERE O.MEMBEER_ID IN (
		SELECT M.ID
            	 FROM MEMBER M
		 WHERE M.ID > 10
			)

QueryBuilder 사용

Query를 실행하도록 지원해주는 다양한 QueryBuilder를 사용할 수 있다.
QueryDSL과 같은 오픈소스를 사용하여 최적화된 쿼리를 구현할 수 있다.

정리

추천하는 방법은 즉시 로딩은 사용하지 않고 지연 로딩만 사용하는 것이다.
즉시 로딩 전략은 그럴듯해보이지만 N+1 문제 뿐 아니라 비즈니스 로직에 따라 필요하지 않은 엔티티를 로딩해야하는 상황이 발생할 수 있다.
즉시 로딩 방법의 큰 문제는 성능 최적화가 어렵다는 점이다.
엔티티를 조회하다보면 즉시 로딩이 연속으로 발생해서 전혀 예상하지 못한 SQL이 실행될 수도 있다.

따라서 모두 지연 로딩으로 설정하고, 성능 최적화가 꼭 필요한 곳에 JPQL fetch join을 사용하자.

@XToOne : 기본 페치 전략 → 즉시 로딩
@XtoMany : 기본 페치 전략 → 지연 로딩

@XtoOne 의 페치 전략을 fetch = FetchType.LAZY로 설정하여 지연 로딩 전략을 사용하도록 하자.

Reference

profile
Brings a positive attitude, loves challenges, and enjoys sharing knowledge with others.

0개의 댓글