들어가기 전에
엔티티를 작성할 때 연관된 다른 테이블을 필드에 작성하는 경우가 있다. 한 테이블이 외래키를 갖는 경우 @ManyToOne을 통해 매핑하게 되는데, 여기서 우리는 FetchType을 설정하게 된다. 로딩 방식에는 Lazy Loading과 Eager Loading이 있다. 이 둘에 대해 알아보고 관련해서 나타나는 N+1 문제를 살펴보자.
Lazy Loading & Eager Loading
Lazy Loading
Lazy Loading은 지연 로딩이라고 하며, 연관된 엔티티를 실제로 접근할 때 불러오는 방식이다. 데이터베이스에서 엔티티를 가져올 때 로드하지 않고 해당 필드에 처음 접근할 때 쿼리가 발생한다. 초기 로드 시 필요한 데이터만 가져오므로 메모리 사용량이 줄어드는 이점이 있다. 하지만 이후에 연관된 엔티티에 접근할 때 추가적인 쿼리가 발생해 성능 저하를 일으킬 수 있으며, N+1 문제가 발생할 가능성이 높다.
Eager Loading
Eager Loading은 즉시 로딩이라고 하며, 한번의 쿼리로 데이터베이스에서 엔티티를 가져올 때 연관된 데이터까지 함께 가져오는 방식이다. 추가적인 쿼리 없이 필요한 데이터를 한번에 로드할 수 있어 쿼리 수를 줄일 수 있다는 장점이 있다. 하지만 불필요한 데이터를 미리 로드하는 경우가 있기 때문에 데이터 양이 많은 경우 메모리 사용량이 증가할 수 있다는 문제가 있다.
N+1 문제?
N+1 문제는 하나의 쿼리로 N개의 데이터를 조회한 후, 각 데이터와 연관된 데이터를 조회하기 위해 N개의 추가 쿼리가 발생하는 현상이다. 이로 인해 불필요한 쿼리가 다수 실행되어 성능 저하가 일어나게 된다. 로딩 방식에 따라 N+1 문제가 발생하는 예시를 함께 보자.
각 로딩에서의 N+1 문제
Lazy Loading에서의 N+1 문제
Member와 Order가 1:N 관계를 가지고 있는 상황이다.
// Member Entity
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders;
}
// Order Entity
@Entity
public class Order {
@Id
@GeneratedValue
private Long id;
private String productName;
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
}
* 참고로 @OneToMany의 기본 로딩 방식은 Lazy Loading이나 명시적으로 작성하였다.
MemberService
// 클래스명, 메소드명 생략
// MemberService
List<Member> members = memberRepository.findAll();
for (Member member : members) {
List<Order> orders = member.getOrders();
for (Order order : orders) {
System.out.println(order.getProductName());
}
}
메소드 내에서 처음 findAll()를 호출할 때 SELECT * FROM Member 쿼리가 실행된다. 이후, 각 회원의 orders에 접근하면 N번의 추가 쿼리가 발생하게 된다. Console를 보면 다음과 같은 쿼리가 출력된다.
SELECT * FROM Order WHERE member_id = ?
만약 회원이 10명 있는 경우, Member를 가져오는 쿼리 1번과 Order를 조회하는 쿼리 10번이 추가로 실행되어 N+1 문제가 발생하게 된다.
Eager Loading에서의 N+1 문제
// Member Entity
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private List<Order> orders;
}
// Order Entity
@Entity
public class Order {
@Id
@GeneratedValue
private Long id;
private String productName;
@ManyToOne(fetch = FetchType.EAGER)
private Member member;
}
* 참고로 @ManyToOne의 기본 로딩 방식은 Eager Loading이나 명시적으로 작성하였다.
MemberService
// 클래스명, 메소드명 생략
// MemberService
List<Member> members = memberRepository.findAll();
for (Member member : members) {
List<Order> orders = member.getOrders();
for (Order order : orders) {
System.out.println(order.getProductName());
}
}
Eager Loading은 처음부터 연관된 데이터를 즉시 불러오므로, Member를 전체 조회하는 SELECT * FROM Member 쿼리 외에도 각 Member과 연관된 Order 데이터를 모두 가져온다. 작성된 메소드를 보면 Member를 조회할 때 Order까지 즉시 로딩하면서 각 회원에 대해 개별 쿼리가 실행될 수 있다. Lazy Loading과 유사하게 Member를 조회하는 쿼리 1개와 Order를 조회하는 쿼리 여러 개가 실행될 수 있다.
N+1 문제를 어떻게 해결할까?
의도하지 않은 쿼리가 추가로 발생하게 된다면 성능 상에 문제가 발생하게 될 것이다. N+1을 해결하는 데에는 크게 3가지가 있다.
N+1 문제 해결 방법
JOIN FETCH 사용
명시적으로 JOIN FETCH를 사용해 연관 데이터를 한 번의 쿼리로 가져오도록 작성한다. 이 경우 다른 테이블와 연관되어 있는 엔티티에 대해 JOIN FETCH로 연결해줘야 한다.
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("SELECT m FROM Member m JOIN FETCH m.orders")
List<Member> findAllWithOrders();
}
@EntityGraph 사용
JPA의 EntityGraph를 이용하여 필요한 데이터만 미리 로딩하는 방법이다.
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
@EntityGraph(attributePaths = {"orders"})
List<Member> findAll();
}
Batch Size 설정
hibernate.default_batch_fetch_size 속성을 설정해 연관된 엔티티를 배치로 로딩하여 N+1 문제를 완화할 수 있다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
'Back-End > Spring Boot' 카테고리의 다른 글
[Spring Boot] Spring AOP (0) | 2024.10.10 |
---|---|
[Spring Boot] 스크래핑 비동기 처리 (6) | 2024.10.09 |
[Spring Boot] JPA 쿼리 최적화 (1) | 2024.10.04 |
[Spring Boot] Setter vs Builder (0) | 2024.03.28 |
[Spring Boot] Logging 처리 (0) | 2023.11.27 |