인프런 수업 강의를 듣고 정리한 내용입니다.
이전까지 xxxtoOne(OneToOne, ManyToOne)
관계만 있었다.
이번에는 컬렉션인 일대다 관계(OneToMany
)를 조회하고, 최적화하는 방법을 알아보자!
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;
}
}
// 초기화
List<OrderItem> orderItems = order.getOrderItems();
orderItems.stream().forEach(o -> o.getItem().getName());
가 추가되었는데
order.getOrderItems()
를 실행하여
강제 초기화가 실행된다.
orderItem
, item
관계를 직접 초기화하면 Hibernate5Module
설정에 의해 엔티티를 JSON으로 생성한다.Hibernate5Module
기본 설정 자체가 지연 로딩 할 때 프록시인 것은 데이터를 뿌리지 않지만, 프록시들을 미리 강제 초기화할 시 데이터가 있다고 판단하여 뿌려준다.@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
}
]
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();
}
}
}
✔️ DTO에 엔티티가 있으면 안된다.
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItem> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
order.getOrderItems().stream().forEach(o->o.getItem().getName());
orderItems = order.getOrderItems();
}
}
이와 같이 DTO
안에 엔티티가 있으면 안된다. (매핑하는 것도 안된다.)
DTO
에 감싸서 보내면 엔티티가 외부에 다 노출되기 때문에, 엔티티 의존을 끊는 코드로 변경해야 한다.OrderItem
자체를 DTO로 변경해야 한다.
✔️ 엔티티 의존을 끊는 다른 방법으로 OrderItemDto
를 생성한다.
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems;
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();
}
}
OrderItemDto
)클래스에 추가한다.OrderDto
→ OrderItemDto
클라이언트가 원하는 요구조건 데이터들만 출력된다.
(address
: ValueObject 변경될 일이 없어 화면에 출력해도 된다. OrderItemDto
같은 클래스들은 DTO를 주의해야한다.)
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
로 변환해주어야 한다.
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();
}
ORDER_ID 4번
인 경우, order_item
으로 JPA1, JPA2를 가지고 있다.orders
테이블은 행이 하나, order_item
테이블은 행이 두개 이다.join
을 할시, (행이 많은)다 쪽에 맞추어 결과가 출력된다. (뻥튀기 발생)
실행 결과
[
{
"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
}
]
}
]
distinct
가 되지 않는다. (중복 데이터가 너무 많다.)distinct
가 있으면 order
를 가져올 때, id
가 같을 시 중복을 제거해준다. (현재는 orderId
4와 11)
v2
와 v3
코드가 같다.v2
와 다르게 v3
에서는 페치 조인만 소스를 추가하여 최적의 결과를 얻게 되었다.SQL
이 1번만 실행된다.
🔔
distinct
를 사용하는 이유는?
- 1대다 조인이 있으므로 데이터베이스 row가 증가한다. 그 결과 같은
order
엔티티의 조회 수도 증가하게 된다.- JPA의
distinct
는 SQL에distinct
를 추가하고, 더해서 같은 엔티티가 조회되면, 애플리케이션에서 중복을 걸러준다.
- 예제에서는
order
가 컬렉션 페치 조인 때문에 중복 조회 되는 것을 막아준다.
⚠️ 단점 : 컬렉션 페치 조인을 사용하면 페이징이 불가능하다.
- 조회할 데이터가 적을 때는 문제가 없지만, 데이터가 커지게 되면 out of memory 예외가 발생할 수도 있어 매우 치명적이다. (메모리에서 수 많은 페이징 처리하게 된다. 이럴 경우
out of memory
가 발생할 수 있다.)- 일대다를 페치 조인 하는 순간, 페이징이 불가능하다. (
setFirstResult, setMaxResults
불가능)- 하이버네이트는 경고 로그를 남기면서 모든 데이터를 (뻥튀기가 일어난)DB에서 읽어오고, 메모리에서 페이징 해버린다. (굉장히 위험하다.)
💡 참고
컬렉션 페치 조인은 1개만 사용할 수 있다.
컬렉션 둘 이상에 페치 조인을 사용하면 안된다. 데이터가 부정합하게 조회될 수 있다.
✔️ 컬렉션을 페치 조인하면 페이징이 불가능하다.
Order
를 기준으로 페이징 하고 싶은데, 다(N)인 OrderItem
을 조인하면 OrderItem
이 기준이 되어버린다.➡️ 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다.
그러면 페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?
- 지금부터 코드도 단순하고, 성능 최적화도 보장하는 매우 강력한 방법을 공부하겠다!
- 대부분의 페이징 + 컬렉션 엔티티 조회 문제는 이 방법으로 해결할 수 있다.
xxxToOne(OneToOne, ManyToOne)
관계를 모두 페치조인 한다. xxxToOne
관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.hibernate.default_batch_fetch_size
, @BatchSize
를 적용한다.hibernate.default_batch_fetch_size
: 글로벌 설정@BatchSize
: 개별 최적화
OrderRepository
public List<Order> findAllWithMemberDelivery() {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class
).getResultList();
}
기존 v3
방식으로 소스를 작성할 시
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(){
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
member
와 delivery
는 페치 조인으로 가져왔기 때문에, 한 번에 다 가져온다. (ToOne 관계로 한 번에 가져온다.)
첫 번째 orderItem
이 실행된다.
orderitem
에는 item
이 2개가 들어있다.
두 번째 orderitem
이 실행된다.
- 이미
delivery
와member
가 조회된 상태라,orderItems
부터 조회한다.
1 N N -> 1 1 1
로 변경되었다. (최적화 옵션)
- 페이징 및 성능이 최적화된다.
***ToOne
는 바로 전에 '지연 로딩과 조회 성능 최적화'에서 알아본 페치 조인 최적화를 사용하고,***ToMany
에서 이와 같이 사용한다.
OrderApiController
에 offset과 limit 추가
/**
* 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(toList());
return result;
}
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();
}
application.yml
에 추가
default_batch_fetch_size
를 추가할 시 지정한 갯수만큼 미리 쿼리 문을 실행한다. (작성한 쿼리문들을 미리 실행함)
실행 결과
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2022-04-20T13:37:03.470376",
"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-20T13:37:03.555037",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 3
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 4
}
]
}
]
콘솔 실행 결과
(1)
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
이부분 실행 결과는
(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());
}
이부분 실행 결과는
default_batch_fetch_size: 100
설정 없이 실행할 때는, userA, userB 각각 가져왔지만, 추가했을 때는 한 번에 가져온다.
(3)
default_batch_fetch_size: 100
설정을 추가했을 때는 한 번에 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든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.