JPA Collection 조회 최적화 1 - Fetch Join, 페이징, 지연 로딩


  • API 개발 고급 - collection 조회 최적화 - 엔티티를 DTO로 변환
    collection인 일대다 관계(OneToMany)를 조회하고, 최적화하는 방법

fetch join 최적화

Order.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Entity
@Table(name = "orders")
class Order(
@Id
@GeneratedValue
@Column(name = "order_id")
var id: Long? = null,

var orderDate: LocalDateTime? = null, //주문시간

@Enumerated(EnumType.STRING)
var status: OrderStatus? = null, //주문상태 [ORDER, CANCEL]
) {
@OneToMany(mappedBy = "order", cascade = [CascadeType.ALL])
var orderItems: MutableList<OrderItem> = ArrayList()
}
OrderApiController.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RestController
class OrderApiController(
@Autowired private val orderRepository: OrderRepository,
@Autowired private val orderQueryRepository: OrderQueryRepository,
) {
@GetMapping("/api/v3/orders")
fun ordersV3(): List<OrderDto> {
val orders: List<Order> = orderRepository.findAllWithItem()
return orders.stream()
.map{ it.toDto() }
.collect(Collectors.toList())
}

data class OrderDto(
val orderId: Long?,
val name: String?,
val orderDate: LocalDateTime?,
val orderStatus: OrderStatus?,
val address: Delivery?,
val orderItems: List<OrderItemDto>? = null,
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Repository
class OrderRepository(
@PersistenceContext private val em: EntityManager
) {
fun findAllWithItem(): List<Order> {
return em.createQuery(
"select distinct o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order::class.java
).resultList
}
}

fetch join 과 SQL의 효율성

fetch join은 SQL 쿼리가 한 번만 실행되도록 하여 성능을 개선하는 기법이다. 이 방법은 주로 1대다 관계(@oneToMany)의 조인에서 유용하게 사용된다. 그러나 이 방식을 사용하면 데이터베이스 row가 증가하는 결과, 같은 엔티티의 조회 수도 증가하게 된다.
이러한 문제를 해결하기 위해 JPA의 distinct를 사용한다. distinct는 SQL에 distinct를 추가하고, 더 나아가 같은 엔티티가 조회될 경우 애플리케이션에서 중복을 걸러주는 역할을 한다. 이는 collection fetch join으로 인해 중복 조회가 발생하는 경우를 방지해준다.

fetch join의 단점

그러나 fetch join에도 주의해야 할 점이 있다. 가장 중요한 것은 페이징이 불가능하다는 것이다. collection fetch join을 사용하게 되면, 하이버네이트는 경고 로그를 남기면서 모든 데이터를 데이터베이스에서 읽어온 후 메모리에서 페이징을 진행한다. 이는 매우 위험한 상황을 초래할 수 있다.
또한 collection fecth join은 한 번에 하나의 collection에만 사용해야 한다. 여러 collection에 fetch join을 사용하면 데이터가 부정합하게 조회될 수 있다.

fetch join은 SQL 쿼리의 효율성을 크게 높일 수 있는 강력한 도구이다. 그러나 페이징 불가능, 한 번에 하나의 collection에만 사용 가능 등의 제약사항을 반드시 이해하고 사용해야 한다. 이러한 주의사항을 유념하며, fetch join을 통해 효율적인 SQL 쿼리를 작성하는 방법을 사용 해야 한다.

페이징 한계 돌파

OrderRepository.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Repository
class OrderRepository(
@PersistenceContext private val em: EntityManager
) {
fun findAllWithMemberDelivery(offset: Int, limit: Int): List<Order> {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order::class.java
).setFirstResult(offset)
.setMaxResults(limit)
.resultList
}
}
OrderApiController.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@RestController
class OrderApiController(
@Autowired private val orderRepository: OrderRepository,
@Autowired private val orderQueryRepository: OrderQueryRepository,
) {
@GetMapping("/api/v3.1/orders")
fun ordersV3_page(
@RequestParam(value = "offset", defaultValue = "0") offset: Int,
@RequestParam(value = "limit", defaultValue = "100") limit: Int
): List<OrderDto> {
val orders: List<Order> = orderRepository.findAllWithMemberDelivery(offset, limit)
return orders.stream()
.map{ it.toDto() }
.collect(Collectors.toList())
}

data class OrderDto(
val orderId: Long?,
val name: String?,
val orderDate: LocalDateTime?,
val orderStatus: OrderStatus?,
val address: Delivery?,
val orderItems: List<OrderItemDto>? = null,
)

data class OrderItemDto(
val itemName: String?, //상품 명
val orderPrice: Int, //주문 가격
val count: Int, //주문 수량
)

}
application.yml
1
2
3
4
5
spring: 
jpa:
properties:
hibernate:
default_batch_fetch_size: 500

페이징과 collection fetch join의 문제점

collection을 fetch join하면 일대다 조인(OneToMany)이 발생하기 때문에 데이터가 예측할 수 없이 증가한다. 이는 페이징 처리에 큰 장애물이 된다. 이 때문에 하이버네이트는 경고 로그를 남기며 모든 데이터를 데이터베이스에서 읽어 메모리에서 페이징을 시도한다. 이런 상황은 최악의 경우 시스템 장애를 일으킬 수 있다.
(Order와 OrderItem을 조인하면 Order가 OrderItem 만큼 중복해서 조회된다. SQL Join 쿼리 실행)

한계 돌파 방안

그렇다면 페이징과 collection 엔티티를 함께 조회하려면 어떻게 해야 할까? 다음은 이 문제를 해결하는 강력한 방법을 제시한다.

  1. ToOne(OneToOne, ManyToOne) 관계는 모두 fetch join 한다. ToOne 관계는 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
  2. collection은 지연(Lazy) 로딩으로 조회한다.
  3. 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용한다. 이 옵션을 사용하면 collection이나 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회한다.
    • hibernate.default_batch_fetch_size: 글로벌 설정
    • @BatchSize: 개별 최적화

장점

이 방법을 사용하면 쿼리 호출 수가 1+N에서 1+1로 최적화된다. 또한, 조인보다 데이터베이스 데이터 전송량이 최적화된다. 이는 각각 조회하므로 전송해야 할 중복 데이터가 없기 때문이다. 또한 collection fetch join이 페이징이 불가능한 것과 대조적으로 이 방법은 페이징이 가능하다.

결론

ToOne 관계는 fetch join해도 페이징에 영향을 주지 않는다. 따라서 ToOne 관계는 fetch join으로 쿼리 수를 줄이고,
나머지는 hibernate.default_batch_fetch_size로 최적화하는 것이 바람직하다. default_batch_fetch_size의 크기는 적당한 사이즈를 골라야 한다. 대체로 100~1000 사이를 선택하는 것을 권장하며, 이 값은 데이터베이스의 IN 절 파라미터 제한, 순간적인 부하 용량 등을 고려하여 결정하면 좋다.
이러한 방법을 통해 페이징과 collection fetch join의 한계를 돌파하고, 데이터베이스 성능을 최적화할 수 있다.

소스코드

참조