I 개발 고급 - 컬렉션 조회 최적화
인프런 수업 강의를 듣고 정리한 내용입니다.
이전까지 xxxtoOne(OneToOne, ManyToOne)
관계만 있었다.
이번에는 컬렉션인 일대다 관계(OneToMany
)를 조회하고, 최적화하는 방법을 알아보자!
📚 1. 주문 조회 V1: 엔티티 직접 노출
OrderApiController
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
/**
* V1. 엔티티 직접 노출
* - Hibernate5Module 모듈 등록, LAZY=null 처리
* - 양방향 관계 문제 발생 -> @JsonIgnore
*/
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName(); //Lazy 강제 초기화
order.getDelivery().getAddress(); //Lazy 강제 초기화
List<OrderItem> orderItems = order.getOrderItems();
orderItems.stream().forEach(o -> o.getItem().getName()); //Lazy 강제 초기화
}
return all;
}
}
orderItem
,item
관계를 직접 초기화하면Hibernate5Module
설정에 의해 엔티티를 JSON으로 생성한다.- 코드에서 프록시를 강제 초기화할 시 데이터를 뿌려준다.
- 양방향 관계에서 무한 루프에 걸리지 않기 위해 한 곳에
@JsonIgnore
을 꼭 붙여줘야한다. - 엔티티를 직접 노출하므로 좋은 코드는 아니다.
실행 결과
[
{
"id": 4,
"member": {
"id": 1,
"name": "userA",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
}
},
"orderItems": [
{
"id": 6,
"item": {
"id": 2,
"name": "JPA1 BOOK",
"price": 10000,
"stockQuantity": 99,
"categories": null,
"author": null,
"isbn": null
},
"orderPrice": 10000,
"count": 1,
"totalPrice": 10000
},
{
"id": 7,
"item": {
"id": 3,
"name": "JPA2 BOOK",
"price": 20000,
"stockQuantity": 98,
"categories": null,
"author": null,
"isbn": null
},
"orderPrice": 20000,
"count": 2,
"totalPrice": 40000
}
],
"delivery": {
"id": 5,
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"status": null
},
"orderDate": "2022-04-13T15:17:07.10899",
"status": "ORDER",
"totalPrice": 50000
},
{
"id": 11,
"member": {
"id": 8,
"name": "userB",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
}
},
"orderItems": [
{
"id": 13,
"item": {
"id": 9,
"name": "SPRING1 BOOK",
"price": 20000,
"stockQuantity": 197,
"categories": null,
"author": null,
"isbn": null
},
"orderPrice": 20000,
"count": 3,
"totalPrice": 60000
},
{
"id": 14,
"item": {
"id": 10,
"name": "SPRING2 BOOK",
"price": 40000,
"stockQuantity": 296,
"categories": null,
"author": null,
"isbn": null
},
"orderPrice": 40000,
"count": 4,
"totalPrice": 160000
}
],
"delivery": {
"id": 12,
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"status": null
},
"orderDate": "2022-04-13T15:17:07.247352",
"status": "ORDER",
"totalPrice": 220000
}
]
📚 2. 주문 조회 V2: 엔티티를 DTO로 변환
OrderApiController
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
...
/**
* V2. 엔티티를 DTO로 변환
*/
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
@Getter
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate; //주문시간
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems; //Dto 변환 필수!!
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
// 프록시 초기화
orderItems = order.getOrderItems().stream()
.map(orderItem -> new OrderItemDto(orderItem))
.collect(toList());
}
}
@Getter
static class OrderItemDto {
private String itemName; //상품명
private int orderPrice; //주문 가격
private int count; //주문 수량
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName();
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
}
- 지연 로딩으로 너무 많은 SQL을 실행한다.
- SQL 실행 수
order
1번member
,address
N번 (order
조회 수 만큼)orderItem
N번 (order
조회 수 만큼)item
N번 (orderItem
조회 수 만큼)
실행 결과
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2022-04-13T16:11:15.370834",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2022-04-13T16:11:15.515381",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
💡 참고
지연 로딩은 영속성 컨텍스트에 있으면 영속성 컨텍스트에 있는 엔티티를 사용하고 없으면 SQL을 실행한다. 따라서 같은 영속성 컨텍스트에서 이미 로딩한 회원 엔티티를 추가로 조회하면 SQL을 실행하지 않는다.
💡 참고
Dto
안에는 엔티티가 있으면 안된다.
OrderDto
안에 있는orderItems
조차도Dto
로 변환해주어야 한다.
📚 3. 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화
OrderApiController
에 추가
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return result;
}
OrderRepository
에 추가 (distinct
없이)
public List<Order> findAllWithItem() {
return em.createQuery(
"select 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)
.getResultList();
}
실행 결과
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2022-04-13T16:32:18.909982",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 4,
"name": "userA",
"orderDate": "2022-04-13T16:32:18.909982",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2022-04-13T16:32:19.083073",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2022-04-13T16:32:19.083073",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
- 실행 결과를 보면 데이터가 뻥튀기가 되어 있다.
✔️ distinct 추가
public List<Order> findAllWithItem() {
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)
.getResultList();
}
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2022-04-13T16:38:12.067828",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2022-04-13T16:38:12.206272",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
- db 쿼리에서는
distinct
가 되지 않는다. - jpabook1, jpabook2, spring1, spring2가 각각 이름이 다르기 때문이다.
- 그러나, jpa에서는
distinct
가 있으면order
를 가져올 때,id
가 같을 시 중복을 제거해준다. (현재는orderId
4와 11)
- 그리고
v2
와v3
코드가 같다. - 단지,
v2
와 다르게v3
에서는 페치 조인만 소스를 추가하여 최적의 결과를 얻게 되었다. - 페치 조인으로
SQL
이 1번만 실행된다.
🔔
distinct
를 사용하는 이유는?
- 1대다 조인이 있으므로 데이터베이스 row가 증가한다. 그 결과 같은
order
엔티티의 조회 수도 증가하게 된다.- JPA의
distinct
는 SQL에distinct
를 추가하고, 더해서 같은 엔티티가 조회되면, 애플리케이션에서 중복을 걸러준다.
- 예제에서는
order
가 컬렉션 페치 조인 때문에 중복 조회 되는 것을 막아준다.
⚠️ 단점 : 컬렉션 페치 조인을 사용하면 페이징이 불가능하다.
- 조회할 데이터가 적을 때는 문제가 없지만, 데이터가 커지게 되면 out of memory 예외가 발생할 수도 있어 매우 치명적이다.
💡 참고
컬렉션 페치 조인은 1개만 사용할 수 있다.
컬렉션 둘 이상에 페치 조인을 사용하면 안된다. 데이터가 부정합하게 조회될 수 있다.
📚 4. 주문 조회 V3.1: 엔티티를 DTO로 변환 - 페이징과 한계 돌파 (이 방식을 선호, 중요)
✔️ 컬렉션을 페치 조인하면 페이징이 불가능하다.
- 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
- 일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row 가 생성된다.
Order
를 기준으로 페이징 하고 싶은데, 다(N)인OrderItem
을 조인하면OrderItem
이 기준이 되어버린다.
➡️ 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다.
📖 A. 한계 돌파(해결 방법)
그러면 페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?
- 지금부터 코드도 단순하고, 성능 최적화도 보장하는 매우 강력한 방법을 공부하겠다!
- 대부분의 페이징 + 컬렉션 엔티티 조회 문제는 이 방법으로 해결할 수 있다.
- 먼저
xxxToOne(OneToOne, ManyToOne)
관계를 모두 페치조인 한다.xxxToOne
관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
- 컬렉션은 지연 로딩으로 조회한다.
- 지연 로딩 성능 최적화를 위해
hibernate.default_batch_fetch_size
,@BatchSize
를 적용한다.hibernate.default_batch_fetch_size
: 글로벌 설정@BatchSize
: 개별 최적화- 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
OrderRepository
에 추가
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class
).setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
OrderApiController
에 추가
/**
* V3.1 엔티티를 조회해서 DTO로 변환 페이징 고려
* - ToOne 관계만 우선 모두 페치 조인으로 최적화
* - 컬렉션 관계는 hibernate.default_batch_fetch_size, @BatchSize로 최적화
* */
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(
@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit)
{
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return result;
}
실행 결과
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2022-04-13T17:22:54.502452",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2022-04-13T17:22:54.648475",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
✔️ default_batch_fetch_size 추가로 1 N N -> 1 1 1
로 변경되었다.(최적화 옵션)
- 페이징 및 성능이 최적화된다.
application.yml
에 추가
- in 쿼리 개수를 100개로 하겠다!
- 이 값을 잘 설정하는 것이 중요하다.
실행 결과
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2022-04-13T17:32:30.200664",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2022-04-13T17:32:30.331924",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
3번의 실행 결과
(1)
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
이부분 실행 결과는
- 데이터 중복이 없어졌다. (2, 3번에서도 데이터 중복이 없어졌다.)
(2)
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream().map(orderItem -> new OrderItemDto(orderItem))
.collect(Collectors.toList());
}
이부분 실행 결과는
- db에 있는 userA orderItems, userB orderItems를 in 쿼리 한번으로 다 가져온 것이다.
(3)
한 번에 item 4개를 다 가져왔다.
✏️ default_batch_fetch_size
- JPA와 db가 버틸수 있따면 큰 숫자를 선택해도 된다.
- 큰 숫자가 걱정되면
default_batch_fetch_size=100
부터 점차 늘려가보기- 개별로 설정하려면
@BatchSize
를 적용하면 된다. (컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용)
🍀 장점
- 쿼리 호출 수가
1 + N
→1 + 1
로 최적화 된다.- 조인보다 DB 데이터 전송량이 최적화 된다.
Order
와OrderItem
을 조인하면Order
가OrderItem
만큼 중복해서 조회된다.- 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.
- 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
- 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.
🌼 결론
xxxToOne
관계는 페치 조인해도 페이징에 영향을 주지 않는다.- 따라서
xxxToOne
관계는 페치조인으로 쿼리 수를 줄이고 해결하고, 나머지는hibernate.default_batch_fetch_size
로 최적화 하자.
💡 참고
default_batch_fetch_size
의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택하는 것을 권장한다.- 이 전략을
SQL IN 절
을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 한다.- 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB 에 순간 부하가 증가할 수 있다.
- 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다.
- 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.
Author And Source
이 문제에 관하여(I 개발 고급 - 컬렉션 조회 최적화), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@chang626/API-개발-고급-컬렉션-조회-최적화-wfsx5n99저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)