[JPA] N+1 문제
JPA 스터디를 진행하며 작성한 글입니다.
N+1 쿼리가 발생하는 이유
JPA를 사용하면 자주 만나게 되는 것이 N+1 문제이다. 그럼 N+1 문제는 언제 발생할까?
N+1 문제가 발생하는 경우는 다음과 같은 2가지 경우가 있다.
두 개의 엔티티가 1:N 관계를 가지며 JPQL로 객체를 조회할 때
- EAGER 전략을 사용한 경우
- 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 |
댓글