오늘의 나보다 성장한 내일의 나를 위해…













:pushpin: N+1


연관관계에서 발생하는 이슈로 연관관계가 설정된 엔티티조회할 경우에 조회된 데이터 개수(N)만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오게 된다. 이를 N+1 문제라고 한다.


JPA로 애플리케이션을 개발할 때 성능상 가장 주의해야 하는 것이 N+1 문제이다.


:pushpin: N+1 예제


Member.java

@Entity
public class Member{

  @Id
  @GeneratedValue
  private Long id;

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


Order.java

@Entity
@Table(name = "ORDERS")
public class Order{

  @Id
  @GeneratedValue
  private Long id;

  @ManyToOne
  private Member member;
  (...)
}


위 코드는 1:N, N:1 양방향 연관관계이다. 그리고 회원이 참조하는 주문정보인 Member.orders즉시 로딩(EAGER)으로 설정했다.


:pushpin: 즉시 로딩과 N+1

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

em.find(Member.class, id)


실행된 SQL은 다음과 같다.


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


여기서 함께 조회하는 방법이 주요한데 SQL을 두 번 실행하는 것이 아니라 조인을 사용해서 한 번의 SQL로 회원과 주문정보를 함께 조회한다. 여기까지만 보면 즉시 로딩이 상당히 좋아보인다.


문제는 JPQL을 사용할 때 발생한다.

다음 코드를 보자.


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


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

이때는 즉시 로딩지연 로딩에 대해서 전혀 신경 쓰지 않고 JPQL만 사용해서 SQL을 생성한다. 따라서 다음과 같은 SQL이 실행된다.


SELECT * FROM MEMBER


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


SELECT * FROM ORDERS WHERE MEMBER_ID=?


조회된 회원이 하나면 이렇게 총 2번의 SQL을 실행하지만 조회된 회원이 5명이면 어떻게 될까?


SELECT * FROM MEMBER //1 실행으로 회원 5 조회
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 //회원과 연관된 주문
SELECT * FROM ORDERS WHERE MEMBER_ID=5 //회원과 연관된 주문


먼저 회원 조회 SQL로 5명의 회원 엔티티를 조회했다.(SELECT * FROM MEMBER)

그리고 조회한 각각의 회원 엔티티와 연관된 주문 컬렉션을 즉시 조회하려고 총 5번의 SQL을 추가로 실행했다. 이처럼 처음 실행한 SQL의 결과 수만큼(회원이 5명이니까 SELECT * FROM ORDERS WHERE MEMBER_ID가 각각 5명에 대응되게 5번 호출되는 것) 추가로 SQL을 실행하는 것을 N+1 문제라 한다.


그렇다면 즉시 로딩이 JPQL을 실행할 때 N+1 문제를 야기할까?


그렇지 않다! Lazy로 설정해도 N+1 문제가 발생할 수 있다.


:pushpin:지연 로딩과 N+1

회원과 주문을 지연 로딩(Lazy)로 설정하면 어떻게 될까? 방금 살펴본 즉시 로딩 시나리오를 지연 로딩으로 변경해도 N+1 문제에서 자유로울 수는 없다

위 코드에서 EAGER 방식을 LAZY 방식으로 바꿔보자.


Member.java

@Entity
public class Member{

  @Id
  @GeneratedValue
  private Long id;

  @OneToMany(mappedBy= "member", fetch = FetchType.LAZY)
  private List<Order> orders=new ArrayLuist<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(); //지연 로딩 초기화


members.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 ORDERS WHERE MEMBERS_ID=1 //회원과 연관된 주문
SELECT * FROM ORDERS WHERE MEMBERS_ID=2 //회원과 연관된 주문
SELECT * FROM ORDERS WHERE MEMBERS_ID=3 //회원과 연관된 주문
SELECT * FROM ORDERS WHERE MEMBERS_ID=4 //회원과 연관된 주문
SELECT * FROM ORDERS WHERE MEMBERS_ID=5 //회원과 연관된 주문


이것도 결국 N+1 문제다. 지금까지 살펴본 것처럼 N+1 문제는 즉시 로딩지연 로딩일 때 모두 발생할 수 있다.


이제부터 N+1 문제를 피할 수 있는 다양한 방법을 알아보자


:pushpin: 해결 방법


:pushpin: 페치 조인 사용

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를 사용해서 중복을 제거하는 것이 좋다.


:pushpin: 하이버네이트 @BatchSize

하이버네이트가 제공하는 org.hibernate.annotations.BatchSize 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size만큼 SQL의 IN 절을 사용해서 조회한다. 만약 조회한 회원이 10명인데 size=5로 지정하면 2번의 SQL만 추가로 실행한다.


Member.java(BatchSize 적용)

@Entity
public class Member{
  ...
  @org.hibernate.annotations.BatchSize(size=5)
  @OneToMay(mappedBy="member", fetch=FetchType.EAGER)
  private List<Order> orders=new ArrayList<Order>();
  ...
}


즉시 로딩으로 설정하면 조회 시점에 10건의 데이터를 모두 조회해야 하므로 다음 SQL이 두 번 실행된다. 지연 로딩으로설정하면 지연 로딩된 엔티티를 최초 사용하는 시점에 다음 SQL을 실행해서 5건의 데이터를 미리 로딩해둔다. 그리고 6번 째 데이터를 사용하면 다음 SQL을 추가로 실행한다.


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


참고
hibernate.default_batch_fetch_size 속성을 사용하면 애플리케이션 전체에 기본적으로 @BatchSize를 적용할 수 있다.


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

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


@Entity
public class Member{
  ...
  @org.hibernate.annotations.Fetch(FetchMode.SUBSELECT)
  @OneToMay(mappedBy="member", fetch=FetchType.EAGER)
  private List<Order> orders=new ArrayList<Order>();
  ...
}


다음 JPQL로 회원 식별자 값이 10를 초과하는 회원을 모두 조회해보자.


select m from Member m where m.id>10


즉시 로딩으로 설정하면 조회 시점에, 지연 로딩으로 설정하면 지연 로딩된 엔티티를 사용하는 시점에 다음 SQL이 실행된다.


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


:pushpin: EntityGraph

@EntityGraph의 attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면 Lazy가 아닌 Eager 조회로 가져오게 된다. Fetch join과 동일하게 JPQL을 사용하면 query 문을 작성하고 필요한 연관관계를 EntityGraph에 설정하면 된다. 그리고 Fetch Join과는 다르게 join 문이 outer join으로 실행된다.(Fetch Join은 Inner join)


:pushpin: Fetch Join과 EntityGraph 주의할 점

Fetch Join과 EntityGraph는 JPQL을 사용하여 JOIN문을 호출한다는 공통점이 있다. 또한, 공통적으로 카테시안 곱(Cartesian Product)이 발생하여 데이터 수만큼의 중복 데이터가 존재할 수 있다. 그러므로 중복된 데이터컬렉션에 존재하지 않도록 주의해야 한다.


그렇다면 어떻게 중복된 데이터를 제거할 수 있을까?

  • 컬렉션을 Set을 사용하게 되면 중복을 허용하지 않는 자료구조이기 때문에 중복된 데이터를 제거할 수 있다.
  • JPQL을 사용하기 때문에 DISTINCT를 사용하여 중복된 데이터를 조회하지 않을 수 있다.


:pushpin: N+1 정리


즉시 로딩과 지연 로딩 중 추천하는 방법은 즉시 로딩은 사용하지 말고 지연 로딩만 사용하는 것이다.

즉시 로딩 전략은 그럴듯해 보이지만 N+1 문제는 물론이고 비즈니스 로직에 필요하지 않은 엔티티를 로딩해야 하는 상황이 자주 발생한다. 그리고 즉시 로딩의 가장 큰 문제는 성능 최적화가 어렵다는 점이다. 엔티티를 조회하다보면 즉시 로딩이 연속으로 발생해서 전혀 예상하지 못한 SQL이 실행될 수 있다. 따라서 모두 지연 로딩으로 설정하고 성능 최적화가 꼭 필요한 곳에는 JPQL Fetch Join을 사용하자.


JPA의 글로벌 페치 전략 기본값은 다음과 같다

  • @OneToOne, @ManyToOne: 기본 페치 전략은 즉시 로딩
  • @OneToMany, @ManyToMany: 기본 페치 전략은 지연 로딩


따라서 기본값이 즉시 로딩인 @OneToOne과 @ManyToOnefetch=FetchType.LAZY로 설정해서 지연 로딩 전략을 사용하도록 변경하자.


N+1에 대해 잘 설명한 글


YoungKyonYou

Integration of Knowledge