개발/JPA

[JPA] N+1 문제

highright96 2022. 1. 8.

JPA 스터디를 진행하며 작성한 글입니다.

 

N+1 쿼리가 발생하는 이유

JPA를 사용하면 자주 만나게 되는 것이 N+1 문제이다. 그럼 N+1 문제는 언제 발생할까?

N+1 문제가 발생하는 경우는 다음과 같은 2가지 경우가 있다.

두 개의 엔티티가 1:N 관계를 가지며 JPQL로 객체를 조회할 때

 

  1. EAGER 전략을 사용한 경우
  2. LAZY 전략을 사용한 후 하위 엔티티를 따로 조회하는 경우

 

예시를 통해 살펴보자. 아래와 같이 1:N 관계를 갖고 있는 회원과 주문 엔티티를 생성하고, 회원을 호출하여 그 안에 속한 주문을 사용한다고 가정하겠다.

 

Member

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

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

    @Builder
    public Member(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    public void addOrders(List<Order> orders) {
        for (Order order : orders) {
            order.setMember(this);
            this.orders.add(order);
        }
    }
}

 

Order

@Getter
@Entity
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

    @Builder
    public Order(Long id, String name, Member member) {
        this.id = id;
        this.name = name;
        this.member = member;
    }

    public void setMember(Member member) {
        this.member = member;
    }
}

 

EAGER 전략을 사용한 경우

위 예제에서 회원과 주문의 1:N 관계에 EAGER 전략을 사용한 것을 볼 수 있다. 여기서 Spring Data JPA에서 제공하는 findAll()을 호출하면 어떻게 될까?

 

간단한 테스트 코드를 작성하여 쿼리가 어떻게 발생되는지 확인해보겠다.

 

@Test
void 즉시_로딩_N1_쿼리_테스트() {
    List<Member> members = memberRepository.findAll();
    assertThat(members.size()).isEqualTo(5);
}

 

위 테스트 코드 실행하면 아래와 같이 member를 전체 조회하는 쿼리와 연관된 order를 조회하는 쿼리 5개가 발생한 것을 확인할 수 있다(N+1 쿼리 발생).

 

 

LAZY 전략을 사용한 경우

위 예제에서 회원과 주문 관계를 LAZY 전략으로 바꾼 다음 findAll()을 호출하면 어떻게 될까?

 

@OneToMany(mappedBy = "member", cascade = CascadeType.PERSIST, orphanRemoval = true)
private List<Order> orders = new ArrayList<>();

 

참고로 @OneToMany의 기본 페치 전략은 LAZY이다.

간단한 테스트 코드를 작성하여 쿼리가 어떻게 발생되는지 확인해보겠다.

 

@Test
void 지연_로딩_N1_쿼리_테스트() {
    List<Member> members = memberRepository.findAll();
    assertThat(members.size()).isEqualTo(5);
}

 

위 테스트 코드를 실행하면 예상한 대로 member만 조회하는 쿼리 한개가 발생하는 것을 확인할 수 있다.

 

 

그럼 여기서 member와 연관된 하위 엔티티들을 조회한다면 어떻게 될까?

 

@Test
@Transactional
void 지연_로딩_N1_쿼리_연관_엔티티_조회_테스트() {
    em.clear();
    List<Member> members = memberRepository.findAll();
    for (Member member : members) {
        System.out.println(member.getOrders().size());
    }
    assertThat(members.size()).isEqualTo(5);
}

 

위 테스트 코드를 실행해보면 EAGER 전략을 사용했을 때와 같이 N+1 쿼리가 발생하는 것을 확인할 수 있다.

 

 

지금은 member가 5개이니 1 + 5개의 쿼리만 발생하지만 만약 member 조회 결과가 10만건이라면 1 + 10만개의 쿼리가 발생하는 말도 안되는 상황이 생긴다.

 

그럼 N+1 문제를 해결하려면 어떻게 해야 할까?

 

 

해결 방법

Join Fetch

첫번 째 방법은 join fetch를 사용하는 것이다(회원과 주문 관계에는 LAZY 전략으로 설정했다).

 

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

 

다음 테스트 코드를 돌려보자.

 

@Test
@Transactional
void 지연_로딩_N1_쿼리_엔티티_그래프_테스트() {
    em.clear();
    List<Member> members = memberRepository.findAllEntityGraph();
    for (Member member : members) {
        System.out.println(member.getOrders().size());
    }
    assertThat(members.size()).isEqualTo(25);
}

 

조인 쿼리 하나로 회원과 주문 엔티티를 가져올 수 있는 것을 확인할 수 있다.

 

 

fetch join은 inner join을 사용한다는 점을 주의해야 한다.

 

Entity Graph

두번 째 방법은 @EntityGraph를 사용하는 것이다.

 

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

 

@Test
@Transactional
void 지연_로딩_N1_쿼리_엔티티_그래프_테스트() {
    em.clear();

    List<Member> members = memberRepository.findAllEntityGraph();
    for (Member member : members) {
        System.out.println(member.getOrders().size());
    }
    assertThat(members.size()).isEqualTo(25);
}

 

테스트 코드를 실행하면 fetch join 과 동일하게 조인 쿼리 하나만 발생되는 것을 확인할 수 있다.

 

 

@EntityGraph는 outer join을 사용한다는 점을 주의해야 한다.

 

ROW 중복 문제

fetch join과 @EntityGraph는 1:N 관계의 테이블에서 조인을 사용하기 때문에 중복된 ROW가 발생한다(카테시안 곱 발생!).

이를 해결하기 위해서는 1:N 필드 타입을 SET으로 선언하거나 쿼리에 distinct를 사용해야 한다.

 

Batch Size 조절

@BatchSize를 사용해 데이터를 미리 로딩할 수 있다(where in을 사용한다). 하지만 EAGER 페치 전략을 사용해야 한다는 단점이 있다.

 

@BatchSize(size = 10)
@OneToMany(mappedBy = "member", cascade = CascadeType.PERSIST, orphanRemoval = true, fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<>();

 

 

 

정리

EAGER 전략을 사용할 경우 @BatchSize를 선택하고, LAZY 전략을 사용할 경우 fetch join을 사용해 성능적인 장점을 가져가자!

 

 

참고

'개발 > JPA' 카테고리의 다른 글

[JPA] 값 타입  (0) 2022.01.08
[JPA] 프록시와 연관관계 정리  (0) 2022.01.08
[JPA] 고급 매핑  (0) 2021.12.21
[JPA] 다양한 연관관계 매핑  (0) 2021.12.14
[JPA] 연관관계 매핑 기초  (0) 2021.12.14

댓글