[Spring] API - 컬렉션 조회(1 : N)

2022. 4. 17. 02:40Spring 기초

일대다 관계(One To Many)에서 조회하는 방법과 최적화하는 방법을 알아보자!
toOne 관계와는 다르게 주의할 점이 많다.

V1. 엔티티 직접 노출
V2. 엔티티를 조회해서 DTO로 변환하기
V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용)
V4. JPA에서 DTO로 바로 조회(1+N 쿼리)
V5. JPA에서 DTO로 바로 조회(최적화로 1+1 쿼리)
V6. JPA에서 DTO로 바로 조회(플랫 데이터 1쿼리)

이번 포스팅에서는 V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용) 까지 알아보고,
다음 포스팅에서는 V6. JPA에서 DTO로 바로 조회(플랫 데이터 1쿼리)다루고있다.


V1. 엔티티 직접 노출

먼저 Order와 OrderItem은 1:N의 관계이다. Order와 Member, Delivery, OrderItem는
모두 지연로딩(LAZY-Loading) 상태이며, 프록시 초기화 작업이 필요해 for문에서 getName(), getAddress(), getItem().getName()를 호출했다.
또, 양방향으로 객체의 참조관계가 있는 경우 한쪽에는 @JsonIgnore를 붙여 무한루프에 빠지지 않게 하였다.(toString 메서드라고 생각하면 쉽다. Order의 데이터를 읽는데, 그 중 Member가 있는 경우 Member의 데이터도 가져오기 위해 Member를 탐색한다. 근데 Member에는 Order가 있다,, 무한 반복이다.)  

@RestController
@RequiredArgsConstructor
public class OrderApiController {
     private final OrderRepository orderRepository;
     
     @GetMapping("/api/v1/orders")
     public List<Order> ordersV1() {
     	List<Order> all = orderRepository.findAll();
     	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;
     }
}

결론부터 말하면 엔티티를 직접 반환하는 V1방법은 사용해서는 아니된다.

엔티티는 어떠한 경우에도 파라미터, 반환값으로 쓰이면 안된다. 예를들어 엔티티의 인스턴스 변수가 변하는 경우, API 스펙을 다뜯어고쳐야 한다. name으로 전송되었던 데이터가, 엔티티의 변수가 username으로 변경되는 순간 username으로 전송된다. 
또, API 검증을 위한 로직이 들어가는데(@NotEmpty 등), 엔티티에 이걸 부착하면 이 엔티티를 사용하면 곳에 범용적으로 검증 로직이 적용된다. 당연하지만 검증은 필요한 곳에 적재적소에 있어야 한다. 엔티티를 사용하는 모든곳에 NotEmpty가 적용될 필요는 없다. 

즉, API 요청 스펙에 맞추어 별도의 DTO를 반환해야 한다.


V2. 엔티티를 조회해서 DTO로 변환하기

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

OrderDto로 변환하여 반환한다. 

@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());
	}
}

@Data
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();
     }
}

OrderDto에는 요청에 맞는 데이터들을 담고, 스트림의 map 메서드를 이용해 Order을 OrderDto로 변환한다.
여기서 주의해야 하는 점은 OrderItem이다. 처음에 설명했듯  Order와 OrderItem은 1:N의 관계이다.

그리고 V1에서 엔티티를 반환하면 안된다고 이미 배웠다. OrderDto의 생성자를 보자.

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());
 }

orderItems = order.getOrderItems(); 가 아니라 OrderItemDto라는 DTO를 새로 만들어서 변환해주는 모습이다.
만약 orderItems = order.getOrderItems(); 라는 코드라면, Dto로 변환할 이유가 사라진다. OrderItem 엔티티가 그대로 노출되기 때문이다. 따라서 OrderItem엔티티도 Dto로 변환해준다.

이제 엔티티 노출도 막았으니 API를 전송해보자. 근데 가만보면 SQL문이 진짜 많이나간다..
그 이유는 Order, OrderItem, Member, Delivery가 모두 지연로딩이기 때문이다.
 
따라서 
Order 조회 쿼리 1방을 날렸는데
member , address N번(order 조회 수 만큼)
orderItem N번(order 조회 수 만큼)
item N번(orderItem 조회 수 만큼)
의 추가 쿼리가 발생한다. 모든 엔티티가 겹치지 않는 경우에는 영속성 컨텍스트에 엔티티가 없으므로 최악의 경우인 
괄호안의 수 만큼 쿼리가 발생한다..
하지만 우리는 fetch join을 배웠기때문에 걱정없다!!!!! 
fetch join은 한방에 전부 다 select하는 방식이다. V3에서 살펴보자.


V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용)

@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
     List<Order> orders = orderRepository.findAllWithItem();
     List<OrderDto> result = orders.stream()
     .map(o -> new OrderDto(o))
     .collect(toList());
     return result;
}

OrderDto를 반환하는 것은 동일하다(엔티티 반환 금지)

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();
}

DAO에 findAllWithItem()메서드를 추가했다. fetch join방식으로 조회한다.

여기서 또다른 핵심은 distinct라고 할 수 있다. 
1대다 조인이 있으므로 데이터베이스 row가 증가한다. 그 결과 같은 order 엔티티의 조회 수도 증가하게 된다. JPA의 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면, 애플리케이션에서 중복을 걸러준다. 이 예에서 order가 컬렉션 페치 조인 때문에 중복 조회 되는 것을 막아준다.  
자세한 내용은 https://mr-popo.tistory.com/46?category=1015747 

 

JPQL: fetch join - 컬렉션 페치 조인, 페치 조인 특징과 한계, 페이징

컬렉션 페치 조인(데이터 뻥튀기 조심하라) 일대다 관계를 가정해보자. 현재 팀A에 멤버가 두명 속해있다. 이때 테이블을 조인하면 테이블이 아래와 같아진다. 따라서 DB에서 결과가 두줄이 

mr-popo.tistory.com



아무튼 이렇게 fetch join을 하는 경우에는 페이징이 불가능하다. 데이터베이스 row가 증가하므로, 페이징이 1:N에서
1이 아닌 N을 기준으로 페이징을 해버린다. 예를들어 1:4라면 데이터가 4줄이 되고, 4줄을 대상으로 페이징을 한다. 이 경우 offset을 두면 몇몇의 데이터는 누락된다.
정 그래도 페이징을 해야겠다! 하고 시도하면 하이버네이트는 경고 메시지를 남기고 모.든 데이터를 DB에서 메모리로 퍼올린 다음 메모리에서 페이징을 진행한다.

이에 대한 자세한 내용도 위의 링크 포스팅에 기록했다.

다음 포스팅에는 V4~V6를 알아보겠다.