본문 바로가기

JPA

[JPA] N + 1 문제와 해결방법

N + 1 문제란?

처음 조회한 데이터 수만큼 다시 SQL을 사용해서 연관 관계가 설정된 엔티티를 조회하는 것을 N + 1 문제라고 한다. 

N + 1 문제는 em.find() 메소드나 스프링 데이터 JPA의 findById() 메소드 같은 단건 조회 시에는 연관된 엔티티를 조인해서 한번에 조회해오기 때문에 문제가 되지 않지만 JPQL을 이용한 메소드를 호출할 때는 문제가 발생한다.

 

N + 1 상황을 만들기 위해 아래와 같이 엔티티를 설정했다.

Member 엔티티

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
    
}

Order 엔티티

package com.jpabook.jpashop.domain;

import lombok.ToString;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

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

    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    public void setMember(Member member) {
        if (this.member != null) {
            this.member.getOrders().remove(this);
        }
        this.member = member;
        member.getOrders().add(this);
    }
    
    ...    
    
}

회원 엔티티와 주문 엔티티가 일대다 다대일로 양방향 매핑 되어있다.

즉시 로딩과 N + 1 문제

public class Member {

	...

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

즉시 로딩의 경우 N + 1 문제를 알아보기 위해 먼저 Member 엔티티에서 @OneToMany 어노테이션에 글로벌 페치 전략을 EAGER로 설정하였다.

@Test
public void 즉시조회() {
    Member member1 = new Member();
    member1.setName("kim");

    Member member2 = new Member();
    member2.setName("lee");

    Member member3 = new Member();
    member3.setName("park");

    Order order1 = new Order();
    order1.setMember(member1);

    Order order2 = new Order();
    order2.setMember(member2);

    Order order3 = new Order();
    order3.setMember(member3);

    em.clear();

    memberRepository.save(member1);
    memberRepository.save(member2);
    memberRepository.save(member3);
    orderRepository.save(order1);
    orderRepository.save(order2);
    orderRepository.save(order3);

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

}

즉시 로딩으로 설정한 후 위와 같은 테스트 코드를 실행했을 때 아래와 같이 조회된 Member의 수 만큼 연관된 Order 엔티티를 조회하는 select 쿼리가 나가는 것을 확인할 수 있다.

지연 로딩과 N + 1 문제

public class Member {

    ...

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

이번에는 지연 로딩의 경우 N + 1 문제를 알아보기 위해 글로벌 페치 전략을 LAZY로 설정하였다.

참고로 @OneToMany 연관관계의 경우 기본 전략이 LAZY이기 때문에 생략해도 된다.

지연 로딩을 설정하고 같은 테스트를 진행하면 아래와 같이 회원만 조회 된다.

하지만 이것은 우리가 글로벌 페치 전략을 LAZY로 설정하여 연관관계 데이터가 프록시 객체로 바인딩되었기 때문이다.

회원을 조회한 후 아래와 같이 실제로 주문 컬렉션을 사용하면 즉시 로딩과 마찬가지로 N + 1 문제가 발생한 것을 확인할 수 있다.

for (Member member : members) {
    System.out.println(member.getOrders().size());
}

N + 1 문제 발생 이유

N + 1 문제가 발생하는 이유는 JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 페치 전략을 참고하지 않고 오직 JPQL 자체만을 사용하기 때문이다. 즉 위 예시와 같이 findAll() 메서드로 전체 회원을 조회해 오면 JPA는 글로벌 페치 전략을 무시하고 SELECT * FROM Member SQL을 실행하여 회원 정보만을 조회해온다. 그리고 나서 즉시 로딩인 경우 영속성 컨텍스트에서 주문 객체를 찾고 영속성 컨텍스트에 없으면 데이터베이스에서 조회해오기 위해 처음에 조회된 회원의 수만큼 쿼리가 발생하는 것이다. 마찬가지로 지연 로딩의 경우에도 회원 정보만 조회해 오고 연관된 주문 데이터는 프록시 객체로 바인딩 해놓지만 실제로 주문 데이터를 사용하게 되면 똑같이 쿼리가 발생한다.

해결 방법

페치 조인 사용

페치 조인은 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능으로 N + 1 문제를 해결하는 가장 일반적인 방법이다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("select m from Member m join fetch m.orders")
    List<Member> findAllMemberWithOrder();
    
}

페치 조인은 join fetch 명령어를 이용해 사용할 수 있는데 MemberRepository에 @Query 어노테이션으로 직접 jpql을 작성한 후 findAllMemberWithOrder() 메소드로 조회하면 추가 쿼리가 발생하지 않고 아래와 같이 회원과 주문 엔티티를 inner join으로 한 번에 조회해 오는 것을 확인할 수 있다.

하이버네이트 @BatchSize

하이버네이트가 제공하는 org.hibernate.annotations.BatchSize 을 이용해서도 N + 1 문제를 해결 할 수 있다. BatchSize 어노테이션은 연관된 엔티티를 조회할 때 지정한 size만큼만 SQL의 IN 절을 사용해서 조회할 수 있다.

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

Member 엔티티에서 위와 같이 BatchSize를 적용해주면  size 만큼 주문 엔티티를 조회해 온다. 테스트 코드에서 주문 엔티티를 3건만 데이터베이스에 저장했기 때문에 주문을 조회하는 쿼리가 한번만 나갈 것이다. 만약 데이터를 6건 저장했다면 추가 쿼리가 2번 나간다.

application.yml 파일이나 application.properties 파일에 hibernate.default_batch_fetch_size 속성을 지정해 놓으면 애플리케이션 전체에 BatchSize를 지정할 수 있다.

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

하이버네이트가 제공하는 @Fetch 어노테이션을 이용해서도 N + 1 문제를 해결할 수 있다. @Fetch 어노테이션의 FetchMode를 SUBSELECT로 설정하면 조회 시 서브 쿼리를 사용해서 주문 정보를 조회해 온다.

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

주문 조회 쿼리 마지막에 서브쿼리가 작성되어 있는 것을 확인할 수 있다.

'JPA' 카테고리의 다른 글

[Querydsl] 프로젝션  (0) 2023.01.17
[Querydsl] Querydsl 적용하기  (0) 2023.01.15
[JPA] 객체지향 쿼리  (0) 2022.10.10
[JPA] 영속성 전이 (CASCADE), 고아객체 제거 (ORPHAN)  (0) 2022.10.05
[JPA] 프록시, 즉시로딩, 지연로딩  (0) 2022.10.04