JPA를 쓰면 왜 갑지가 쿼리가 많이 나갈까?
Spring Data JPA 의 메서드쿼리(Query Methods) 기능을 활용하다 보면 구현의 생산성을 극대화할 수 있다
하지만 이러한 편리함에 매몽되어 내부 동작 권리를 간과할 경우 런타임 시점의 쿼리 실행 계획을 예측하지 못해 성능저하를 초래할 수 있다.
N+1란?
데이터 1건을 조회하기 위해 쿼리(1)를 보내는데, 연관된 데이터를 가지고오기 위해 추가 쿼리(N)가 발생하여 총 N+1개의 쿼리가 발행하는 것을 말한다.
- 상황 : 10개의 order를 조회한다
- 기대 : 쿼리 한번으로 10개의 order정보를 가지고 온다
- 실제 :
- order 전체 조회 쿼리 (1번)
- order가 가지고 있는 menu들의 정보를 조회하는 쿼리 (10번)
- 결과 : 총 11번의 쿼리가 실행됨. 데이터가 많아질수록 서버 응답 속도 느려짐
왜 N+1이 발생하는가?
JPQL의 동작 방식
JPA에서 findAll()을 호출하면 JPQL이라는 객체 지향 쿼리가 생성된다. JPQL은 연관 관계를 무시하고 오직 해당 엔티티 만을 대상으로 SQL을 생성한다.
// JPQL
SELECT O FROM Order O;
// 실제 SQL
SELECT * FROM order
// Menu는 포함되지 않음
JPQL은 SQL을 추상화한 객체 지향 쿼리 언어로 엔티티 객체를 대상으로 쿼리를 작성한다.
테이블이 아닌 엔티티를 중심으로 동작하기 때문에, 연관 관계에 있는 엔디디는 명시적으로 JOIN FETCH를 사용하지 않는 한 함께 조회되지 않는다.
지연로딩 (Lazy Loding)과 프록시(Proxy)
JPA의 기본 Fetch전략
- @OneToOne, @ManyToOne: EAGER (즉시 로딩)
- @OneToMany, @ManyToMany: LAZY (지연 로딩)
지연로딩 전략에서는 연관된 엔티티를 실제로 사용하는 시점에 DB에서 조회한다. JPA는 처음에 연관 엔티티 대신 프록시 객체를 생성하여 참조를 유지하고, 실제로 해당 데이터레 접근할 때 프록시가 초기화되면서 DB쿼리가 발생한다.
Order order = orderRepository.findById(1L).get();
// Order를 조회
List<Menu> menus = order.getMenus();
// 프록시 객체
menus.get(0).getName();
// 실제 db접근 SELECT * FROM menu WHERE order_id = 1;
이 때문에 반복문에서 각 order의 menu를 조회할 때마다 개별 쿼리가 발생하여 N+1 문제가 발생한다.
N+1 문제 해결방법 3가지
패치조인
JOIN FETCH를 사용하면 엔티티를 한꺼번에 조회한다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o from Order o join fetch o.menus")
List<Order> findAllWithFetchJoin();
}
- 결과 쿼리 : SELECT o.*, m.* FROM orders o INNER JOIN menu m ON o.id = m.order_id;
- 1번의 쿼리 실행
@ EntityGraph (Spring Data JPA)
Spring Data JPA에서 제공하는 어노테이션으로, 패치 조인을 간편하게 사용할 수 있다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Override
@EntityGraph(attributePaths = {"menus"}) // 같이 가져올 연관관계 명시
List<Order> findAll();
}
Batch Size
페이징 처리가 필요하거나 컬렉션이 많을 때 효율적인 대안이다.
yml에서 글로벌로 설정가능
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
// 엔티티별 Batch Size 설정
@BatchSize(size = 100)
@OneToMany(mappedBy = "order")
private List<Menu> menus = new ArrayList<>();
}
// application.yml에서 글로벌 설정
/*
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
*/
- findAll() 시 order 먼저 100개 조회
- 루프 돌며 order.getMenus()를 호출 시 딱 한번 IN절을 사용하여 연관된 메뉴 100개 분량을 한꺼번에 가지고 옴
- 실행 쿼리 : SELECT * FROM menu WHERE order_id IN (1, 2, 3, ..., 100);
OneToMany 패치 조인의 함정
컬렉션 패치 조인의 한계
1. 페이징 불가능 문제
@Query("SELECT o FROM Order o JOIN FETCH o.menus")
Page<Order> findAllWithMenus(Pageable pageable);
일대다 조인으로 인해 row수가 증가하여 JPA가 정확한 페이징을 알 수 없기 때문
해결방법
- batch size 사용
- 다대일 방향으로 조회
- 별도 조회 후 Map으로 조합
2. 중복 데이터 문제
// DISTINCT 사용 (JPQL)
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.menus")
List<Order> findAllWithMenus();
3. 둘 이상의 컬렉션 패치조인 불가
// ❌ MultipleBagFetchException 발생
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.menus " +
"JOIN FETCH o.payments")
List<Order> findAll();
해결방법
- 하나만 fetch join, 나머지는 batch size
- 별도 쿼리로 분리
성능 최적화는 '예측 가능성'에서 시작된다
JPA는 많은 것을 자동으로 해주지만, 그 자동화 뒤에 숨은 SQL을 예측할 수 없게 되는 순간 성능의 재앙이 된다
내가 작성한 코드 한 줄이 어떻게 DB에 영향을 줄 수 있는지 정확하게 알고 작성해야 한다.
'Spring Boot' 카테고리의 다른 글
| [Spring Boot] 연관관계 매핑 (@ManyToOne, @OneToMany, @ManyToMany, @OneToOne) (0) | 2024.06.28 |
|---|---|
| [Spring Boot] QueryDSL이란? (1) | 2024.05.22 |
| [Spring Boot] file업로드 (0) | 2024.05.21 |
| [Spring Boot] @RestControllerAdvice를 이용한 Spring 예외처리 (0) | 2024.05.21 |
| [Spring Boot] @Component 어노테이션 (0) | 2024.05.20 |