본문 바로가기
spring & boot/JPA

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

by lucas_owner 2023. 2. 21.

 

JPA N+1 문제

이번에 볼 문제는 JPA를 사용한다면 한번쯤은 들어봤고, 자주 봤을것이다. 

JPA N+1 문제에 대해서 알아보고 해결법에 대해서 알아보자. 

 

- N+1 문제란?

1번의 쿼리를 실행 했을 때, N번의 쿼리가 추가적으로 실행되는것을 뜻합니다. 

 

예를들어 member를 조회 했을때 연관관계를 맺고있는 데이터를 조회 하기 위해 N번의 조회 쿼리가 실행된다고 생각하면됩니다.

일반적인 쿼리라면 join을 사용한다면, 한번의 쿼리로 데이터를 조회 할 수 있지만. 

JPA에서는 member를 조회하고, member가 참조하고 있는 테이블의 연관되어있는 data를 data의 갯수만큼 조회 하기 때문입니다. 

 

JPA의 경우 즉시로딩(fetchType_EAGER), 지연로딩(fetchType_LAZY)와 같은 '글로벌 페치 전략' 을 통해서 연관된 data를 '사용'

하는 시점에 조회 할 수 있는 기능이 존재합니다.(지연로딩)

해당 전략에 대해서는 자세하게 다루지 않겠습니다. 

 

하지만 이마저도 N+1 문제를 해결해줄수는 없습니다.

결국 사용 시점에 N번의 쿼리가 실행되는건 마찬가지 이기 때문입니다. 

따라서 N+1 문제를 해결하기 위해서는 크게 2가지 방법이 존재합니다. 

 

여러 조회 예제와 함께 해결 방법에 대해서 보겠습니다.


목차

  1. Entity 관계
  2. 여러가지 조회 상황 - 즉시로딩
  3. 지연 로딩 전략 사용
  4. 해결방법(1) - fetch join
  5. 해결방법(2) - @EntityGraph
  6. 카테시안 곱 - 데이터중복 문제 해결

 

1. 예제 - Entity 관계 

- N_member는 member, N_orders는 orders 로 정의하고 말하겠다.

 

- member는 orders와 1:N의 관계를 맺고 있다. (하나의 member는 여러개의 orders를 가질수 있다는 의미) 

* 아래의 예제는 Spring Data JPA가 아닙니다. 

 

- member Entity

member Entity

- orders Entity

- 2개의 Entity가 연관관계를 맺고 있고, 수동으로 Getter/Setter를 생성해 주겠습니다.

 

- Data 

- member의 Data는 3개가 들어있고, 각 3건씩의 orders data를 갖고 있습니다.(FK) 

 

 

2. 여러가지 조회 상황 - 즉시로딩 (fetchType.EAGER)

- 우선 즉시로딩으로 설정 되어 있는 경우 조회를 해보겠습니다. 

- 즉시로딩의 경우 사용을 지양해야 합니다. 모든 데이터가 필요하다면, @Query 를 사용하거나, 다른 전략을 추천합니다.

(의도치 쿼리 발생 및 예측이 어려움)

 

2-1. em.find() - 사용시 

- Entity Manger 에서 제공하는 find() 메서드를 사용하면 어떤 쿼리가 발생하는지 확인해보죠.

em.find()

- 조회 쿼리는 join을 사용해서 data를 한번에 가져온 것을 확인 할 수 있습니다. 


 

2-2. JPQL 사용 - 문제상황

- JPQL을 사용해서 membe의 결과를 확인 해보겠습니다.

JPQL select

- 1번의 member 조회 이후, orders 객체는 3번의 조회 쿼리가 실행됬습니다.

 

- JPQL을 사용한다면 JPA 가 분석하여 SQL 쿼리를 생성합니다. 이때 '글로벌 페치 전략'은 신경쓰지 않게 됩니다. 

따라서 JPA는 아래의 쿼리를 차례대로 생성, 실행합니다.

 

- member 를 조회 후, 즉시로딩이 설정되어있는것을 확인 후 orders를 조회함.

# member 전체 조회
SELECT * FROM N_MEMBER;

# orders 조회 - 즉시로딩
SELECT * FROM N_ORDERS WHERE MEMBER_ID = 1;
SELECT * FROM N_ORDERS WHERE MEMBER_ID = 5;
SELECT * FROM N_ORDERS WHERE MEMBER_ID = 9;

- 만약 참조되어있는 데이터가 1건이라면 orders 쿼리 또한 1건이 발생하지만. 

- 위의 예제처럼 N건이라면, N건 만큼의 쿼리가 실행되는것입니다. -> 이게 N+1 문제입니다.

 

 

3. 지연로딩 (LAZY) 전략을 사용한다면?

- 지금 예제는 모두 즉시로딩(EAGER) 전략을 사용 했습니다. 만약 지연로딩(LAZY) 전략을 사용한다면 해결될까?

 

- 답은 해결할 수 없다 입니다.

 

- 지연로딩의 경우 사용시점에 조회를 하는것인데, 조회 시점이 다르다의 차이점이 존재할 뿐 입니다. 

 

- 예를 들어보겠습니다. 

1. member List 데이터를 조회 후, 1개의 member객체를 선택 후 orders 를 사용하는 시점에는 쿼리가 

(SELECT * FROM ORDERS WHERE MEMBER_ID = {1개의 멤버객체})

가 실행 됩니다. (여기까지는 문제 없음)

 

2. 문제상황은 for문을 사용하여 모든 member에 대해 연관된 orders 객체를 사용할 때 발생합니다. 

for(Member member: resultList) {
	System.out.println("result: " + member.getOrders.getId());
}

 

- 해당 코드를 실행 한다면 결국 member의 모든 객체를 순회하며 조회 하기 때문에 아래와 같은 쿼리가 실행됩니다.

SELECT * FROM N_ORDERS WHERE MEMBER_ID = 1;
SELECT * FROM N_ORDERS WHERE MEMBER_ID = 5;
SELECT * FROM N_ORDERS WHERE MEMBER_ID = 9;

- 이것 또한 N+1 문제입니다. 

 

 

4. 해결방법(1) - fetch join 

4-1. 해결방법

- N+1 문제의 가장 일반적인 해결 방법은 fetch join 입니다. 

JPQL 사용시의 N+1 문제의 코드와 동일 합니다. 다만 변경된 부분은 join fetch 부분입니다. 

 

- 쿼리를 보면 똑같은 문제상황임에도, 1번의 쿼리가 실행이 되었고, 결과 또한 잘 나왔습니다.

- 다만 fetch join의 경우 중복된 결과를 리턴 할 수 있습니다. 

--> 카테시안 곱(Cartesian Product)이 발생 할 수 있기 때문입니다.(join 쿼리 시 문제)

---> (orders의 갯수만큼 member가 조회 되는것) 

 

* 해당 해결방법은 맨 마지막에 기술 하겠습니다.


 

4-2. (추가) Spring Data JPA 사용시 

- Spring Data JPA 를 사용하면 어떻게 fetch join을 사용해야 하는지 의문이 있을 수 있습니다만, 

결과적으론 같습니다. 

 

왜냐하면 Spring Data JPA 에서도 @Query 를 사용하여 해결하기 때문입니다.

// join fetch - Spring Data Jpa
@Query("select m from member m join fetch m.orders")
List<Member> findAllJoinFetch();

 

 

5. 해결방법(2) - Entity Graph

- 2번째 해결방법은 @EntityGraph를 이용하는 것 입니다. 

// Spring Data JPA
public interface MemberRepository extends JpaRepository<Member, Long>{
	
    //findAll
    @EntityGraph(attributePaths = {"orders"})
    @Query("select m from Member m")
    List<Member> findAllGraph();
}

- 엔티티 그래프를 사용하여 조회 하게 되면, fetch join 과 같은 결과를 얻습니다. 

 

* 다만 fetch join은 inner join을 사용하지만, 엔티티그래프의 경우 outer join 을 사용합니다.

 

 

6. 카테시안 곱 문제 해결 - 데이터 중복 문제

- N+1 문제는 해결했지만, 데이터중복 문제가 아직 남아있다. (카테시안 곱) 해결 방법을 알아보자. 

 

 

6-1. distinct 사용

- query 를 정의할 때 distinct 를 포함하여 중복을 제거하는 방법이다. 

- 필자는 해당 방법을 선호하는데, query 생성시 fetch join, EntityGraph 를 정의할때 키워드만 같이 사용하면 되기에 편리하기 때문이다. 

// Spring Data JPA 예제

@EntityGraph(attributePaths = {"orders"})
@Query("select DISTINCT m from Member m")
List<Member> findAllGraph();

// JPQL
List<N_member> resultList =
                    em.createQuery("select DISTINCT m from N_member m join fetch m.orders"
                            , N_member.class)
                            .getResultList();

 

6-2. LinkedHashSet 사용

- Entity의 OneToMany 관계를 가진곳의 타입을 Set으로 바꾸는것이다. 

Java의 set은 중복을 허용하지 않기 때문에, 중복된 데이터가 들어갈 일이 없는것이다. 

 

* 다만 set의 특징상 순서가 보장되지않기 때문에 LinkedHashSet을 통해, 중복제거 + 순서보장을 해주는것이 좋다.

// Member Entity

@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
@JoinColumn(name = "id")
private Set<Orders> orders = new LinkedHashSet<>();

 

 

 

 

반응형

댓글