JPA Collection 조회 최적화 2 - 컬렉션 조회, 플랫 데이터 최적화


API 개발 고급 - collection 조회 최적화 - JPA에서 DTO롤 직접 조회

JPA에서 DTO 직접 조회

OrderApiController.kt
1
2
3
4
5
6
7
8
9
10
11
12
@RestController
class OrderApiController(
@Autowired private val orderRepository: OrderRepository,
@Autowired private val orderQueryRepository: OrderQueryRepository,
) {

@GetMapping("/api/v4/orders")
fun ordersV4(): List<OrderQueryDto> {
return orderQueryRepository.findOrderQueryDtos()
}

}
OrderQueryRepository.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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Repository
class OrderQueryRepository(
@Autowired private val em: EntityManager,
) {

/**
* 컬렉션은 별도로 조회
* Query: 루트 1번, 컬렉션 N 번
* 단건 조회에서 많이 사용하는 방식
*/
fun findOrderQueryDtos(): List<OrderQueryDto> {
//루트 조회(toOne 코드를 모두 한번에 조회)
val result = findOrders()

//루프를 돌면서 컬렉션 추가(추가 쿼리 실행)
result.forEach {
val orderItems = findOrderItems(it.orderId)
it.orderItems = orderItems
}
return result
}

/**
* 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
*/
private fun findOrders(): List<OrderQueryDto> {
return em.createQuery(
"select new kotlinbook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderQueryDto::class.java
).resultList
}

/**
* 1:N 관계인 orderItems 조회
*/
private fun findOrderItems(orderId: Long): List<OrderItemQueryDto> {
return em.createQuery(
"select new kotlinbook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
" from OrderItem oi" +
" join oi.item i" +
" where oi.order.id = : orderId", OrderItemQueryDto::class.java
).setParameter("orderId", orderId).resultList
}

}
OrderQueryDto.kt
1
2
3
4
5
6
7
8
9
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy::class)
data class OrderQueryDto (
val orderId: Long,
val name: String,
val orderDate: LocalDateTime, //주문시간
val orderStatus: OrderStatus,
val address: Address,
var orderItems: List<OrderItemQueryDto>? = null,
)

먼저 ‘ToOne(N:1, 1:1)’ 관계를 조회하고, 이후에 ‘ToMany(1:N)’ 관계를 각각 개별적으로 처리하는 전략을 선택했습니다. 이 방식을 선택한 이유는 다음과 같습니다:
‘ToOne’ 관계의 경우, 이들을 결합해도 데이터의 행(row) 수가 증가하지 않습니다. 이는 ‘ToOne’ 관계를 결합하여 최적화하는 것이 상대적으로 용이하다는 것을 의미합니다. 따라서 이런 관계들은 한 번에 조회합니다.
반면에 ‘ToMany(1:N)’ 관계를 결합하면 데이터의 행 수가 증가하게 됩니다. 이런 경우 최적화하는 것이 어렵기 때문에, findOrderItems()와 같은 별도의 메서드를 이용하여 각각을 조회합니다.

컬렉션 조회 최적화

OrderApiController.kt
1
2
3
4
5
6
7
8
9
10
11
@RestController
class OrderApiController(
@Autowired private val orderRepository: OrderRepository,
@Autowired private val orderQueryRepository: OrderQueryRepository,
) {

@GetMapping("/api/v5/orders")
fun ordersV5(): List<OrderQueryDto> {
return orderQueryRepository.findAllByDto_optimization()
}
}
OrderQueryRepository.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
33
34
35
36
37
38
39
40
41
@Repository
class OrderQueryRepository(
@Autowired private val em: EntityManager,
) {

/**
* 최적화
* Query: 루트 1번, 컬렉션 1번
* 데이터를 한꺼번에 처리할 때 많이 사용하는 방식
*
*/
fun findAllByDto_optimization(): List<OrderQueryDto> {

//루트 조회(toOne 코드를 모두 한번에 조회)
val result = findOrders()

//orderItem 컬렉션을 MAP 한방에 조회
val orderItemMap = findOrderItemMap(toOrderIds(result))

//루프를 돌면서 컬렉션 추가(추가 쿼리 실행X)
result.forEach { it.orderItems = orderItemMap[it.orderId] }
return result
}

private fun toOrderIds(result: List<OrderQueryDto>): List<Long> {
return result.map { it.orderId }
}

private fun findOrderItemMap(orderIds: List<Long>): Map<Long, List<OrderItemQueryDto>> {
val orderItems = em.createQuery(
"select new kotlinbook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
" from OrderItem oi" +
" join oi.item i" +
" where oi.order.id in :orderIds", OrderItemQueryDto::class.java
).setParameter("orderIds", orderIds).resultList

return orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::orderId))
}

}

먼저 ‘ToOne’ 관계를 조회하고, 이 과정에서 얻은 식별자인 ‘orderId’를 이용하여 ‘ToMany’ 관계인 ‘OrderItem’을 한 번에 조회합니다.
이 과정에서 ‘MAP’을 사용함으로써 매칭 성능을 향상시킬 수 있습니다. 이는 ‘MAP’의 탐색 시간 복잡도가 O(1)이기 때문입니다.

플랫 데이터 최적화

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
33
34
35
36
37
38
39
40
41
42
43
44
@RestController
class OrderApiController(
@Autowired private val orderRepository: OrderRepository,
@Autowired private val orderQueryRepository: OrderQueryRepository,
) {

@GetMapping("/api/v6/orders")
fun ordersV6(): List<OrderQueryDto> {
val flats: List<OrderFlatDto> = orderQueryRepository.findAllByDto_flat()

return flats.stream()
.collect(
Collectors.groupingBy({
OrderQueryDto(
it.orderId,
it.name,
it.orderDate,
it.orderStatus,
it.address,
)
},
Collectors.mapping({
OrderItemQueryDto(
it.orderId,
it.itemName,
it.orderPrice,
it.count,
)
}, Collectors.toList()
)
)
).map {
OrderQueryDto(
it.key.orderId,
it.key.name,
it.key.orderDate,
it.key.orderStatus,
it.key.address,
it.value,
)
}
}

}
OrderQueryDto
1
2
3
4
5
6
7
8
9
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy::class)
data class OrderQueryDto (
val orderId: Long,
val name: String,
val orderDate: LocalDateTime, //주문시간
val orderStatus: OrderStatus,
val address: Address,
var orderItems: List<OrderItemQueryDto>? = null,
)
OrderQueryRepository.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Repository
class OrderQueryRepository(
@Autowired private val em: EntityManager,
) {

fun findAllByDto_flat(): List<OrderFlatDto> {
return em.createQuery(
"select new kotlinbook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, o.status, d.address, i.name, oi.orderPrice, oi.count)" +
" from Order o" +
" join o.member m" +
" join o.delivery d" +
" join o.orderItems oi" +
" join oi.item i", OrderFlatDto::class.java
).resultList
}

}
OrderFlatDto
1
2
3
4
5
6
7
8
9
10
11
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy::class)
data class OrderFlatDto(
val orderId: Long,
val name: String, //주문시간
val orderDate: LocalDateTime,
val orderStatus: OrderStatus,
val address: Address,
val itemName: String,
val orderPrice: Int,
val count: Int
)

이 방식의 단점으로는, 쿼리는 한 번만 실행되지만, 조인 결과로 인해 데이터베이스에서 애플리케이션으로 전달되는 데이터에 중복이 추가될 수 있습니다. 이로 인해 상황에 따라서는 V5 방식보다 성능이 더 느려질 수 있습니다. 또한 애플리케이션에서 추가적인 작업량이 상당히 커집니다. 그리고 이 방식은 페이징이 불가능하다는 점도 단점으로 들 수 있습니다.

소스코드

참조