JPA - 지연 로딩과 조회 성능 최적화


  • API 개발 고급 - 지연 로딩과 조회 성능 최적화

엔티티를 직접 노출

xToOne 관계에 대해서
Order
Order -> Member
Order -> Devlivery

Order.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
@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]
) {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
var member: Member? = null

@OneToMany(mappedBy = "order", cascade = [CascadeType.ALL])
var orderItems: MutableList<OrderItem> = ArrayList()

@OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
@JoinColumn(name = "delivery_id")
var delivery: Delivery? = null
}
OrderSimpleApiController.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RestController
class OrderSimpleApiController(
@Autowired private val orderRepository: OrderRepository,
@Autowired private val orderSimpleQueryRepository: OrderSimpleQueryRepository //의존관계 주입
) {
/**
* V1. 엔티티 직접 노출
* - Hibernate5Module 모듈 등록, LAZY=null 처리
* - 양방향 관계 문제 발생 -> @JsonIgnore
*/
@GetMapping("/api/v1/simple-orders")
fun ordersV1(): List<Order> {
val all: List<Order> = orderRepository.findAllByString(OrderSearch())
for (order in all) {
order.member?.name //Lazy 강제 초기화
order.delivery?.address//Lazy 강제 초기화
}
return all
}

애플리케이션을 구축하는 과정에서 데이터의 적절한 관리와 표현은 중요한 역할을 한다.
이에 대한 관점 중 하나는 엔티티를 API 응답으로 직접 노출하는 것에 대한 경계하는 것이고, 무조건적으로 엔티티를 API 응답으로 반환하는 것은 좋지 않은 방법으로 여겨진다.
물론, 간단한 애플리케이션에서는 이를 그대로 사용할 수도 있겠지만, Hibernate5Module 같은 도구를 이용하는 것 보다는 DTO(Data Transfer Object)로 변환하여 반환하는 것이 더 바람직하다.
(Hibernate5Module 실제 업무에서 DTO로 사용해서 사용할 일이 없을것)

DTO는 일종의 정보 가방으로, 필요한 정보만 담아서 전송하며 데이터의 과도한 노출을 막아주는 역할을 한다. 이를 통해, 필요한 데이터만 클라이언트에게 제공함으로써 API의 응답을 보다 적절하게 관리할 수 있다.

또한, 데이터의 로딩 방식에 대한 고려도 중요하다. 데이터베이스로부터 데이터를 로딩하는 방법에는 주로 ‘즉시 로딩(Eager Loading)’과 ‘지연 로딩(Lazy Loading)’ 두 가지가 있다.
즉시 로딩은 연관된 데이터를 모두 한 번에 조회하는 방법으로, 연관 관계가 없는 경우에도 데이터를 항상 조회하게 되어 성능 문제를 일으킬 수 있다. 반면, 지연 로딩은 데이터가 실제로 사용될 때까지 로딩을 지연시키는 방식으로, 불필요한 데이터 로딩을 최소화하여 성능을 향상시키는 효과를 가진다.

따라서, 기본적으로는 지연 로딩을 사용하되, 성능 최적화가 필요한 경우에만 ‘페치 조인(fetch join)’과 같은 기법을 활용하는 것이 바람직하다. 페치 조인은 SQL의 JOIN 기능을 활용하여 필요한 정보만을 한 번의 쿼리로 가져오는 방법으로, 성능 튜닝에 유용하다.

결론적으로, 애플리케이션의 복잡성이 증가함에 따라 엔티티의 직접 노출을 최소화하고, 로딩 전략을 적절하게 관리하는 것이 중요하다. 이를 위해 DTO를 활용하고, 지연 로딩을 기본으로 하며, 필요한 경우 페치 조인을 활용하는 방법을 고려해야 한다.

엔티티를 DTO로 변환

Order.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
@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]
) {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
var member: Member? = null

@OneToMany(mappedBy = "order", cascade = [CascadeType.ALL])
var orderItems: MutableList<OrderItem> = ArrayList()

@OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
@JoinColumn(name = "delivery_id")
var delivery: Delivery? = null

fun toDto(): OrderApiController.OrderDto {
return OrderApiController.OrderDto(
orderId = this.id,
name = this.member?.name,
orderDate = this.orderDate, //주문시간
orderStatus = this.status,
address = this.delivery,
orderItems = this.orderItems.map { it.toDto() }
)
}
}
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/v2/orders")
fun ordersV2(): List<OrderDto> {
val orders: List<Order> = orderRepository.findAll()
return orders.map{ it.toDto() }
}

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

}

엔티티를 DTO로 변환하는 일반적인 방법을 통해 데이터베이스에서 데이터를 조회하는 과정은, 대체로 1 + N + N 번의 쿼리가 실행된다.

먼저, order를 조회하는 쿼리가 1번 실행된다. 이로 인해 반환된 order의 개수가 N이 된다.
이어서 각 order에 연관된 member를 조회한다. 이 때는 지연 로딩(Lazy Loading)이 적용되어, order의 개수(N)만큼 쿼리가 실행된다.
마찬가지로, 각 order에 연관된 delivery 정보도 조회하게 된다. 이 경우에도 지연 로딩이 적용되어 N번의 쿼리가 추가로 실행된다.
예를 들어, order의 결과가 4개인 경우, 최악의 경우에는 1(처음 order 조회) + 4(order에서 member 조회) + 4(order에서 delivery 조회) = 9번의 쿼리가 실행될 수 있다.

하지만 지연 로딩의 특성상, 영속성 컨텍스트에서 이미 조회된 데이터에 대해서는 쿼리를 실행하지 않고, 중복된 쿼리 실행을 방지하므로 성능 최적화에 중요한 역할을 한다.

요약하면, 엔티티를 DTO로 변환하는 일반적인 방법을 사용하면서도, 지연 로딩의 원리를 이해하고 활용하면, 데이터베이스에서 필요한 데이터만 효율적으로 조회하면서 성능을 최적화할 수 있다.

엔티티를 DTO로 변환 - 페치 조인 최적화

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/v3/simple-orders")
fun ordersV3(): List<OrderApiController.OrderDto> {
val orders: List<Order> = orderRepository.findAllWithMemberDelivery()
return orders.map{ it.toDto() }
}
}
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
}
}

페치 조인(fetch join)은 SQL의 JOIN 연산을 활용하여 관련 있는 여러 테이블의 데이터를 한 번의 쿼리로 모두 가져오는 기법이다. 이 기법을 이용하면, 연관된 엔티티를 한번의 쿼리로 조회할 수 있다.

예를 들어, order 엔티티에 연관된 member와 delivery 엔티티를 조회할 경우, 페치 조인을 사용하면 order -> member, order -> delivery 관계를 한 번의 쿼리로 조회할 수 있다. 이 방식을 사용하면, 각각의 엔티티를 따로 조회하는 지연 로딩(Lazy Loading)이 아닌 즉시 로딩(Eager Loading)이 가능하게 된다.

따라서, 페치 조인은 특정 엔티티와 그것과 연관된 엔티티들을 한번에 조회할 수 있어, 쿼리 실행 횟수를 줄이고, 성능 최적화에 크게 기여한다. 이 방식은 이미 조회된 상태이므로 추가적인 지연 로딩이 발생하지 않는다.

요약하면, 페치 조인을 활용하면, 한 번의 쿼리로 연관된 여러 엔티티를 함께 조회할 수 있어, 데이터 접근 효율을 높이고 성능 최적화에 기여할 수 있다.

JPA에서 DTO로 바로 조회

OrderSimpleApiController.kt
1
2
3
4
5
6
7
8
9
10
@RestController
class OrderSimpleApiController(
@Autowired private val orderRepository: OrderRepository,
@Autowired private val orderSimpleQueryRepository: OrderSimpleQueryRepository //의존관계 주입
) {
@GetMapping("/api/v4/simple-orders")
fun ordersV4(): List<OrderSimpleQueryDto> {
return orderSimpleQueryRepository.findOrderDtos()
}
}
OrderSimpleQueryRepository.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
@Repository
class OrderSimpleQueryRepository(
@Autowired private val em: EntityManager,
){
fun findOrderDtos(): List<OrderSimpleQueryDto> {
return em.createQuery(
"select new kotlinbook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderSimpleQueryDto::class.java
).resultList
}
}

일반적인 SQL 사용 시에는 원하는 값만을 선택하여 조회하는 것이 가능하다. 이를 JPQL에서도 구현할 수 있으며, 결과를 DTO로 즉시 변환하는 데에 new 명령어를 사용한다.

SELECT 절에서 원하는 데이터를 직접 선택하므로, 필요한 데이터만을 조회하여 DB와 애플리케이션 사이의 네트워크 용량을 최적화한다. 이는 데이터 전송량을 줄이고 성능을 향상시키는데 기여한다. 그러나 실제로 이 방식이 가져다주는 이점은 상대적으로 미비할 수 있다.

다만, 이 방식의 단점도 존재한다. 리포지토리의 재사용성이 떨어질 수 있고, JPQL을 사용하여 특정 API 스펙에 맞게 데이터를 선택하다보면, 이런 코드가 리포지토리에 포함되게 되는데, 이로 인해 다른 곳에서 해당 리포지토리를 재활용하는 것이 어렵게 된다.

요약하면, JPQL을 사용하여 원하는 데이터만 선택하여 조회하고, new 명령어를 통해 결과를 즉시 DTO로 변환하는 방식은 성능 최적화와 네트워크 용량 절약에 도움이 될 수 있지만, 리포지토리의 재사용성이 떨어지는 단점도 함께 고려해야 한다.

정리

엔티티를 DTO로 변환하는 방법과 DTO로 바로 조회하는 방법, 이 두 가지 방식은 각각의 장점과 단점을 가지고 있다. 따라서, 상황에 따라 가장 효과적인 방법을 선택하는 것이 중요하다

엔티티를 직접 조회하는 방법을 사용하면, 리포지토리의 재사용성이 향상되고 개발 과정도 상대적으로 간결해진다. 이런 장점 때문에 일반적으로는 엔티티를 직접 조회하는 방법을 권장하고 있다. 하지만 이는 상황에 따라 달라질 수 있으므로, 개발자는 항상 해당 상황에 최적화된 방법을 고려하며 선택해야 한다.

쿼리 방식 선택 권장 순서

데이터 조회에 대한 쿼리 방식 선택은 일반적으로 다음과 같은 순서를 따르는 것이 권장된다.

  1. 엔티티를 DTO로 변환: 이 방법을 우선적으로 고려한다. 엔티티를 DTO로 변환하는 방법은 개발 과정을 간결하게 하고, 리포지토리의 재사용성을 높이는 장점이 있다.
  2. 페치 조인 활용: 필요한 경우, 페치 조인(fetch join)을 이용하여 성능을 최적화한다. 페치 조인을 통해 관련된 엔티티를 한 번의 쿼리로 조회하여, 쿼리 실행 횟수를 줄이는 데 도움이 된다.
  3. DTO 직접 조회: 페치 조인을 활용하여도 성능이 충분히 최적화되지 않는다면, DTO로 직접 조회하는 방법을 고려한다. 이는 특정 API 스펙에 맞게 데이터를 직접 선택하므로, 불필요한 데이터 조회를 줄일 수 있다.
  4. 네이티브 SQL이나 스프링 JDBC Template 활용: 위의 모든 방법이 적절한 해결책을 제공하지 못하는 경우에는, JPA가 제공하는 네이티브 SQL이나 스프링의 JDBC Template를 사용하여 SQL을 직접 사용하는 것을 고려한다.

이렇게 쿼리 방식 선택의 권장 순서를 따르면, 상황에 따라 가장 적합한 방법을 선택하면서 성능을 최적화할 수 있다.

소스코드

참조