본문 바로가기
CS

[JPA] N+1 문제

by cornsilk-tea 2023. 9. 6.

N+1 문제란?

  • N+1 문제는 JPA등의 ORM(Object-Relational Mapping) 프레임워크에서 발생하는 성능 이슈.
  • 한 번의 쿼리로 여러 개의 주 엔터티(예: Member)를 조회한 후, 각 주 엔터티에 연결된 하위 엔터티(예: Order)를 개별적으로 조회할 때 발생합니다.

N+1 문제가 발생하는 이유

예를 들어`Order`가 아래와 같이 설정되어 있다고 가정해 봅시다.

@Entity
public class Member {
    
    @Id @GeneratedValue
    private Long id;

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

    // getters and setters
}

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

    @ManyToOne
    private Member member;

    // getters and setters
}

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

List<Member> members = memberRepository.findAll();

첫 번째 쿼리는 `findAll()` 메서드로 모든 `Member`를 조회합니다.

SELECT * FROM Member;

그리고 이 과정에서 각 `Member`에 대한 `Order`를 즉시 조회하기 위해 추가 쿼리가 발생합니다.

SELECT * FROM ORDERS WHERE member_id = ?;

여기서 N은 전체 `Order`의 수이며, 총 N+1 개의 쿼리가 실행됩니다.


그렇다면 fetchType을 Eager 대신 LAZY로 변환하면 N+1 문제가 해결될까요?

꼭 그렇지만도 않습니다. 다음 예제를 통해 확인해 봅시다.

@Entity
public class Member {
    
    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY) // EAGER -> LAZY 변경
    private List<Order> orders = new ArrayList<>();

    // getters and setters
}

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

    @ManyToOne
    private Member member;

    // getters and setters
}

이렇게 `findAll()` 메서드로 모든 `Member`를 조회할 시엔 아무 문제가 생기지 않습니다. 지연로딩의 특성상 `order`를 사용하기 전까진 로딩하지 않기 때문입니다.

List<Member> members = memberRepository.findAll();

하지만 문제는 다음과 같은 로직이 있을 경우에 발생합니다.

List<Member> members = memberRepository.findAll();
for (Member member : members) {
    List<Order> orders = member.getOrders(); // 이 부분에서 추가 쿼리가 발생
}

전체 `Member`에 대해 매번 `SELECT * FROM ORDERS WHERE member_id =?;`를 호출하기 때문에 이 또한 N+1과 무관하다고 할 순 없습니다.

이처럼 우리는 N+1문제가 EAGER, LAZY 설정과 무관하게 발생하는 문제라는 것을 알 수 있었습니다. 그렇다면 어떻게 해결할 수 있을까요?

N+1 문제 해결

1. JOIN FETCH 적용

jpql과 QueryDSL을 사용하여 JOIN FETCH를 적용하면 해결할 수 있습니다. 하지만, 이 방법을 사용할 때 주의해야 할 점은 일대다 조인의 결과로 데이터가 늘어나 중복된 결과가 나올 수 있다는 것입니다. 이는 DISTINCT 키워드를 사용해서 해결할 수 있습니다.

  • JPQL
    • List<Member> members = em.createQuery("SELECT DISTINCT m FROM Member m JOIN FETCH m.orders", Member.class).getResultList();
  • QueryDSL
    • JPAQuery<Member> query = new JPAQuery<>(em);
      List<Member> members = query.distinct().from(member)
          .leftJoin(member.orders, order).fetchJoin()
          .fetch();

단점

  • 데이터가 늘어나면서 중복될 수 있어, 메모리 사용량이 높아질 수 있습니다.
  • 복잡한 쿼리에는 적용하기 어려울 수 있으며, 최적화가 제한적입니다.

2. Entity Graph

Entity Graph를 사용하면 @EntityGraph 어노테이션을 통해 어떤 엔터티의 어떤 필드를 미리 로딩할 것인지 명시적으로 지정할 수 있습니다. 이는 JOIN FETCH와 비슷한 역할을 하나, API를 통해 더 세밀하게 제어할 수 있습니다.

@EntityGraph(attributePaths = "orders")
List<Member> findAll();

단점

  • EntityGraph는 동적인 쿼리 생성이 제한적일 수 있습니다.

3. Batch Size 설정

`@BatchSize`는 한 번에 로딩할 하위 엔터티의 크기를 지정합니다. 예를 들어, `@BatchSize(size = 5)`라고 설정하면, `Member` 한 명당 연관된 `Order`를 5개씩 로딩합니다. 이 방법은 주로 `LAZY` 로딩을 사용할 때 유용하며, 한 번에 여러 엔터티를 로딩하여 N+1 쿼리 문제를 완화할 수 있습니다.

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

단점

  • 실제 필요한 양보다 많이 불러올 위험이 있어, 불필요한 리소스가 사용될 수 있습니다.
  • 적절한 Batch Size 값을 찾기 위한 튜닝 작업이 필요하며, 이 과정에서 시간과 리소스가 소모됩니다.

4. 하이버네이트 @Fetch(FetchMode.SUBSELECT) 사용

@Fetch(FetchMode.SUBSELECT)를 사용하게 되면 `Member` 엔터티 목록을 조회할 때 해당 `Member` 엔터티들에 연관된 `Order` 엔터티들을 서브 쿼리를 통해 한 번에 로딩합니다.

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

이렇게 설정했을 시 `select m from Member m where m.id > 10`쿼리를 수행한다면, 즉시 로딩 시에는 조회 시점에, 지연로딩 시에는 지연로딩 된 엔터티를 사용하는 시점에 다음 SQL이 실행됩니다.

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

단점

  • SUBSELECT는 모든 연관 엔터티를 한 번에 로딩하므로, 데이터 양이 클 경우 메모리 문제가 발생할 수 있습니다.
  • 서브 쿼리를 생성하기 때문에 데이터베이스에 부하를 줄 수 있으며, DB 최적화가 어려울 수 있습니다.