💡 개요
오늘은 JPA에서 종종 발생하는 N+1 문제에 대해 정리해 보자.
📕 N+1 문제
N+1 문제는 연관관계가 매핑된 엔티티를 조회할 때, 예상보다 많은 쿼리가 실행되는 비효율적인 상황을 말한다.
이는 주로 ORM 기술을 사용할 때, 연관된 데이터를 지연 로딩(LAZY) 방식으로 가져오면서 발생한다.
한 명의 사용자가 여러 개의 주문을 한 상황을 예로 들자.
@Entity
class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
...
@OneToMany(mappedby="member")
private List<Order> orders = new ArrayList<>();
...
}
@Entity
class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
...
}
List<Member> members = memberRepository.findAll();
for (Member member : members) {
System.out.println("회원: " + member.getName());
for (Order order : member.getOrders()) {
System.out.println(" 주문: " + order.getItemName());
}
}
만약 회원 정보가 10개 저장되어 있다면, 쿼리는 다음과 같이 실행될 것이다.
-- 회원 정보 전체 조회
SELECT * FROM member;
-- 조회된 회원 정보 각각에 매핑된 주문 정보 조회
SELECT * FROM orders WHERE member_id = 1;
SELECT * FROM orders WHERE member_id = 2;
SELECT * FROM orders WHERE member_id = 3;
SELECT * FROM orders WHERE member_id = 4;
SELECT * FROM orders WHERE member_id = 5;
SELECT * FROM orders WHERE member_id = 6;
SELECT * FROM orders WHERE member_id = 7;
SELECT * FROM orders WHERE member_id = 8;
SELECT * FROM orders WHERE member_id = 9;
SELECT * FROM orders WHERE member_id = 10;
위 쿼리대로라면 데이터베이스에 총 11번 접근한다.
만약 회원 데이터가 1000개라면, 쿼리가 총 1001번 실행된다.
이렇게 데이터베이스에 접근하는 과정은 많은 지연시간을 발생시킨다.
어떻게 보면 ORM의 특징이자 장점은 엔티티 객체와 테이블을 직접 매핑하여 따로 쿼리문을 작성하지 않아도 데이터를 관리할 수 있는 것이다.
하지만 이 과정에서 쿼리 생성을 담당하는 Hibernate는 복잡한 조인 쿼리나 최적화된 조회 방식은 자동으로 적용되지 않는다.
그렇기 때문에 조인을 포함한 쿼리를 생성하기 위해선 개발자가 따로 설정해줘야 한다.
🛠️ Fetch Join
Fetch Join을 사용하면 연관관계가 매핑된 엔티티를 한 번에 조회하기 때문에 전달되는 쿼리의 수가 상당히 줄어든다.
@Query("SELECT m FROM Member m JOIN FETCH m.orders")
List<Member> findAllWithOrders();
위 findAllWithOrders() 메서드를 실행하면 다음과 같은 쿼리가 생성된다.
SELECT
m.id AS m_id,
m.name AS m_name,
o.id AS o_id,
o.item_name AS o_item_name,
o.member_id AS o_member_id
FROM
member m
JOIN
orders o ON m.id = o.member_id
결과적으로 쿼리는 한 번만 나가고, 모든 데이터가 한 번에 조회된다.
🛠️ Batch Size
BatchSize는 Hibernate에게 연관관계가 매핑되어 있는 데이터를 설정해 놓은 수만큼 한 번에 조회할 때 사용한다.
N+1 문제가 발생하는 것은 지연로딩으로 설정된 엔티티를 하나하나 조회해서 발생한다.
하지만 @BatchSize(size = N)를 설정하면, Hibernate는 여러 엔티티를 한꺼번에 조회할 수 있도록 최적화된 쿼리를 사용한다.
즉, 연관된 엔티티를 N개 단위로 IN 쿼리를 만들어 한 번에 조회하게 된다.
Member 엔티티 입장에서 Order 엔티티를 조회할 때 Batch Size를 사용하는 방법은 다음과 같다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "member")
@BatchSize(size = 10)
private List<Order> orders = new ArrayList<>();
}
SELECT * FROM member;
SELECT * FROM orders WHERE member_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
🛠️ EntityGraph
EntityGraph는 특정 엔티티를 조회할 때 지연 로딩으로 설정된 연관 엔티티를 즉시 로딩(FETCH) 하도록 지시할 수 있다.
이 설명만 보면 위에서 언급한 Fetch Join과 동작 방식이 비슷하다고 느껴질 수 있는데, 실제로도 유사하게 동작한다.
하지만 Fetch Join 방식과 다른 점은 Fetch Join은 JPQL이나 네이티브 쿼리를 직접 작성해야 하지만 EntityGraph는 실제 쿼리를 작성할 필요가 없다.
Entity Graph 사용 방법은 @EntityGraph(attributePaths = {"조회할 엔티티"}) 을 JPA 메서드 위에 붙인다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders;
}
public interface MemberRepository extends JpaRepository<Member, Long> {
@EntityGraph(attributePaths = {"orders"})
List<Member> findAll(); // orders를 함께 조회 (즉시 로딩)
}
SELECT m.*, o.*
FROM member m
LEFT OUTER JOIN orders o ON m.id = o.member_id
생성되는 쿼리를 보면 알 수 있듯이, Fetch Join과 매우 유사하다.
'개발 일기' 카테고리의 다른 글
[개발 일기] 2025.04.07 - Offset (0) | 2025.04.07 |
---|---|
[개발 일기] 2025.04.06 - 즉시로딩을 피해야 하는 이유 (1) | 2025.04.06 |
[개발 일기] 2025.04.04 - isBlank() vs isEmpty() (0) | 2025.04.04 |
[개발 일기] 2025.04.03 - @DataJpaTest vs @SpringBootTest (0) | 2025.04.03 |
[개발 일기] 2025.04.02 - Spring Boot 서버가 작동될 때 발생되는 일 (0) | 2025.04.02 |