JPA N+1 문제

@Entity
public class Item {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.EAGER)  // 기본값
    private Order order;
}

✅ EAGER인 경우 실행되는 쿼리

  1. Item 엔티티 조회:

SELECT * FROM item WHERE id = 1;
  1. 연관된 Order도 즉시 조회:

SELECT * FROM orders WHERE id = ?;  -- item.order_id 에 해당하는 값

2. N+1 문제

List<Item> items = itemRepository.findAll();  // Item 100개

이럴 경우,

  • Item 리스트 조회 1번

  • 각 Item에 대해 Order 조회 100번 → 총 101번 쿼리 발생 (바로 이게 N+1 문제)

즉,

"Item만 조회한 건데, 각각에 딸린 Order를 100번 쿼리 날려서 가져오는 것"이 @ManyToOne에서의 N+1 문제

✅ 1. @OneToMany 관계란?

예시:

@Entity
public class Order {
    @Id
    private Long id;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> orderItems = new ArrayList<>();
}
  • 하나의 Order는 여러 개의 OrderItem을 가질 수 있는 관계

  • mappedBy = "order"OrderItem 쪽에 외래 키(@ManyToOne)가 있다는 뜻

  • fetch = FetchType.LAZY는 이 리스트를 즉시 로딩하지 않고, 실제로 접근할 때 DB에서 SELECT 쿼리를 날리겠다는 의미


✅ 2. @OneToMany는 기본이 LAZY, 그 이유는?

💥 EAGER로 하면?

만약 @OneToMany(fetch = FetchType.EAGER)를 쓴다면:

Order order = orderRepository.findById(1L).get();

이 쿼리 하나만으로 끝나지 않고, 내부적으로 아래처럼 조인 쿼리가 자동 실행됩니다:

SELECT * FROM orders o
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.id = 1;

문제점:

  • 항상 조인이 발생 → 성능 저하

  • 조인 결과가 많아지면 → 데이터 중복 (N개 OrderItem이 있을 경우 Order 데이터도 N번 반복됨)

  • 컬렉션의 경우 EAGER는 Hibernate에서도 비추하는 전략

그래서 JPA는 기본값을 LAZY로 설정해둔 것.


✅ 3. LAZY 동작 방식

Order order = orderRepository.findById(1L).get();  // 여기서는 orderItems는 아직 조회 안 됨
order.getOrderItems();  // 이 시점에 쿼리 날림
  • 이때 orderItems 필드는 Hibernate의 PersistentBag, ListProxy 같은 프록시 객체로 초기화되어 있음

  • 이 프록시는 .get()이나 .size() 같은 메서드가 호출될 때 진짜 DB 쿼리를 실행


✅ 4. Lazy 로딩의 문제점

❗ LazyInitializationException

@Transactional
public Order getOrder(Long id) {
    return orderRepository.findById(id).get();  // orderItems는 아직 로딩 안 됨
}

// 트랜잭션 밖에서 호출
Order order = service.getOrder(1L);
order.getOrderItems().size();  // ❌ 여기서 LazyInitializationException 발생
  • 트랜잭션이 종료된 후 프록시가 실제 데이터를 로딩하려 하면 실패 → LazyInitializationException


✅ 5. 해결 방법

① Fetch Join

@Query("SELECT o FROM Order o JOIN FETCH o.orderItems WHERE o.id = :id")
Order findByIdWithItems(@Param("id") Long id);
  • 한 번에 필요한 엔티티 + 연관 엔티티를 함께 조회

  • 단점: 페이징 불가능 (@OneToMany 컬렉션 조인은 LIMIT과 호환되지 않음)

이렇게 하면:

SELECT i.*, o.* FROM item i
JOIN order o ON i.order_id = o.id

Item과 Order를 한 번의 쿼리로 모두 가져옴 (Join + 즉시 조회) ✔ Hibernate가 order 필드에 대해 추가 쿼리 없이 자동으로 채워줌


② DTO 직접 조회

@Query("SELECT new com.example.dto.OrderDto(o.id, i.name, i.price) " +
       "FROM Order o JOIN o.orderItems i WHERE o.id = :id")
List<OrderDto> findOrderDetails(@Param("id") Long id);
  • 엔티티 대신 필요한 필드만 조회하여 DTO로 매핑

  • 페이징 가능, 성능 최적화에 가장 유리


③ Hibernate 배치 사이즈 설정

spring.jpa.properties.hibernate.default_batch_fetch_size=100
  • 여러 개의 지연 로딩 컬렉션을 한 번에 모아서 쿼리 날림

  • N+1 → 1+1 쿼리로 최적화 가능

Last updated