BackEnd/JPA

[JPA] 컬렉션 조회 최적화 - 1

샤아이인 2022. 4. 22.

내가 공부한 것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼 겸 상세히 기록하고 얕은 부분들은 가볍게 포스팅하겠습니다.

 

1. 주문 조회 V1: 엔티티 직접 노출

컬렉션 조회같은 경우 1:N 에서 N의 데이터 수가 3건 이라면, JOIN시 1쪽 또한 3건으로 증가해 버린다.

 

Order - OrderItem - Item 의 연관관계가 (1 : N) (N : 1) 로 되어있는 상황이다.

Order 와 Item의 다대다 관계를 중간 Order_Item 테이블을 통하여 만든 상황이다.

 

우선 다음 컨트롤러를 확인해 보자.

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
            order.getMember().getName();
            order.getDelivery().getAddress();
            
            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream().forEach(o -> o.getItem().getName());
        }
        return all;
    }
}

이전 코드와 같이 Order에 지연로딩 처리 되어있던 Member 와 Delivery를 초기화 하고 있다.

 

Order의 코드는 다음과 같다.

Member와 Delivery는 LAZY 로딩 처리 되어있는것을 확인할 수 있다.

@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;
}

여기에 추가적으로 상품명도 함께 출력하고 싶은 상황이다. 

하지만 Order 안에는 상품명에 대한 정보는 없다. 대신 OrderItem에 대한 정보가 있다.

 

따라서 order.getOrderItems()를 통해 orderItems를 찾아온 후 접근해야 한다.

해당 OrderItem에 가보면 상품에 대한 이름 정보는 없지만, private Item item 으로 item객체에 대한 참조 정보가 있다.

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {

    @Id @GeneratedValue
    @Column(name = "order_item_id")
    private long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item; // 객체에 대한 참조
    
    // 생략...
}

item에 대한 참조가 LAZY 로딩 되어있기 때문에 item에 대한 데이터 또한 가져오기 위해 위에서 다음과 같이 한 것 이다.

List<OrderItem> orderItems = order.getOrderItems();
orderItems.stream().forEach(o -> o.getItem().getName());

 실행 결과는 다음과 같다. 하나의 Item에 대한 정보가 다음과 같다.

member, orderItem, delivery 에 대한 정보를 출력하고 있다.

[
    {
        "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-22T08:22:31.598277",
        "status": "ORDER",
        "totalPrice": 50000
    },
    // 생략...
]

하지만 위와 같이 Entity를 직접적으로 반환하는 것 은 좋지 못하다.

이에대한 내용은 이어서 알아보자.

 

2. 주문 조회 V2: 엔티티를 DTO로 변환

이번에는 Entity를 직접 반환하는 것 이 아닌, DTO 로 변환하여 반환해 보자.

 

@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
    List<Order> orders = orderRepository.findAllByString(new OrderSearch());
    List<OrderDto> collect = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(Collectors.toList());
    return collect;
}

@Getter
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(); // LAZY 로딩 초기화
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress(); // LAZY 로딩 초기화
        orderItems = order.getOrderItems();
    }
}

실행결과는 다음과 같다.

orderItems에 대한 부분은 null로 반환되었다. 이는 Hibernate5Module을 Bean을 등록해줬기 때문에 Proxy는 무시하고 출력한 것 이다.

즉, OrderItems이 lazy로딩이고 프록시객체를 통해 OrderItems에 더이상 탐색을 안했기때문에 Hibernate5Module 로 인해 null로 나온 것 이다.

 

여기서 orderItems 까지 출력하려면 코드가 다음과 같이 지저분 하기는 하지만 다음과 같이 forEach문을 돌면서 초기화를 해주면 된다.

@Getter
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(); // LAZY 로딩 초기화
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress(); // LAZY 로딩 초기화
        order.getOrderItems().stream().forEach(o -> o.getItem().getName()); // 초기화 부분
        orderItems = order.getOrderItems();
    }
}

실행 결과는 다음과 같다.

아직 문제점이 남아있다.

DTO를 반환할때는 사실 DTO안에 Entity가 있으면 안된다.

위 코드같은 경우 OrderDto를 통해 List<OrderItem>과 같은 List를 Wrapping 하여 Entity를 반환하고 있는데, 이러면 안된다!

Wrapping하기는 했지만, 사실상 Entity를 직접 반환하는 것 이다.

 

위 결과를 보면 OrderItem에 대한 Entity가 외부에 다 노출되어 버렸다. 따라서 OrdetItem 까지 DTO로 변환 시켜야 한다.

변환시킨 코드는 다음과 같다.

@Getter
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(); // LAZY 로딩 초기화
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress(); // LAZY 로딩 초기화
        orderItems = order.getOrderItems()
                .stream()
                .map(OrderItemDto::new)
                .collect(Collectors.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 대로 잘 나오는 것 을 확인할수 있다.

 

그럼 이번에는 SQL문이 총 몇번 나가게 될까?

1) 맨 처음 Order를 검색하는 쿼리 1번 (2개의 Order 검색됨)

2) 1번 Order의 Member, delivery, OrderItems 를 가져온다. 쿼리 3번

    2-1) OrderItems에서 Item을 2개 가져오게 된다. 쿼리 2번

3) 2번 Order의 Member, delivery, OrderItems 를 가져온다. 쿼리 3번

    3-1) OrderItems에서 Item을 2개 가져오게 된다. 쿼리 2번

 

총 11번의 쿼리가 발생!

이러한 컬렉션에서의 N+1의 문제를 다음 시간에 Fetch Join을 통해 극복해 보자!

댓글