[JPA] N+1 문제

wisdom·2022년 11월 21일
0

Spring Boot

목록 보기
6/7
post-thumbnail

N+1 문제란?

N+1 문제란 1개의 쿼리로 처리되길 기대했지만 N개의 추가 쿼리가 발생하는 현상으로, 처음 실행한 SQL의 결과 수만큼 추가로 SQL이 실행되는 것을 말한다. 이것은 연관관계가 설정되어 있는 엔티티를 JPQL을 통해 조회하는 경우에 발생하는 문제다.
JPQL을 실행하면 JPA가 JPQL을 분석하여 SQL을 생성해주는데, 이때 연관관계와 즉시 로딩, 지연 로딩에 대해서는 전혀 신경쓰지 않고 엔티티 기준으로만 SQL 쿼리를 생성하게 된다.

즉시 로딩, 지연 로딩 모두에서 N+1 문제가 발생할 수 있다.

즉시 로딩에서의 N+1 문제

Member 엔티티, Order 엔티티가 1:N 양방향 연관관계 설정이 되어있다고 하자.

@Entity
@Table(name = "orders")
public class Order {
    @ManyToOne
    private Member member;
    ...
}

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

연관관계가 즉시 로딩(fetch = FetchType.EAGER)으로 설정되어 있는 경우, 우선 해당 엔티티를 기준으로 쿼리를 실행한 후에 바로 이어서 연관된 엔티티를 조회하는 쿼리가 추가로 실행된다.

동작 과정

memberRepository.findAll()
  1. findAll()을 한 순간 select m from Member m 이라는 JPQL 구문이 생성되고 해당 구문을 분석한 select * from member 이라는 SQL이 생성되어 실행된다.
  2. DB의 결과를 받아 member 엔티티의 인스턴스들을 생성한다.
  3. member와 연관되어 있는 order 도 로딩을 해야 한다.
  4. 영속성 컨텍스트에서 연관된 order가 있는지 확인한다.
  5. 영속성 컨텍스트에 없다면 2에서 만들어진 member 인스턴스들 개수에 맞게 select * from order where member_id = ? 이라는 SQL 구문이 생성된다. (N+1 발생)

지연 로딩에서의 N+1 문제

@Entity
@Table(name = "orders")
public class Order {
    @ManyToOne
    private Member member;
    ...
}

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

지연로딩의 경우 엔티티 조회 당시에는 N+1 문제가 바로 발생하지는 않지만, 이후에 비즈니스 로직에서 연관된 엔티티를 실제로 사용할 때 추가로 쿼리를 실행하게 된다.

동작 과정

  1. findAll()을 한 순간 select m from Member m 이라는 JPQL 구문이 생성되고 해당 구문을 분석한 select * from member 이라는 SQL이 생성되어 실행된다.
  2. DB의 결과를 받아 member 엔티티의 인스턴스들을 생성한다.
  3. 코드 중에서 member 의 order 객체를 사용하려고 하는 시점에 영속성 컨텍스트에서 연관된 order가 있는지 확인한다.
  4. 영속성 컨텍스트에 없다면 order 객체를 사용하려는 member 인스턴스들 개수에 맞게 select * from order where member_id = ? 이라는 SQL 구문이 생성된다. (N+1 발생)

해결 방법

1. fetch join

페치 조인은 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

한계

  1. 패치 조인 대상에는 별칭을 줄 수 없다.
  2. 둘 이상의 컬랙션을 패치할 수 없다.
  3. 컬랙션을 패치 조인하면 페이징 API를 사용할 수 없다.

💡 컬렉션 패치 조인과 페이징

컬렉션을 fetch join하게 되면 페이징이 불가능하다. fetch join을 통해 가져온 데이터는 컬렉션의 개수에 따라 row가 증가하기 때문이다.
페이징을 사용하고 싶은 경우는, row 수에 영향을 미치지 않도록 ToOne 관계만 fetch join 으로 가져오고 컬렉션은 지연 로딩으로 조회하자. 이때 지연 로딩의 성능 최적화를 위해 아래에 나오는 @BatchSize를 사용하자.

2. 하이버네이트 @BatchSize

org.hibernate.annotations.BatchSize 어노테이션은 연관된 엔티티를 조회할 때 지정한 size만큼 SQL IN 절 을 사용해서 조회한다. 예를 들어, 조회한 회원이 10명이고 size=5라면 2번의 SQL만 추가로 실행된다.

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

실행되는 SQL

select * from member;

select * from orders where member_id in (?,?,?,?,?);

select * from orders where member_id in (?,?,?,?,?);

💡 hibernate.default_batch_fetch_size
hibernate.default_batch_fetch_size 속성을 사용하면 애플리케이션 전체에 기본으로 @BatchSize 를 적용할 수 있다.
default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택하는 것을 권장한다.
대부분의 DB에서 IN절의 최대 개수가 1000개로 제한되어 있기 때문에, 기본적으로 Batch Size의 값을 1000 이하로 설정해야 한다.
application.yml

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000 

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

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

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

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

실행되는 SQL

select * from member;

select * 
from orders o
where o.member_id in (select m.id from member m);

4. @EntityGraph

엔티티 그래프 기능은 SQL 조인 을 통해 엔티티를 조회하는 시점에 연관된 엔티티들을 함께 조회한다. fetch join과 유사하지만 left outer join 만 지원한다는 차이가 있다.

public interface MemberRepository extends JpaRepository<Member, Long> {

	@Override
	@EntityGraph(attributePaths = {"orders"}, 
				type = EntityGraphType.LOAD)
	List<Member> findAll();
}
  • attributePaths : 함께 조회할 엔티티를 추가한다. 복수 가능.
  • type
    • EntityGraphType.FETCH (default) : attribute는 EAGER로 패치하고, 나머지 attribute는 LAZY로 패치한다.
    • EntityGraphType.LOAD : 명시한 attribute는 EAGER로 패치하고, 나머지 attribute는 entity에 명시한 fetch type이나 디폴트 FetchType으로 패치한다. (e.g. @OneToMany는 LAZY, @ManyToOne은 EAGER가 디폴트)

실행되는 SQL

select m.*, o.* from member m
	left outer join orders o on m.id=o.member_id
profile
백엔드 개발자

0개의 댓글