JPA DTO 활용

Park sang woo·2024년 9월 3일
1

CS스터디

목록 보기
17/25

⚒️ 엔티티 직접 노출 (절대 하면 안됨)

현재 DB 관계에서 Order : Member -> 다대일 , Order : Delivery -> 일대일 , Order : OrderItems -> 일대다 관계입니다.

/**
 * V1. 엔티티 직접 노출
 * - Hibernate5Module 모듈 등록, LAZY=null 처리
 * - 양방향 관계 문제 발생 -> @JsonIgnore
 */
 @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;
}

정말 좋지 않은 방법입니다. 엔티티를 직접 노출해서는 절대 안됩니다.


Lazy 강제 초기화를 한 이유는 hibernateSModule에 기본 설정으로 LazyLoading하고 있을 때 프록시가 데이터를 뿌리지 않는데 강제 초기화를 하면 데이터를 뿌립니다.
즉 프록시 객체가 데이터를 제대로 뿌리지 않는 문제를 해결하기 위해 강제 추기화를 사용하여 실제 데이터를 로드하도록 합니다.
Lazy 로딩 상태에서 엔티티를 가져오면, Hibernate는 해당 엔티티에 대한 프록시 객체를 생성합니다. 이 프록시 객체가 사용되면, 실제 데이터가 로드되지 않은 상태이기 때문에 JSON으로 직렬화할 때 필요한 데이터가 누락될 수 있습니다. 그래서 클라이언트에 빈 데이터나 프록시 객체의 기본 정보만 전달될 수 있습니다.
따라서 강제 초기화를 통해 프록시 객체를 실제 데이터로 변환하는 것입니다.

@Bean
Hibernate5Module hibernate5Module() {
	Hibernate5Module hibernate5Module = new Hibernate5Module();
	//강제 지연 로딩 설정
	.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
	 hibernate5Module;
}


🔻 @JsonIgnore

양방향 관계라면 엔티티를 직접 다 출력하기 때문에 JsonIgnore로 꼭 찾아서 걸어줘야 합니다.
양방향 관계에서는 두 엔티티가 서로를 참조하기 때문에 JSON으로 직렬화할 때 무한 루프가 발생할 수 있습니다.
@JsonIgnore를 사용하면 특정 필드를 JSON 직렬화 과정에서 무시할 수 있습니다.






⚒️ 엔티티를 DTO로 변환

DTO로 반환할 때 DTO 안에 엔티티가 존재하면 안 됩니다. (랩핑도 X)

엔티티가 외부로 노출이 되버립니다. → 단순하게 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;
}
@Data
static class OrderDto {    
	private Long orderId;
    private String name;
    private LocalDateTime orderDate; //주문시간
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItem> orderItems;
}

여기 List<OrderItem> 처럼 사용하면 안됩니다. 나중에 엔티티를 수정하게 된다면 화면이 다 바뀌게 되버립니다. →API가 망가집니다.

그래서 귀찮더라도 DTO로 한번 더 감싸서 만들어줘야 완전히 Entity가 나가지 않고 DTO를 출력할 수 있습니다.

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

하지만 지연 로딩으로 너무 많은 쿼리가 실행됩니다.
SQL 실행이 주문 1번을 했다고 한다면 member가 order 조회 수 만큼 N번, orderItem도 N번 쿼리가 실행됩니다.






⚒️ 페치 조인 최적화

ToOne 관계에서는 페치 조인을 통해서 N+1, 지연 로딩, 쿼리 최적화의 문제를 해결할 수 있었습니다.
하지만 DB에서 일대다 관계로 조회를 해버리면 데이터가 뻥튀기가 됩니다. 예를 들어 하나의 주문에 여러 개의 아이템이 존재하여 일대다 관계라면 조인했을 때 쿼리가 아이템 개수에 맞춰서 쿼리가 3개가 날라갑니다.
그래서 이점에서는 성능 최적화에 고민해야 할 포인트입니다.

@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;
}
public List<Order> findAllWithItem() {
    JPAQueryFactory queryFactory = new JPAQueryFactory(em);

    return queryFactory.selectFrom(order)
            .distinct()
            .join(order.member, member).fetchJoin()
            .join(order.delivery, delivery).fetchJoin()
            .join(order.orderItems, orderItem).fetchJoin()
            .join(orderItem.item, item).fetchJoin()
            .fetch();
}

이 경우 Order 입장에서 member와 delivery는 ~ToOne 관계입니다. 하지만 orderItems의 경우 일대다 관계이기 때문에 데이터가 뻥튀기가 됩니다. 즉 한번의 order에 orderItem이 2개 있고 ORDER_ID로 조인을 하게 되므로 관계형 DB에서는 조인한 결과가 ORDER_ID가 2개가 됩니다. (개수에 맞춰서)
이러면 발생하는 문제는 JPA에서 Order를 가져올 데이터가 2배가 됩니다. (다 쪽이 N만큼 있다면 N만큼 증가가 되는 것.)

우리가 원하는 것은 뻥튀기 없이, 중복없이 쿼리를 날리고 싶은 것입니다.
SQL에서는 중복을 없애기 위해 distinct를 사용하면 된다고 생각할 수 있지만 DB의 distinct는 조인했을 때 일부 컬럼값이 다르기 때문에 실제로 DB쿼리에서 distinct가 제대로 되지 않습니다.
JPA에서는 자체적으로 Order를 가져올 때 같은 id 값이면 중복을 알아서 제거해줍니다. 그렇게 해서 리스트에 담아서 반환해줍니다.

JPA distinct 역할 2가지 -> DB에 distinct 키워드를 날려준다(SQL에 distinct를 추가) + 루트인 엔티티가 중복인 경우에 그 중복을 걸러서 컬렉션에 담아준다






⚒️ 페이징과 한계 돌파

컬렉션 페치 조인이 치명적인 단점으로 페이징이 불가능합니다. 일대다 페치 조인하는 순간 페이징 쿼리가 아예 날라가지 않습니다.
만약 하게 된다면 postman에서 실행 시 페이징 쿼리 시 작성했던 limit, offset이 보이지 않습니다. 그리고 WARN이 발생합니다.

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)
          .setFirstResult(offset)
          .setMaxResults(limit)
          .getResultList();
}

firstResult/maxResults specified with collection fetch; applying in memory!
이 의미는 firstResult와 maxResults가 콜렉션 fetch랑 같이 정의가 되었다는 의미입니다. 그래서 패치 조인을 썼는데 지금 페이징 쿼리가 들어가서 메모리에서 이걸 sorting 해버리겠다는 의미입니다. (메모리에서 페이징 처리)
데이터가 만개가 있다면 이 만개를 다 애플리케이션에 퍼 올린 다음에 페이징 처리를 해버리는 것입니다. DB 입장에서는 Limit, Offset 하게 되면 Order를 기준으로 적용되는 게 아니라 정확히는 OrderItem을 기준으로 Paging이 됩니다. 데이터가 뻥튀기 되었기 때문에 데이터 개수를 맞추지 못하게 됩니다.


~ToOne 관계에서는 가능, 일대다 관계라면 페이징 불가능.

페이징에서는 일대다라면 fetch join이라면 데이터가 뻥튀기 돼서 페이징 자체가 불가능해짐.

컬렉션 페치 조인은 1개만 사용.
컬렉션 페치 조인 둘 이상 사용하지 말기 (페이징은 예외)



🔻 해결책 (페이징 + 컬렉션 엔티티 조회 문제 해결)

먼저 ToOne 관계는 모두 페치조인 -> ToOne 관계는 row수를 증가시키지 않음.
컬렉션은 페치 조인이 아닌 지연로딩으로 둔다.
지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용한다.

전역적으로 하는 hibernate.default_batch_fetch_size 이거는 yml 파일에 spring.jpa.hibernate 안에 default_batch_fetch_size: * 값을 설정해주면 됩니다.
실행시킨 쿼리를 보면 where 절에 orderitem0_.order_id in (*, *) 이런 식으로 값이 들어가 있습니다.
이것의 의미는 한 번의 쿼리로 DB에 있는 유저 A의 OrderItem이랑 유저 B의 OrderItem을 다 가져오는 것입니다. ~~fetch_size 값은 인쿼리의 개수를 몇 개로 할 거냐를 정의한 것입니다.

배치는 DB에서 여러 개의 레코드를 한 번에 가져오는 방법입니다. 각 엔티티를 개별적으로 쿼리하는 대신 미리 설정한 수만큼 묶어서 가져올 수 있습니다.
만약 10으로 잡고 데이터가 100건이라면 Hibernate는 한 번에 10개의 레코드를 가져오도록 쿼리를 실행합니다. 즉 총 100개의 데이터를 가져올 때, 10개씩 묶어서 10번의 쿼리가 실행됩니다. 이러면 성능 향상과 N+1 문제를 해결할 수 있습니다.


장점

  • 1 + N -> 1 + 1로 최적화
  • 조인보다 DB 데이터 전송량이 최적화
  • 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만 DB 전송량 감소
  • 컬렉션 페치 조인은 페이징이 불가능하지만 이것은 페이징이 가능

🔻 결론

ToOne 관계에서는 페치 조인해도 페이징에 영향을 안 줌. 따라서 ToOne관계는 페치 조인으로 쿼리 수를 줄이고 나머지는 fetch_size로 최적화.

Fetch_size 크기 설정
default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택하는 것을 권장한다. 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기 도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다. 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는 지로 결정하면 된다.



⚒️ JPA에서 엔티티를 조회하고 DTO로 변환하는 방식

  1. 엔티티들을 fetch join 써서 엔티티들을 조회
  • 그냥 fetch join 사용해서 연관된 엔티티나 컬렉션을 한방 쿼리로 한 번에 함께 조회 조회
  1. fetch join 열심히 해서 애플리케이션에서 DTO로 바꿔서 화면에 반환
  • 필요한 엔티티 조회해서 DTO로 변환.
  1. 처음 JPQL 짤 때부터 아예 new operation으로 DTO로 스위칭해서 가져오기ㅐ
  • JPQL 쿼리에서 직접 DTO를 생성
  • new 연산자를 사용하여 쿼리 결과를 DTO로 직접 매핑.
  • SELECT new com.example.ParentDTO(p.id, p.name) FROM Parent p
profile
일상의 인연에 감사하라. 기적은 의외로 가까운 곳에 있을지도 모른다.

0개의 댓글