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 최적화가 어려울 수 있습니다.