728x90
반응형
SMALL

이번 포스팅에는 OSIV가 무엇인지, 이게 어떤 성능에 영향을 주는지 알아보는 시간을 가져보자!

OSIV(Open Session In View)라는 이 녀석은, 스프링을 사용하면 이런 설정값으로 표현된다.

spring.jpa.open-in-view

근데, 이 값이 기본으로 `true`이다. 즉, 아무것도 설정하지 않으면 이 설정이 켜져있는건데 이게 켜져있으면 데이터베이스 커넥션이 반납되는 시점이 크게 달라진다. 

 

자, 다음 코드를 보자.

package cwchoiit.shoppingmall.service;

import cwchoiit.shoppingmall.domain.Member;
import cwchoiit.shoppingmall.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional
    public Long signup(Member member) {
        validateDuplicateMember(member);

        memberRepository.save(member);
        return member.getId();
    }
}
  • 여기보면 signup() 이라는 메서드가 하나 있고, @Transactional 애노테이션이 달려있다.
  • JPA는 @Transactional 애노테이션이 붙어있는 메서드가 실행하는 시점에 커넥션을 받아온다. 그리고 일반적으로 @Transactional 애노테이션이 달린 메서드가 끝나면 커넥션을 반납하게 된다.
  • 그런데, 이 OSIV가 켜져있으면 메서드가 끝나는 시점에 커넥션을 반환하지 않는다. 
  • 그럼 언제 반환하지? 자, 이 signup()을 호출한 컨트롤러로 넘어가보자.
@PostMapping("/members/new")
public String create(@Validated MemberForm memberForm, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        return "members/createMemberForm";
    }

    Address address = new Address(memberForm.getCity(), memberForm.getStreet(), memberForm.getZipCode());

    Member member = Member.builder()
            .name(memberForm.getName())
            .address(address)
            .build();

    memberService.signup(member);
    return "redirect:/";
}
  • 이 컨트롤러에서 signup()을 호출하는데, 호출하고 다시 돌아오는 시점에도, 커넥션은 반환되지 않는다. 그리고 사용자에게 최종적으로 화면이 뿌려지고 완전히 응답이 끝나면! 그때 커넥션을 반납한다.
  • 왜 그러냐면, 만약, 데이터베이스를 통해 데이터를 받아왔는데 이 데이터 중 프록시를 초기화해야 하는 데이터가 있을수도 있기 때문에 그때까지 영속성 컨텍스트를 살려두어야만 초기화가 가능하기 때문이다.
  • 프록시 초기화는 반드시 영속성 컨텍스트가 관리하고 있는 엔티티여야만 가능하다!

그럼, 결국 OSIV가 켜져있으면, API라면 호출한 곳으로부터 응답이 완전히 다 나가고 나서 커넥션이 반납되고, API가 아니라 뷰를 반환하는 경우라면 템플릿이 완전히 다 만들어지고 사용자에게 화면이 뿌려지는 시점에 커넥션이 반납된다.

 

그러니까, OSIV는 장단점이 있다. 컨트롤러나 뷰 레이어에서도 지연로딩을 처리할 수도 있다는 장점이 있지만 단점은 커넥션을 너무 오랫동안 유지하고 있다는 점이다. 그래서 자칫 잘못하면 커넥션이 반납이 너무 느려져서 말라버릴 수도 있다. 그럼 도대체 이 옵션은 켜야하나 꺼야하나? 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야 하는 단점이 있지만, 키면 커넥션을 너무 오랫동안 가지고 있다는 단점이 있다. 

 

 

내가 생각하는 좋은 방법은..

  • OSIV 옵션을 끈다.
  • 모든 지연로딩을 페치 조인이나, 중간 레이어를 두어서 컨트롤러나 뷰에서 처리할 필요가 없도록 데이터를 받아온다.

그래서, 커넥션을 너무 오랫동안 유지하고 있지 않도록 하는게 가장 좋은 방법인것 같다. 어차피 지연로딩을 초기화해야 하는 경우라면 페치 조인을 사용해서 처음부터 모든 데이터를 받아오도록 하면 될 것이고, 바로 지연로딩이 초기화가 필요한 게 아니라 특정 순간에만 필요한 경우 중간 레이어 하나를 만들어서 쿼리와 커맨드를 분리해서 한번 더 트랜잭션의 도움을 받으면 될 것 같다. 

 

근데 이게 뭐 딱 정해진 정답이 있는게 아니라 상황에 따라 잘 활용하면 될 것 같다. 예를 들어 이렇게 하는 것이다.

  • 고객 서비스의 실시간 APIOSIV를 꺼서 최대한 커넥션 반납을 빠르게 하고, ADMIN 화면과 같은 커넥션을 많이 사용하지 않는 곳에서는 OSIV를 키는 방식으로 유연하게 적용한다.

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

이번 포스팅에는 JPA를 통해 컬렉션을 조회할 때 최적화하는 방법을 설명한다. ToMany 관계에 있는 데이터를 가져올때 문제가 되는 부분들과 그 문제를 해결하는 방법을 설명한다.

 

우선, OneToMany 관계에 있는 OrderOrderItems를 API로 반환해보자.

 

1. 모든 엔티티를 제거하고 DTO로 변환하라

@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
    List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch());
    return orders.stream().map(OrderDto::new).toList();
}
  • 일단 @GetMapping으로 REST API 하나를 매핑시키고, 반환하는 데이터는 반드시 DTO로 변환하자.
  • 아 물론, 반환 데이터는 응답 공통 객체로 감싸는게 더 좋다. 그래야 전체 개수나 페이지 수 같은 메타데이터도 포함시킬 수 있기 때문이다. 여기서는 단순화를 위해 데이터만 반환하도록 했다.
@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) {
        this.orderId = order.getId();
        this.name = order.getMember().getName();
        this.orderDate = order.getOrderDate();
        this.orderStatus = order.getStatus();
        this.address = order.getDelivery().getAddress();

        this.orderItems = order.getOrderItems().stream().map(OrderItemDto::new).toList();
    }
}

@Data
static class OrderItemDto {
    private String itemName;
    private int orderPrice;
    private int count;

    public OrderItemDto(OrderItem orderItem) {
        this.itemName = orderItem.getItem().getName();
        this.orderPrice = orderItem.getOrderPrice();
        this.count = orderItem.getCount();
    }
}
  • 반환하려는 OrderDto는 이렇게 생겼다. 여기서 중요한 점은, OrderItems에 해당하는 데이터 역시 DTO로 변환해야 한다는 점이다. 엔티티를 DTO로 변환하라는 말은 껍데기만 DTO를 사용하라는 말이 아니라 모든 엔티티가 없어야 한다.
  • 그래서, orderItems 타입도 역시 DTO로 만들기 위해 OrderItemDto도 존재한다.

 

이 상태에서 REST API를 호출해보자. 아래와 같은 결과가 나온다.

[
  {
    "orderId": 1,
    "name": "userA",
    "orderDate": "2024-11-17T13:40:39.935598",
    "orderStatus": "ORDER",
    "address": {
      "city": "서울",
      "street": "1",
      "zipCode": "1111"
    },
    "orderItems": [
      {
        "itemName": "JPA BOOK 1",
        "orderPrice": 10000,
        "count": 1
      },
      {
        "itemName": "JPA BOOK 2",
        "orderPrice": 20000,
        "count": 2
      }
    ]
  },
  {
    "orderId": 2,
    "name": "userB",
    "orderDate": "2024-11-17T13:40:39.940763",
    "orderStatus": "ORDER",
    "address": {
      "city": "경기",
      "street": "2",
      "zipCode": "2222"
    },
    "orderItems": [
      {
        "itemName": "SPRING BOOK 1",
        "orderPrice": 10000,
        "count": 1
      },
      {
        "itemName": "SPRING BOOK 2",
        "orderPrice": 20000,
        "count": 2
      }
    ]
  }
]
  • 결과는 원하는대로 잘 나왔다

여기서 끝낼 수 없다. 우선, 엔티티는 모두가 다 지연로딩이기 때문에 이 API를 호출하는 순간, 정말 많은 쿼리가 나갈것이다. 왜냐하면 아직 페치 조인으로 쿼리 최적화를 한 상태가 아니기 때문에. 그래서 엔티티를 DTO로 변환하는 것까진 좋지만 이제 쿼리 최적화를 위해 페치 조인을 사용해보자.

 

2. 쿼리 최적화를 위해 페치 조인을 사용하라

이제 페치 조인을 사용해서, 조인을 함과 동시에 셀렉트 절에 필드에 값을 채워넣는 작업까지 한번의 쿼리로 끝내보자. JPA가 제공하는 막강한 기능이다.

OrderRepository 일부분

public List<Order> findAllWithItem() {
    return entityManager.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();
}
  • 다음 코드와 같이 관련된 모든 엔티티에 대해 페치 조인을 사용했다. 이러면 SQL문으로 조인을 하는것까진 똑같은데 조인을 한 다음 실제 그 데이터를 메모리에 올려서 필드에 퍼올려주게 된다. 
  • 그런데 여기서 문제는 바로, o.orderItems를 페치 조인하는 것이다. 이 녀석은 OneToMany 관계로 설정된 컬렉션이다. 컬렉션을 페치 조인하게 되면 데이터가 뻥튀기가 된다. 결과를 보자.

OrderController 일부분

@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
    return orderRepository.findAllWithItem().stream().map(OrderDto::new).toList();
}
  • 페치 조인을 사용하는 메서드를 호출한다. 이 녀석을 API로 호출해보자. 호출을 해보면 실제로 쿼리가 어떻게 나가는지 볼 수 있는데, 다음과 같이 나간다.
select
        o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zip_code,
        d1_0.status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zip_code,
        m1_0.name,
        o1_0.order_date,
        oi1_0.order_id,
        oi1_0.order_item_id,
        oi1_0.count,
        i1_0.item_id,
        case 
            when i1_1.item_id is not null 
                then 1 
            when i1_2.item_id is not null 
                then 2 
            when i1_3.item_id is not null 
                then 3 
        end,
        i1_0.name,
        i1_0.price,
        i1_0.stock_quantity,
        i1_1.artist,
        i1_1.etc,
        i1_2.author,
        i1_2.isbn,
        i1_3.actor,
        i1_3.director,
        oi1_0.order_price,
        o1_0.status 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id 
    join
        order_item oi1_0 
            on o1_0.order_id=oi1_0.order_id 
    join
        (item i1_0 
    left join
        album i1_1 
            on i1_0.item_id=i1_1.item_id 
    left join
        book i1_2 
            on i1_0.item_id=i1_2.item_id 
    left join
        movie i1_3 
            on i1_0.item_id=i1_3.item_id) 
        on i1_0.item_id=oi1_0.item_id
  • 쿼리는 매우 행복하게 딱 한개만 나간다. 이게 페치 조인의 힘이다. 근데 이 쿼리를 실제로 데이터베이스에서 날려보면 어떻게 될까? 결과는 다음과 같다.

  • 보다시피 Order 레코드는 ID가 1, 2로 2개뿐이지만, OneToMany 관계에 있는 OrderItems와 조인을 해버리니까 데이터가 4개로 뻥튀기 됐다. 왜냐하면 1번 주문이 가지고 있는 OrderItem이 2개다 보니까 1번 주문의 레코드가 2개가 나올수밖에 없다. 2번도 마찬가지 이유다.

이게 바로 OneToMany 관계에 있는 엔티티와 조인할 때 즉, 컬렉션을 조인할때 생기는 데이터 뻥튀기 현상이다. 이걸 가지고 페이징 처리를 한다면? 뭔가 문제가 생길 수 밖에 없다. 그래서 이를 해결하기 위해 JPA에서 무엇을 제공하냐? `DISTINCT` 라는 키워드를 제공한다.

 

"어? 데이터베이스에서 그냥 제공하는 키워드 아닌가요?" → 맞다. 맞는데, JPA가 추가적인 기능을 제공한다. 일단 사용을 해보자.

public List<Order> findAllWithItem() {
    return entityManager.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();
}
  • 다음과 같이 JPQLDISTINCT만 추가했다.
  • 실행한 다음 나간 쿼리를 한번 보자. 
select
        distinct o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zip_code,
        d1_0.status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zip_code,
        m1_0.name,
        o1_0.order_date,
        oi1_0.order_id,
        oi1_0.order_item_id,
        oi1_0.count,
        i1_0.item_id,
        case 
            when i1_1.item_id is not null 
                then 1 
            when i1_2.item_id is not null 
                then 2 
            when i1_3.item_id is not null 
                then 3 
        end,
        i1_0.name,
        i1_0.price,
        i1_0.stock_quantity,
        i1_1.artist,
        i1_1.etc,
        i1_2.author,
        i1_2.isbn,
        i1_3.actor,
        i1_3.director,
        oi1_0.order_price,
        o1_0.status 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id 
    join
        order_item oi1_0 
            on o1_0.order_id=oi1_0.order_id 
    join
        (item i1_0 
    left join
        album i1_1 
            on i1_0.item_id=i1_1.item_id 
    left join
        book i1_2 
            on i1_0.item_id=i1_2.item_id 
    left join
        movie i1_3 
            on i1_0.item_id=i1_3.item_id) 
        on i1_0.item_id=oi1_0.item_id
  • 이 나간 쿼리에도 DISTINCT가 붙어있다. 근데 이 쿼리 그대로 복사해서 데이터베이스에 실제로 날려보면 결과 어떻게 나올까? 안타깝게도 전혀 달라지지 않는다.

  • 이런 현상의 이유는 데이터베이스의 DISTINCT는 정말 레코드의 모든 값이 다 똑같아야 중복으로 판단하고 제거해준다. 그런데 위 결과를 보면, 1번 주문의 2개의 레코드는 Order 관련 데이터만 동일하고, 나머지 값은 다르다. OrderItem이 다르니까.

그런데 API 날리고 받은 응답을 보면, 데이터는 두개만 보여진다.

[
  {
    "orderId": 1,
    "name": "userA",
    "orderDate": "2024-11-17T14:24:10.567046",
    "orderStatus": "ORDER",
    "address": {
      "city": "서울",
      "street": "1",
      "zipCode": "1111"
    },
    "orderItems": [
      {
        "itemName": "JPA BOOK 1",
        "orderPrice": 10000,
        "count": 1
      },
      {
        "itemName": "JPA BOOK 2",
        "orderPrice": 20000,
        "count": 2
      }
    ]
  },
  {
    "orderId": 2,
    "name": "userB",
    "orderDate": "2024-11-17T14:24:10.571629",
    "orderStatus": "ORDER",
    "address": {
      "city": "경기",
      "street": "2",
      "zipCode": "2222"
    },
    "orderItems": [
      {
        "itemName": "SPRING BOOK 1",
        "orderPrice": 10000,
        "count": 1
      },
      {
        "itemName": "SPRING BOOK 2",
        "orderPrice": 20000,
        "count": 2
      }
    ]
  }
]
  • 이게 JPA가 추가적으로 해주는 기능이다. JPA는 DISTINCT를 사용하면, 우선 SQL에도 그 키워드를 붙여준다. 그래서 만약, 레코드의 모든 값이 동일한 레코드가 있다면 하나만 보여주게 해준다.
  • 두번째로는, JPA가 추가적으로 제공하는 기능인데 가져온 데이터를 메모리에 퍼 올릴때, 반환 엔티티에 대한 ID가 동일하다면 (여기서는 Order) 그 값은 중복된 것으로 판단하고 메모리에 올리지 않는다. 이게 JPA가 해주는 추가적인 기능이다.

 

그래서 결론적으로 깔끔하게 DTO와 페치조인을 사용해서 일대다 조인을 사용할때도 깔끔하게 데이터를 가져올 수 있게 됐다. 그런데, 이때 정말 치명적인 문제가 하나 발생한다. 일대다 페치 조인을 하는 순간 페이징 처리가 불가능하다. 

 

2-1. 일대다 페치 조인을 할 때 페이징이 불가능한 문제

바로 위에서 일대다 페치 조인을 할 때 치명적인 문제를 말했다. 즉, 페이징 처리가 불가능해진다. 바로 코드로 보자.

페이징 처리라는 게 별게 아니라 다음과 같이 사용하면 된다.

public List<Order> findAllWithItem() {
    return entityManager.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(1)
            .setMaxResults(1000)
            .getResultList();
}
  • setFirstResult(1), setMaxResults(1000) 을 사용해서 0번은 건너뛰고 1번부터 1000개를 가져오는 쿼리를 작성했다. 그러면 여기서 드는 생각은, 아 지금 현재 데이터는 2개니까 앞에꺼 하나를 건너뛰고 뒤에꺼 한 개만 가져오겠군?! 이라고 생각이 든다.

근데 이거 실제로 실행해보자. 실행해보면, 날라가는 쿼리가 다음과 같다.

select
        distinct o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zip_code,
        d1_0.status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zip_code,
        m1_0.name,
        o1_0.order_date,
        oi1_0.order_id,
        oi1_0.order_item_id,
        oi1_0.count,
        i1_0.item_id,
        case 
            when i1_1.item_id is not null 
                then 1 
            when i1_2.item_id is not null 
                then 2 
            when i1_3.item_id is not null 
                then 3 
        end,
        i1_0.name,
        i1_0.price,
        i1_0.stock_quantity,
        i1_1.artist,
        i1_1.etc,
        i1_2.author,
        i1_2.isbn,
        i1_3.actor,
        i1_3.director,
        oi1_0.order_price,
        o1_0.status 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id 
    join
        order_item oi1_0 
            on o1_0.order_id=oi1_0.order_id 
    join
        (item i1_0 
    left join
        album i1_1 
            on i1_0.item_id=i1_1.item_id 
    left join
        book i1_2 
            on i1_0.item_id=i1_2.item_id 
    left join
        movie i1_3 
            on i1_0.item_id=i1_3.item_id) 
        on i1_0.item_id=oi1_0.item_id
  • ..? limit, offset은 어디있는거지? 

페이징을 추가했는데 날라가는 쿼리에는 페이징 처리를 위한 limit, offset이 없다. 굉장히 심각한거다. 그리고 로그를 보면 이러한 로그가 보인다. 컬렉션 페치조인과 같이 firstResult, maxResults가 사용됐다고 말해주고, 이 데이터를 메모리에 올려서 페이징 처리를 하겠다는 무시무시한 로그가 찍힌다.

2024-11-17T14:34:19.126+09:00  WARN 42800 --- [ShoppingMall] [nio-8080-exec-1] org.hibernate.orm.query                  : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
  • 이 로그가 왜 무시무시하냐? 데이터가 만개면? 십만개면? 메모리에 올리는 순간 OOM이 터질 수 있다. 실제 운영중인 서비스라면..? 끔찍하다.
  • 아니 그럼 도대체 왜..? 메모리에 올려서 페이징 처리를 하지..? 왜 Hibernate는 이런 극단적인 선택을 했을까?

2-2. 일대다 페치 조인일 때 페이징 시도 시 Hibernate가 극단적 선택을 한 이유

일대다 페치 조인을 했을 때, 왜 도대체 메모리에 올려서 페이징을 할까? 

 

 

아까 일대다 페치 조인을 했을 때 날라가는 쿼리를 직접 데이터베이스에서 실행해봤을 때 이런 결과를 얻었다.

  • 실제 데이터베이스에 있는 주문 데이터는 딱 2개다. ID가 (1, 2)
  • 그렇지만, 그 주문 데이터는 주문 아이템 데이터를 두개씩 가지고 있기 때문에 위 결과처럼 데이터가 뻥튀기 된다.
  • 이 상태에서 limit, offset을 적용하면 정확한 데이터가 나올까? 아니다. 실제로는 주문은 2개뿐인데, 지금 상태에서 offset 1 limit 1000을 쿼리에 붙여 실행하면 위 결과에서 ORDER_ID 컬럼 기준으로 맨 위에 데이터를 제외하고 (1, 2, 2) 세 개의 데이터가 나올 것이다. 그 말은 잘못된 페이징 처리가 된다는 뜻이다.
  • 그런데, JPA가 어떤 도움을 주냐? 이 상태의 데이터를 메모리에 올릴때 같은 아이디는 제거해버린다. 그래서 메모리에 올라간 시점은 뻥튀기 데이터가 사라진 상태가 된다. 그렇기 때문에 메모리에 올린 상태에서 페이징 처리를 꾸역꾸역 해주는 것이다.

이러한 이유때문에 결국 일대다 페치 조인을 사용할때 페이징 처리를 하면 메모리에 올려서 페이징 처리를 할 수 밖에 없게 되는 것이다.

그리고 또 한가지 주의할 점이 있다. 일대다 페치 조인은 딱 한개만 사용해야 한다. 그러니까 위 예시에서는 Order입장에서 일대다 관계가 OrderItems였는데, 여기에 만약에 추가적으로 일대다 관계가 있는 녀석(A)이 있다고 가정하면, 페치 조인 시 OrderItems, A 이 두개를 같이 페치조인하면 안된다는 뜻이다. 이유는 위에서 이미 다 봤다. 데이터가 뻥튀기가 되는데, 이건 배로 뻥튀기가 된다. 데이터 정합성의 문제가 생길 소지가 너무 많고, 이렇게 굳이 하지 않아도 충분히 원하는 데이터를 뽑아낼 수 있는데 리스크는 크고 리턴은 적다. 

 

 

3. 일대다 페치조인 시 페이징과 한계 돌파

일대다 페치조인시 생기는 페이징 처리의 문제를 해결하는 방법을 소개한다. 결론부터 말하면 페이징이 필요한 쿼리에서 일대다 관계에 있는 녀석들은 다 지워버리면 된다. 그럼 페이징에 아무런 문제가 발생하지 않는다. 그럼 여기서 남은 문제는 페치 조인을 하지 않으면 일대다 관계에 있는 녀석들도 API 응답을 반환할때 반환 데이터로 이 컬렉션이 필요한 경우엔 프록시 초기화를 해야하니까 쿼리가 여러번 나갈텐데 이걸 어떻게 해결할까?

 

큰 그림으로 이렇게 정리할 수 있겠다. 다음 단계를 따르면 된다.

  1. ToMany 관계에 있는, 즉, 컬렉션은 모두 페치 조인에서 제거한다.
  2. ToOne 관계에 있는 엔티티들만 몇개가 됐든 상관없이 모두 페치조인한다.
  3. `default_batch_fetch_size` 옵션을 적절하게 부여해서 한번에 이 속성에 적용한 개수만큼 데이터를 가져오게 한다.

 

@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_1(@RequestParam(value = "offset", defaultValue = "0") int offset,
                                 @RequestParam(value = "limit", defaultValue = "100") int limit) {
    return orderRepository.findAllWithMemberDelivery(offset, limit).stream()
            .map(OrderDto::new)
            .toList();
}
  • ToOne 관계에 있는 엔티티는 몇개가 됐든 페치 조인을 해도 상관없고 페이징도 아주 잘 작동한다. 데이터 뻥튀기란 존재할 수 없기 때문에 그렇다. 
  • 그렇기 때문에 위 코드처럼 offset, limit을 쿼리 파라미터로 받아서 페이징을 처리할수 있는 메서드를 하나 만들자.
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
    return entityManager.createQuery(
                    "SELECT o " +
                            "FROM Order o " +
                            "JOIN FETCH o.member m " +
                            "JOIN FETCH o.delivery d", Order.class)
            .setFirstResult(offset)
            .setMaxResults(limit)
            .getResultList();
}

 

  • 위에서 말한대로, ToOne 관계에 있는 엔티티들(Member, Delivery)는 모두 페치 조인을 했고 컬렉션 관계에 있는 엔티티(OrderItems)는 페치 조인에서 제거했다.
  • 이 상태로 실행해보자. 결과는 다음과 같다.
[
    {
        "orderId": 2,
        "name": "userB",
        "orderDate": "2024-11-17T15:26:12.224888",
        "orderStatus": "ORDER",
        "address": {
            "city": "경기",
            "street": "2",
            "zipCode": "2222"
        },
        "orderItems": [
            {
                "itemName": "SPRING BOOK 1",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "SPRING BOOK 2",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    }
]
  • 아주 깔끔하게 딱 페이징이 된 모습이다.
  • 그런데 이걸 실행했을 때 날라가는 쿼리를 보면 굉장히 많이 나갈 수 밖에 없다. 왜냐하면 OrderItems는 지연로딩이고 이 값을 초기화하기 위한 쿼리가 모두 나가야하기 때문이다.

 

그럼 여기서 일대다 관계에 있는 데이터인 OrderItems는 페치 조인으로 적용하지 않았기 때문에 반환값으로 이 데이터를 보여주려면 프록시 초기화가 진행된다. 초기화하기 위해 추가적으로 날라가는 많은 쿼리들을 어떻게 최적화하면 될까? 다음과 같이 적용하면 된다.

application.yaml

...
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100
...
  • jpa.properties.hibernate 하위에 다음과 같은 프로퍼티가 있다: `default_batch_fetch_size`.
  • 이 값을 적절하게 넣어주면 프록시 초기화를 할 때, 한번에 저 개수만큼 초기화하는 쿼리가 날라간다. 
  • 바로 쿼리로 확인해보자.

 

우선, 가장 먼저 OrderMember, Delivery를 조인하는 쿼리가 나간다. 

    select
        o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zip_code,
        d1_0.status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zip_code,
        m1_0.name,
        o1_0.order_date,
        o1_0.status 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id 
    offset
        ? rows 
    fetch
        first ? rows only

 

그 다음은, OrderItems의 값을 가져와야 하므로 프록시 초기화를 하고 초기화 하기 위해 쿼리를 날리는데, 이렇게 날라간다.

    select
        oi1_0.order_id,
        oi1_0.order_item_id,
        oi1_0.count,
        oi1_0.item_id,
        oi1_0.order_price 
    from
        order_item oi1_0 
    where
        oi1_0.order_id=?

ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ

    select
        i1_0.item_id,
        case 
            when i1_1.item_id is not null 
                then 1 
            when i1_2.item_id is not null 
                then 2 
            when i1_3.item_id is not null 
                then 3 
        end,
        i1_0.name,
        i1_0.price,
        i1_0.stock_quantity,
        i1_1.artist,
        i1_1.etc,
        i1_2.author,
        i1_2.isbn,
        i1_3.actor,
        i1_3.director 
    from
        item i1_0 
    left join
        album i1_1 
            on i1_0.item_id=i1_1.item_id 
    left join
        book i1_2 
            on i1_0.item_id=i1_2.item_id 
    left join
        movie i1_3 
            on i1_0.item_id=i1_3.item_id 
    where
        i1_0.item_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  • 참고로, 나는 ItemAlbum, Book, Movie를 조인 전략을 사용해서 상속 관계 매핑을 정의했기 때문에 OrderItem을 가져오고 OrderItem에 있는 Item의 실제 데이터를 가져오기 위해 Album, Book, Movie와 조인하는 쿼리 한번이 더 나간다. 만약, ItemAlbum, Book, Movie를 싱글 테이블 전략을 사용했다면 조인하는 쿼리가 나가지 않을 것이다. 이건 테이블 매핑 전략을 어떻게 사용하느냐에 따라 달라진다.
  • 그래서, OrderItem을 가져오는 쿼리를 한번 날린다. 근데 OrderItem에는 Item이 있고 이 ItemAlbum, Book, Movie 중 하나이기 때문에 조인 쿼리를 하나 더 날리는데 그때, IN 키워드로 100개의 ID가 한번에 날라간다.
  • 이게 바로 default_batch_fetch_size가 적용된 결과다. 만약, 이 값이 적용되지 않았다면, OrderItem에 있는 Item 마다마다 그 값을 가져오기 위한 쿼리가 나갔을 것이다.

 

일대다 페치조인의 한계돌파 결론

결론적으로, 이 일대다 관계를 가지고 있는 엔티티(OrderItems)가 있는 엔티티(Order)를 페치 조인으로 페이징 할 때는 1. 일대다 관계의 엔티티를 페치 조인에서 전부다 제거하고, 2. ToOne 관계에 있는 모든 엔티티는 페치조인을 사용해서 최대한 여러번의 쿼리가 나가는 것을 방지한 다음, 페이징을 처리해서 원하는 데이터를 원하는 수만큼 가져온다. 그런데 여기서 반환해야 하는 데이터에 컬렉션 데이터가 필요한 경우 그 값들을 채우기 위해 프록시 초기화가 발생하기에 추가적으로 나가는 쿼리를 최대한 최적화하기 위해, 3. default_batch_fetch_size 옵션을 설정해서 한번에 설정한 값만큼 데이터를 가져오도록 최적화한다. 

 

그리고 이 default_batch_fetch_size 옵션은 글로벌 옵션이다. 그래서 전체에 적용이 되는데, 만약에 특정 부분만 적용하고 싶은 경우 이렇게 하면 된다.

 

Order 엔티티 일부분

package cwchoiit.shoppingmall.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.BatchSize;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

@Entity
@Getter
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 생성 메서드를 만들었으면 그 메서드로만 인스턴스를 생성할 수 있도록 막아야 함
public class Order {
    ...

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
    
    ...
}
  • 이런식으로 필드에 @BatchSize 애노테이션을 붙여주면 된다. 근데 이건 컬렉션 필드에는 이렇게 적용하면 되는데 ToOne에는 이렇게 적용하면 안되고 다음과 같이 적용해야 한다.

OrderItem 일부분

package cwchoiit.shoppingmall.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.BatchSize;

@BatchSize(size = 100)
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {

    ...

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    ...
}
  • 이렇게 OrderItemToOne 관계로 Order, Item이 있는데 이 녀석들에도 배치 사이즈를 부여하고 싶으면 클래스 레벨에 @BatchSize를 붙여주면 된다.

 

근데 보통은 그냥, 글로벌로 적용해두면 좋다. 그냥 기본으로 깔고 간다고 생각하면 좋다.

참고로, 이 default_batch_fetch_size값의 최대값은 1000이다.

 

 

자, 이렇게 일대다 페치 조인의 한계까지 해결해보고 지연로딩도 어떻게 해결해야 하는지 Part.1부터 걸쳐서 알아봤다. 여기까지 완벽히 이해하면 JPA로 성능 문제의 90%는 해결할 수 있다. 

 

 

번외: 컬렉션도 DTO로 바로 조회하기

이 컬렉션을 데이터베이스에서 가져와야 할 때도 SELECT절에 DTO로 직접 조회가 가능하다. 한번 이 부분도 다뤄보자. 

Part.1 에서 만들었었던 OrderQueryRepository를 기억하는가? 어떤 특정 화면에 종속적인 그러니까 순수한 Repository가 아니라 어딘가에 핏하게 종속되는데 사용되는 쿼리만 따로 모아두는 레포지토리를 만들었었다. 여기서 DTO로 직접 받아서 딱 필요한 필드들만 메모리에 올리게끔 최적화를 해본 적이 있는데 이걸 그대로 사용해보자.

 

우선, DTO부터 만들어야 한다.

package cwchoiit.shoppingmall.repository.order;

import cwchoiit.shoppingmall.domain.Address;
import cwchoiit.shoppingmall.domain.OrderStatus;
import lombok.Data;

import java.time.LocalDateTime;
import java.util.List;

@Data
public class OrderQueryDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItemQueryDto> orderItems;

    public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }
}
package cwchoiit.shoppingmall.repository.order;

import lombok.Data;

@Data
public class OrderItemQueryDto {

    private Long orderId;
    private String itemName;
    private int orderPrice;
    private int count;

    public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}
  • DTO를 두개 만들었다. 왜냐하면 Order를 위한 DTOOrderItems를 위한 DTO.

OrderQueryRepository

package cwchoiit.shoppingmall.repository.order;

import cwchoiit.shoppingmall.dto.SimpleOrderQuery;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager entityManager;

    public List<SimpleOrderQuery> findOrderByDTO() {
        return entityManager.createQuery(
                        "SELECT new cwchoiit.shoppingmall.dto.SimpleOrderQuery(o.id, m.name, o.orderDate, o.status, d.address) " +
                                "FROM Order o " +
                                "JOIN o.member m " +
                                "JOIN o.delivery d", SimpleOrderQuery.class)
                .getResultList();
    }

    public List<OrderQueryDto> findOrderQueries() {
        List<OrderQueryDto> results = entityManager.createQuery(
                        "SELECT new cwchoiit.shoppingmall.repository.order.OrderQueryDto(o.id, m.name, o.orderDate, o.status, o.delivery.address) " +
                                "FROM Order o " +
                                "JOIN o.member m " +
                                "JOIN o.delivery d", OrderQueryDto.class)
                .getResultList();

        results.forEach(r -> {
            List<OrderItemQueryDto> orderItems =  findOrderItems(r.getOrderId());
            r.setOrderItems(orderItems);
        });

        return results;
    }

    private List<OrderItemQueryDto> findOrderItems(Long orderId) {
        return entityManager.createQuery(
                "SELECT new cwchoiit.shoppingmall.repository.order.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count) " +
                        "FROM OrderItem oi " +
                        "JOIN oi.item i " +
                        "WHERE oi.order.id = :orderId", OrderItemQueryDto.class)
                .setParameter("orderId", orderId)
                .getResultList();
    }
}
  • 지금 추가한 부분은 딱 두 부분이다. 하나씩 살펴보자.
public List<OrderQueryDto> findOrderQueries() {
    List<OrderQueryDto> results = entityManager.createQuery(
                    "SELECT new cwchoiit.shoppingmall.repository.order.OrderQueryDto(o.id, m.name, o.orderDate, o.status, o.delivery.address) " +
                            "FROM Order o " +
                            "JOIN o.member m " +
                            "JOIN o.delivery d", OrderQueryDto.class)
            .getResultList();

    results.forEach(r -> {
        List<OrderItemQueryDto> orderItems =  findOrderItems(r.getOrderId());
        r.setOrderItems(orderItems);
    });

    return results;
}
  • 먼저, DTO로 직접 필요한 필드들만 받기 위해 SELECT절에 new 키워드를 사용해서 DTO로 직접 받고 있다. 그리고 이때도 마찬가지로 컬렉션 데이터는 조인해서 가져오지 않는다. 애시당초에 DTO로 조회를 할 때 컬렉션 데이터는 데이터베이스에서 바로 붙일수도 없다. 이건 위에서 말한 일대다 페치 조인을 할 때 페이징에 대한 한계를 돌파하는 방법이랑은 또 다른 얘기다. 그냥 DTO로 받아올때 필드에 컬렉션이 있으면 그 컬렉션에 데이터베이스에서 값을 한번에 넣어줄 수가 없다.
  • 그래서, 우선 컬렉션을 제외하고 DTO로 데이터를 다 받아오면 이 데이터를 가지고 루프를 돌아야 한다. 그 부분이 바로 다음 results.forEach(...) 이 부분이다.
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
    return entityManager.createQuery(
            "SELECT new cwchoiit.shoppingmall.repository.order.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count) " +
                    "FROM OrderItem oi " +
                    "JOIN oi.item i " +
                    "WHERE oi.order.id = :orderId", OrderItemQueryDto.class)
            .setParameter("orderId", orderId)
            .getResultList();
}
  • 그래서 이제 순회하면서 Order 레코드 하나하나가 이 메서드를 하나씩 실행한다. 이 메서드는 OrderItem으로부터 데이터를 받아오는데 이 역시 DTO로 직접 받아 온다.
  • 그런데, WHERE절에서 특정 OrderID 조건을 넣어서 해당하는 데이터만 가져온다.
  • 그래서 아래 코드를 보면, 순회하면서 실행한 후 이렇게 받아온 데이터를 r.setOrderItems(...)를 호출해서 넣어준다. 조금 귀찮지만 DTO로 직접 받아올때 컬렉션 데이터를 받을 수 있는 유일한 방법이다.
results.forEach(r -> {
    List<OrderItemQueryDto> orderItems =  findOrderItems(r.getOrderId());
    r.setOrderItems(orderItems);
});

 

 

그리고 컨트롤러에서 이 메서드를 이제 실행한다.

OrderController 일부분

@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4() {
    return orderQueryRepository.findOrderQueries();
}

 

실행 결과는 다음과 같다. 결과는 똑같이 나오겠지만 쿼리를 보자.

    select
        o1_0.order_id,
        m1_0.name,
        o1_0.order_date,
        o1_0.status,
        d1_0.city,
        d1_0.street,
        d1_0.zip_code 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id
            
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ

    select
        oi1_0.order_id,
        i1_0.name,
        oi1_0.order_price,
        oi1_0.count 
    from
        order_item oi1_0 
    join
        item i1_0 
            on i1_0.item_id=oi1_0.item_id 
    where
        oi1_0.order_id=?
        
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ

    select
        oi1_0.order_id,
        i1_0.name,
        oi1_0.order_price,
        oi1_0.count 
    from
        order_item oi1_0 
    join
        item i1_0 
            on i1_0.item_id=oi1_0.item_id 
    where
        oi1_0.order_id=?
  • 최초에 Order를 가져오는 쿼리는 당연히 나간다.
  • 그런데, 그 OrderItem들을 가져오는 쿼리가 개수만큼 나가고 있다. 어찌보면 당연한게 가져온 Order 데이터들로 순회하면서 OrderItemDTO로 받아오기 위해 호출하는 메서드가 있기 때문에 당연한건데 이것 역시 N + 1 문제이다.
  • 이 부분을 해결해야 한다.

 

다음 코드는 N + 1 문제를 해결하는 방법이다. 하나씩 보면서 살펴보자.

public List<OrderQueryDto> findOrderQueries2() {
    List<OrderQueryDto> results = entityManager.createQuery(
                    "SELECT new cwchoiit.shoppingmall.repository.order.OrderQueryDto(o.id, m.name, o.orderDate, o.status, o.delivery.address) " +
                            "FROM Order o " +
                            "JOIN o.member m " +
                            "JOIN o.delivery d", OrderQueryDto.class)
            .getResultList();

    List<Long> orderIds = results.stream().map(OrderQueryDto::getOrderId).toList();

    List<OrderItemQueryDto> orderItems = entityManager.createQuery(
                    "SELECT new cwchoiit.shoppingmall.repository.order.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count) " +
                            "FROM OrderItem oi " +
                            "JOIN oi.item i " +
                            "WHERE oi.order.id IN :orderIds", OrderItemQueryDto.class)
            .setParameter("orderIds", orderIds)
            .getResultList();

    /**
     * orderItem: OrderItemQueryDto(orderId=1, itemName=JPA BOOK 1, orderPrice=10000, count=1)
     * orderItem: OrderItemQueryDto(orderId=1, itemName=JPA BOOK 2, orderPrice=20000, count=2)
     * orderItem: OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 1, orderPrice=10000, count=1)
     * orderItem: OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 2, orderPrice=20000, count=2)
     *
     * 이렇게 orderItems 가 있으면, 이걸 orderId 로 groupingBy를 하면
     * <1, [OrderItemQueryDto(orderId=1, itemName=JPA BOOK 1, orderPrice=10000, count=1), OrderItemQueryDto(orderId=1, itemName=JPA BOOK 2, orderPrice=20000, count=2)]>
     * <2, [OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 1, orderPrice=10000, count=1), OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 2, orderPrice=20000, count=2)]>
     * 이렇게 된다.
     */
    Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
            .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));

    results.forEach(r -> r.setOrderItems(orderItemMap.get(r.getOrderId())));

    return results;
}

 

List<OrderQueryDto> results = entityManager.createQuery(
            "SELECT new cwchoiit.shoppingmall.repository.order.OrderQueryDto(o.id, m.name, o.orderDate, o.status, o.delivery.address) " +
                    "FROM Order o " +
                    "JOIN o.member m " +
                    "JOIN o.delivery d", OrderQueryDto.class)
    .getResultList();
  • 우선, findOrderQueries()와 동일하게 처음은 Order 관련 데이터를 DTO로 바로 받아온다. 이때, Member, Delivery와 같은 ToOne 관계만 조인하는 것 역시 동일하다.
List<Long> orderIds = results.stream().map(OrderQueryDto::getOrderId).toList();

List<OrderItemQueryDto> orderItems = entityManager.createQuery(
                "SELECT new cwchoiit.shoppingmall.repository.order.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count) " +
                        "FROM OrderItem oi " +
                        "JOIN oi.item i " +
                        "WHERE oi.order.id IN :orderIds", OrderItemQueryDto.class)
        .setParameter("orderIds", orderIds)
        .getResultList();
  • 이제 여기가 변하는 지점인데, 기존에는 가져온 Order 데이터들로 순회하면서 본인의 OrderItems를 하나씩 DTO로 받아왔다. 그러다보니 N + 1 문제가 발생한 것인데, 여기서는 그 부분을 해결하기 위해 가져온 Order 데이터들의 ID값을 모두 추출한 후 WHERE절에 IN 키워드로 한번에 모두 해당하는 데이터를 가져온다. 이렇게 N + 1 문제를 해결할 수 있다.
Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));

results.forEach(r -> r.setOrderItems(orderItemMap.get(r.getOrderId())));
  • 이제 OrderItems를 모두 받아왔으면, OrderQueryDtoOrderItems에 값을 넣어줘야 한다. 이때, 자기의 OrderItems을 컬렉션으로 넣어주기 위해 가져온 orderItems를 가지고 Collectors.groupingBy(OrderItemQueryDto::getOrderId)를 사용한다. 이건뭐냐면, 리스트를 맵으로 변경하는데 이때 그룹핑을 할 수가 있다. 각 리스트의 요소인 orderId로.
  • 이 부분의 자세한 동작은 주석으로 설명을 추가해놨다. 다시 한번 주석을 보여주면 이렇게 생각하면 된다.
/**
 * orderItem: OrderItemQueryDto(orderId=1, itemName=JPA BOOK 1, orderPrice=10000, count=1)
 * orderItem: OrderItemQueryDto(orderId=1, itemName=JPA BOOK 2, orderPrice=20000, count=2)
 * orderItem: OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 1, orderPrice=10000, count=1)
 * orderItem: OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 2, orderPrice=20000, count=2)
 *
 * 이렇게 orderItems 가 있으면, 이걸 orderId 로 groupingBy를 하면
 * <1, [OrderItemQueryDto(orderId=1, itemName=JPA BOOK 1, orderPrice=10000, count=1), OrderItemQueryDto(orderId=1, itemName=JPA BOOK 2, orderPrice=20000, count=2)]>
 * <2, [OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 1, orderPrice=10000, count=1), OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 2, orderPrice=20000, count=2)]>
 * 이렇게 된다.
 */

 

이러면, 이제 OrderItems가 몇개가 됐건, 딱 2번의 쿼리(Order 데이터를 가져오는 쿼리, 각 Order에 있는 OrderItems를 한번에 다 가져오는 쿼리)만 나가게 된다. 이렇게 컬렉션도 DTO로 직접 조회를 할 수 있고 N + 1 문제도 해결할 수 있게 된 것이다.

 

근데 지금 보면 SELECT절에 DTO로 직접 받아오는게 생각보다 만만치 않다. 귀찮기도 하고, 무엇보다 그냥 페치 조인을 사용해서 전체 필드에 값을 다 박을때보다 코드양이 너무 길어진다. 그래서 SELECT절에 DTO로 딱 필요한 것만 받아오면 아무래도 모든 데이터를 퍼올리는 페치 조인보다는 성능적인 부분에서 장점이 있지만, 코드의 가시성이나 더 많은 코드를 수행하는 부분에서는 성능적인 단점이 또 있다. 즉, 트레이드 오프가 있다는 말이다.

 

 

정리를 하자면

컬렉션이 있는 엔티티를 조회할 때 여러 난관이 있다. 대표적으로 페치 조인을 할 때 페이징이 불가능하다는 점이다. 그리고 이 문제를 해결하기 위해 페치 조인에서 컬렉션은 페치 조인에서 제외한 후 페이징을 하고 컬렉션 데이터를 초기화할 때 N + 1 문제를 BatchSize로 해결했다. 이게 가장 최적의 방법이고 사실 이 방법을 제외하고 방법도 없다. 

 

그리고, 마지막에는 DTO로 바로 조회하는 방법도 알아봤다. 이제 조회에 대해서는 다 배운것이다.

 

그럼 권장하는 방식을 정리해보면,

  • SELECT절에 엔티티 조회? DTO 직접 조회? → 엔티티 조회로 우선 접근
  • 컬렉션 최적화
    • 페이징 필요 → 컬렉션 데이터를 페치 조인에서 제외하고, default_batch_fetch_size를 사용해서 최적화
    • 페이징 필요 X → 페치 조인 사용
  • SELECT절에 엔티티로 조회하는 방식으로 해결이 안되면 DTO 조회 방식을 사용
  • 이도 저도 안되면 NativeSQL을 사용

 

728x90
반응형
LIST
728x90
반응형
SMALL

컨트롤러에서 엔티티를 직접 반환하지 마라! 이유는 너무 많다.

  • 엔티티를 반환하는 설계를 하면 엔티티의 변경이 생기면 API 스펙이 변경된다.
    • Member 엔티티의 name 필드를 username 필드로 변경하면, API 스펙 자체가 변경된다. 가져다가 사용하는 쪽은 매우 화가 난다.
  • 엔티티에 양방향 연관관계가 있는 경우, 무한 재호출로 인한 무응답 현상이 일어난다.
    • 이후에 코드로 직접 살펴보자. 
  • 엔티티에는 민감한 데이터가 있기도 하고, 굳이 외부로 노출시키지 않아도 되는 데이터가 있는데 그런 데이터까지 노출된다.
    • @JsonIgnore 애노테이션으로 막으면 되잖아요? → 다른 곳에서는 그 필드가 필요한 경우엔 어떡할래?
  • 지연로딩으로 설정한 연관 엔티티를 처리하기가 매우 귀찮아진다. 

 

컨트롤러에서 엔티티를 직접 반환하면 생기는 문제들

이런 여러 문제가 있고 엔티티를 직접 노출하지 않는 코드를 작성해서 더 우아하게 JPA를 사용하자. 다음 코드를 보자.

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1() {
        return orderRepository.findAllByCriteria(new OrderSearch());
    }
}
  • Order 라는 엔티티를 직접 반환하고 있다. 일단 이걸 실행해보자.
  • 아래와 같은 에러가 발생하고 뭔가 제대로 동작하지 않는다.
Ignoring exception, response committed already: org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Document nesting depth (1001) exceeds the maximum allowed (1000, from `StreamWriteConstraints.getMaxNestingDepth()`)

 

이게 무슨 일이냐면, Order로 들어가보자.

@Entity
@Getter
@Table(name = "orders")
@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;

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

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

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    ....
}
  • OrderMember를 가지고 있다. Member로 들어가보자.
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table(
        indexes = {
                @Index(name = "idx_name", columnList = "name"),
                @Index(name = "idx_name_address", columnList = "name, city")
        }
)
public class Member {

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

    private String name;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    @Builder.Default
    private List<Order> orders = new ArrayList<>();


    public void change(String name) {
        this.name = name;
    }
}
  • MemberOrder를 가지고 있다. 자꾸 서로가 서로를 호출하다가 더 이상 못하겠다! 라고 말하는 중이다.
  • 이게 맨 위에서 설명한 무한 재호출이다.

 

그럼 이제, 이걸 해결하기 위해 혹시라도 @JsonIgnore 애노테이션을 사용했다고 해보자. 다음과 같이 말이다.

Order 일부분

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

...

 

이번에는 응답은 받았는데 뭔가 또 문제가 생겼다.

 

어떤 문제일까? 에러 내용은 다음과 같다.

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0]->cwchoiit.shoppingmall.domain.Order["member"]->cwchoiit.shoppingmall.domain.Member$HibernateProxy$UdxcWMDQ["hibernateLazyInitializer"])
...
  • 결론부터 말하면 지연로딩으로 처리한 연관관계 엔티티를 반환하려 할 때 나 지연로딩이라 데이터를 돌려줄 수 없어! 라고 말하는 것이다.
  • 이렇듯, 엔티티를 직접 노출하면 지연 로딩을 원하든 원치않든 무조건 다뤄야 한다는 또 하나의 단점이 있다.

그럼 이제 이 여러 문제를 어떻게 해결하면 될까? 

 

1. 엔티티를 DTO로 변환

우선, 가장 먼저 엔티티를 전부 제거해버려라. 그리고 엔티티를 반환하지 말고 DTO로 변환해서 반환해라. 다음 코드를 보자.

@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDTO> ordersV2() {
    return orderService.findOrders(new OrderSearch()).stream()
            .map(SimpleOrderDTO::new)
            .toList();
}
  • OrderController에서 주문 데이터들을 반환하는데 반환값이 List<SimpleOrderDTO>이다. 즉, 이젠 엔티티를 반환하지 않고 DTO로 반환한다. (물론, 이 데이터를 응답 공통 객체로 감싸서 count, page, maxResults 등 추가 정보를 같이 넘기는게 일반적이다)
  • 그리고 DTO는 어떻게 생겼냐면, 별게 없다. 
@Data
static class SimpleOrderDTO {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    public SimpleOrderDTO(Order order) {
        this.orderId = order.getId();
        this.name = order.getMember().getName();
        this.orderDate = order.getOrderDate();
        this.orderStatus = order.getStatus();
        this.address = order.getMember().getAddress();
    }
}
  • 이너 클래스로 간단하게 만들어봤다. 다른 방식으로 해도 좋다. 
  • DTO는 곧 API 스펙이 되면 된다. 이제 API 스펙은 데이터를 orderId, name, orderDate, orderStatus, address 외 다른것은 넘기지 않게 서로 협의를 하면 끝이다. 이러면 Order 엔티티에 변경이 생겨도 이 API 스펙만 유지해줄 수 있다면 아무런 문제도 발생하지 않게 된다.

 

그럼 실제로 결과를 보자. 아래와 같이 데이터가 잘 나간다. 

 

 

그럼 이대로 괜찮을까? 아니다. 왜냐? 지연 로딩에 대한 문제가 여전히 남아있다. 나간 쿼리를 로그로 확인해보자.

 

  • 우선, 첫번째로 Order에 대해 조회를 하기 때문에 당연히 Order 관련 쿼리가 처음으로 나간다. 그리고 그 쿼리엔 Member와 조인하는 것도 있기 때문에 멤버를 조인한다.
  • 그런데 이게 조인을 한다고 해서 바로 지연로딩으로 설정한 연관관계들의 데이터를 다 가져오는 게 아니라, 지연로딩으로 설정한 녀석들은 프록시로 만들어 돌려준다. 이게 JPA의 규칙이다. (페치 조인과 헷갈리면 안된다. 애시당초에 데이터베이스에는 페치 조인이라는 개념 자체가 없다. 이 페치 조인은 JPA에서 사용하는 것이다)
  • 그리고 실제로 이 프록시를 초기화할때, 그러니까 위 코드에서는 엔티티를 DTO로 변환할 때 생성자에서 order.getMember().getName()과 같은 메서드를 실행하는 순간, JPA는 "어? 멤버가 필요하구나? 초기화 해야겠다!"라고 판단하고 해당 데이터를 가져오기 위해 쿼리를 날린다.
  • 그럼 결론적으로 Order에 대한 조회를 했고 데이터가 2개가 나왔다. 그 각각의 레코드에는 멤버 관련 데이터도 있어야 하지만 우선은 지연로딩이기 때문에 바로 데이터를 가져오는게 아니라 프록시로 만들어둔다. 그리고 이후에 해당 프록시를 초기화하는 순간, 그 데이터를 뽑아오기 위해 쿼리를 날리게 된다. 그 얘기는 Order가 10개고 각각의 주문이 모두 다 다른 유저라면 1 + 10개의 쿼리가 나가는 것이다. 이게 바로 N + 1 문제이고. 만약, 지연 로딩으로 설정한 연관관계가 더 많고 그 연관관계의 엔티티를 모두 프록시 초기화를 한다면 10개보다 더 나갈것이다.

 

결론은, 엔티티를 DTO로 변환하는 것으로 API 스펙이 바뀌는 문제를 해결하고 불필요한 데이터를 외부로 노출하지 않게 됐지만 여기서 끝내면 안된다는 것이다!

 

2. 페치조인을 사용해 N +1 문제 해결 

이제 정말 JPA에서 정말 중요한 페치 조인을 사용해서 딱 한번의 쿼리로 원하는 데이터를 다 가져와보자!

아래와 같이 GET 매핑을 하나 더 만들자.

@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDTO> ordersV3() {
    return orderRepository.findAllWithMemberDelivery().stream()
            .map(SimpleOrderDTO::new)
            .toList();
}

 

그리고, OrderRepository에서 다음과 같은 메서드를 하나 만든다.

public List<Order> findAllWithMemberDelivery() {
    return entityManager
            .createQuery(
                    "SELECT o " +
                            "FROM Order o " +
                            "JOIN FETCH o.member m " +
                            "JOIN FETCH o.delivery d", Order.class)
            .getResultList();
}

 

  • JPQL 부분을 보면, JOIN FETCH 라는 키워드가 있다. 그냥 JOIN이 아니다! 
  • JOIN FETCHJPA에서 제공해주는 기능인데 조인을 한 후 데이터를 모두 넣어서 한번에 돌려준다.
  • 그래서 지연로딩의 프록시 데이터도 프록시 초기화를 할 필요가 없어진다. 

 

나가는 쿼리를 한번 확인해보자! 아래와 같이 딱 한번의 쿼리로 모든 데이터를 다 받아왔고, 더 이상 쿼리가 나가지 않는다.

 

 

V2와 달랐던 점은, JOIN을 한다고 해도 그 엔티티가 지연로딩이면 바로 데이터를 넣어주는 게 아니라 프록시로 만들어서 실제로 필요한 시점에 메서드를 호출하면 그때 비로소 프록시가 초기화 되면서 그 값을 가져오기 위해 쿼리를 추가적으로 날렸는데 지금은 아예 처음부터 데이터를 다 가져와버린다. 이렇게 해서 N+1 문제를 해결할 수 있다. 

 

그런데, 한가지만 더 최적화해보자. 지금은 모든 필드가 SELECT절에 다 걸린다. 막상 필요한 데이터만 있을 수도 있는데 이건 다 걸려있기 때문에 이 부분마저 최적화해보자.

 

3. SELECT 절을 DTO로 받기

일단 위에서 사용한 페치 조인과 컨트롤러에서 엔티티를 직접 반환하는 게 아니라 DTO로 변환하여 반환하는 이 두가지 기법을 사용하면 거의 모든 대부분의 성능 문제? API 스펙 문제? 해결 된다. 근데, 위 쿼리를 보면 알겠지만 모든 필드를 다 SELECT절에서 받기 때문에 아무렴 DB에서 데이터를 메모리에 많이 퍼올리게 된다. 

 

만약, 어떤 유저가 주문한 [주문 내역]이라는 화면에 들어갔을때 보여져야 할 필드들이 주문번호, 주문자명, 배달위치, 주문일시, 주문상태 이렇게 딱 5개만 필요하다고 해보자. 그럼 저 나머지 필드들은 의미없이 메모리에 데이터를 퍼올리는 셈이 된다. 

 

그래서, 이 경우에 SELECT절 자체를 DTO로 받아볼 수 있다. 다음 코드를 보자.

 

OrderRepository 일부분

public List<SimpleOrderQuery> findOrderByDTO() {
    return entityManager.createQuery(
            "SELECT new cwchoiit.shoppingmall.dto.SimpleOrderQuery(o.id, m.name, o.orderDate, o.status, d.address) " +
                    "FROM Order o " +
                    "JOIN o.member m " +
                    "JOIN o.delivery d", SimpleOrderQuery.class)
            .getResultList();
}
  • 지금 보면, SELECT 절에 newDTO 클래스를 사용하고 있다. 그래서 딱 원하는 데이터만 넣어주고 있다. 
  • 이렇게 해서 반환 자체를 저 DTO의 리스트로 반환하면 된다.

SimpleOrderQuery

package cwchoiit.shoppingmall.dto;

import cwchoiit.shoppingmall.domain.Address;
import cwchoiit.shoppingmall.domain.OrderStatus;
import lombok.Data;

import java.time.LocalDateTime;

@Data
public class SimpleOrderQuery {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    public SimpleOrderQuery(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }
}
  • 이렇게 DTO를 만들었다.

OrderController 일부분

@GetMapping("/api/v4/simple-orders")
public List<SimpleOrderQuery> ordersV4() {
    return orderRepository.findOrderByDTO();
}
  • 컨트롤러는 단지 위임만 하고 있다.

 

이렇게 하고 실행해보면, 다음과 같이 딱 우리가 필요한 필드들만 SELECT절에서 받아오고 있음을 확인할 수 있다. 그리고 더이상의 쿼리는 나가지 않는다. "어? 페치 조인이 아닌데 왜 지연로딩을 초기화하는 쿼리가 안나가요?"DTOSELECT절을 받을때, 생성자로 넘겨주는 값 자체를 넘겼으니 그때 그 값을 가져와야 하므로 이미 쿼리를 날릴때 필요한 필드들을 모두 메모리에 퍼 올리게 된다.

 

 

이러면, 정말 극한의 최적화가 완성이 되는데.. 이 방법은 트레이드 오프가 있다.

  • SELECT절이 지저분해진다. 아래와 같이 패키지 명까지 모두 작성을 해줘야하는 번거로움이 있다.

  • Repository의 순수함이 사라진다.
    • 이 말은 무슨말이냐면, Repository는 결국 엔티티에 의존해야 한다. 그 외 어떤것에도 의존하지 않는게 가장 좋은 방법이다. 그런데 지금 같은 경우, 좁게 보면 DTO(SimpleOrderQuery)에 의존하고 있으며 넓게 보면 화면 하나에 의존하고 있다.
    • 화면 하나에 의존한다는 말은 우리가 [주문 내역]이라는 화면에서 필요한 데이터만을 위해 지금 이 쿼리를 만들었단 뜻이다. 
    • 다음 전체 코드를 보자. 이 메서드가 만들어지기 전까지 이 레포지토리는 오로지 Order 라는 엔티티에만 의존하고 있었다.

OrderRepository

package cwchoiit.shoppingmall.repository;

import cwchoiit.shoppingmall.domain.Order;
import cwchoiit.shoppingmall.dto.OrderSearch;
import cwchoiit.shoppingmall.dto.SimpleOrderQuery;
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;

import java.util.ArrayList;
import java.util.List;

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager entityManager;

    public void save(Order order) {
        entityManager.persist(order);
    }

    public Order findById(Long id) {
        return entityManager.find(Order.class, id);
    }

    public List<Order> findAllByCriteria(OrderSearch orderSearch) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Order> cq = cb.createQuery(Order.class);

        Root<Order> o = cq.from(Order.class);
        Join<Object, Object> m = o.join("member", JoinType.INNER);

        List<Predicate> predicates = new ArrayList<>();

        if (orderSearch.getOrderStatus() != null) {
            Predicate status = cb.equal(o.get("status"), orderSearch.getOrderStatus());
            predicates.add(status);
        }

        if (StringUtils.hasText(orderSearch.getMemberName())) {
            Predicate name = cb.like(m.get("name"), "%" + orderSearch.getMemberName() + "%");
            predicates.add(name);
        }

        cq.where(cb.and(predicates.toArray(new Predicate[0])));

        return entityManager
                .createQuery(cq)
                .setMaxResults(1000)
                .getResultList();
    }

    public List<Order> findAllWithMemberDelivery() {
        return entityManager.createQuery(
                        "SELECT o " +
                                "FROM Order o " +
                                "JOIN FETCH o.member m " +
                                "JOIN FETCH o.delivery d", Order.class)
                .getResultList();
    }

    public List<SimpleOrderQuery> findOrderByDTO() {
        return entityManager.createQuery(
                        "SELECT new cwchoiit.shoppingmall.dto.SimpleOrderQuery(o.id, m.name, o.orderDate, o.status, d.address) " +
                                "FROM Order o " +
                                "JOIN o.member m " +
                                "JOIN o.delivery d", SimpleOrderQuery.class)
                .getResultList();
    }
}
  • 전부 다 반환값으로 Order 엔티티를 반환하고 있고, 사실 이게 가장 모범적인 방식이다. 마지막 findOrderByDTO()는 오로지 하나의 화면을 위해서 만들어진 메서드이지 범용적으로 사용할 수 없다.
  • 그럼 방안은 없을까?

방안

그래서, 이런 경우 방안이 있는데, 이렇게 가장 기본이 되는 Repository는 순수한 Repository로 남겨두고 저런 특정 화면에 필요한 최적화된 쿼리들을 모아두는 Repository를 하나 더 만드는 것이다.

 

OrderQueryRepository

package cwchoiit.shoppingmall.repository.order;

import cwchoiit.shoppingmall.dto.SimpleOrderQuery;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager entityManager;

    public List<SimpleOrderQuery> findOrderByDTO() {
        return entityManager.createQuery(
                        "SELECT new cwchoiit.shoppingmall.dto.SimpleOrderQuery(o.id, m.name, o.orderDate, o.status, d.address) " +
                                "FROM Order o " +
                                "JOIN o.member m " +
                                "JOIN o.delivery d", SimpleOrderQuery.class)
                .getResultList();
    }
}
  • 이제 OrderQueryRepository를 따로 만들어서, 특정 화면이나 어떤 딱 필요한 부분에만 사용되는 쿼리들을 따로 모아두고 관리하는 것이다. 
  • 이러면 두가지 장점이 생기는데 첫번째는 유지보수성이 좋아진다. 순수한 OrderRepository는 다시 오로지 엔티티에만 의존하고 있기 때문에 결합력이 약해진다. 두번째는 분리된 Repository로 인해 개발하는 개발자도 이 쿼리는 어딘가에 특정된 쿼리구나를 인식할 수 있게 한다. 

그리고 가져다가 사용하는 쪽도, 이렇게 변경해주면 된다. 

@GetMapping("/api/v4/simple-orders")
public List<SimpleOrderQuery> ordersV4() {
    return orderQueryRepository.findOrderByDTO();
}
  • 물론 컨트롤러가 의존성이 더 추가됐지만, 컨트롤러는 얼마든지 그럴 수 있다.

 

 

결론

결론을 얘기하면 다음 순서를 따르자!

  1. 무조건 컨트롤러는 엔티티 말고 DTO를 반환한다.
  2. 필요하면 페치 조인으로 성능을 최적화한다. 대부분은 이걸로 성능 이슈가 해결된다.
  3. 그래도 더욱 더욱 최적화 하고 싶다면 SELECT절을 DTO로 받아서 정말 딱 필요한 데이터만 메모리에 퍼올리자.
  4. 아예 이것도 저것도 안되면, JPA가 제공하는 네이티브 SQL이나 스프링 JDBCTemplate을 사용해서 SQL을 직접 날린다.
728x90
반응형
LIST
728x90
반응형
SMALL

자, 엔티티를 만들고 인덱스를 걸어보자. 그 전에 인덱스를 사용하는 이유가 무엇인지부터 좀 이해해보자.

아래 내가 작성한 링크에 인덱스를 사용하는 이유와 인덱스를 만들때 사용되는 구조에 대한 간략한 글을 작성했었다.

 

Index란? (DB)

데이터베이스에서 빼놓을 수 없는 개념인 Index. 이 내용을 정리해보고자 한다. 우선 다음과 같이 데이터베이스에 데이터가 저장되어 있다고 가정해보자. 그리고 질문은 다음과 같다.Q: age = 20인

cwchoiit.tistory.com

 

자, 이제 인덱스를 왜 만들고 인덱스가 왜 필요하고 어떻게 만들어지는지 어느정도 감을 잡았다면 JPA와 인덱스를 같이 사용해보자.

다음 코드를 보자. 매우 간단한 엔티티이다.

@Entity
@Table(name = "members", indexes = {
    @Index(name = "idx_username", columnList = "username")
})
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String email;

}
  • 지금 Member엔티티는 username 필드를 인덱스로 설정했다.
  • 이제 검색 조건에 username이 들어가고, 그 빈도수가 잦다면, 인덱스를 통해 검색 최적화를 기대해 볼 수 있다.

 

그런데, 이런 상황도 있을 수 있다. "어? 저는 username, email 이 두개를 검색 조건으로 같이 사용하는 쿼리가 훨씬 많아요!" 그런 경우엔 복합 인덱스를 사용해서 조금 더 최적화를 기대해볼 수 있다. 다음 코드를 보자.

@Entity
@Table(name = "members", indexes = {
    @Index(name = "idx_username_email", columnList = "username, email")
})
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String password;

    // Getters and setters omitted for brevity
}
  • 이번에는 username, email 이 두가지를 사용해서 복합 인덱스를 만들었다.
  • 이 경우, 검색 조건으로 username, email 둘 다 사용될 때 가장 최적의 성능을 발휘한다.
  • username만 검색 조건에 들어가는 경우엔 이 복합 인덱스를 사용할 순 있지만 username만 가지고 인덱스를 만든 단일 인덱스보단 성능이 덜 나올 수 있다. 여기서 인덱스 순서와 성능에 대한 이야기를 해볼 수 있다.
  • 그리고 이 경우, 검색 조건에 email 만 사용되는 경우 이 인덱스를 사용할 수 없다. 인덱스가 username을 먼저 기준으로 하기 때문이다.

복합 인덱스를 사용할 때 인덱스 컬럼 순서

지금처럼 username, email 순으로 username을 먼저 인덱스 순서로 지정하면, 인덱스는 username을 먼저 기준으로 정렬하고, 그 다음으로 email을 정렬하는 구조가 된다. 그래서 실제 사용 패턴에 따른 인덱스를 설계할 땐 다음과 같이 고려해볼 수 있다.

  • username을 더 자주 검색하는 경우: username, email 순으로 설정하는 것이 효율적이다. 
  • email을 더 자주 검색하는 경우: email, username 순으로 설정하는 것이 효율적이다.
누군가는 이런 오해를 한다. 필드 선언 순서가 중요하다!? 큰일날 소리다! 배울 때 잘 배웠으면 좋겠다. 😮‍💨 그러니까 그 누군가가 말하는건 아래와 같은 내용이다. 
@Column(nullable = false, unique = true)
private String username;

@Column(nullable = false, unique = true)
private String email;

 

@Column(nullable = false, unique = true)
private String email;

@Column(nullable = false, unique = true)
private String username;
  • 이렇게 필드를 선언하는 순서에 따라 인덱스의 성능이 달라진다고 오해를 하더라! 아니다! 이거 때문에 이 포스팅 만들었다.

중간 정리를 하자면

그래서 복합 인덱스는 첫 컬럼이 가장 중요한 역할을 하며, 두 번째 컬럼은 첫 번째 컬럼 조건이 있을 경우에만 추가로 최적화가 된다. 

 

두 컬럼을 동시에 사용한다는게 어떤건가요?

1. 두 컬럼을 모두 조건으로 사용하는 경우

SELECT * FROM members
WHERE username = 'john_doe' AND email = 'john@example.com';

 

그래서, 이런 경우에 저 복합 인덱스는 최적의 성능을 발휘할 수 있다. 

 

복합 인덱스를 만들어 두었는데 하나의 컬럼만 검색조건으로 사용하는 경우에는 그냥 사용할까요?

거의 대부분 두가지 컬럼 모두 조건으로 사용하는데 아주 가끔 하나만 사용하는 경우엔 그 녀석을 인덱스의 첫번째 순서로 설정하고 복합 인덱스를 사용하면 될 것 같다. 근데 빈번하게 각각의 컬럼 조건으로 검색을 할 때는 단일 인덱스를 따로 만들어 두는게 더 효율적이다. 

 

예를 들어, username, email 순으로 복합 인덱스를 설정한 경우:

  • username만 조건으로 사용: username이 첫 번째 컬럼이므로 인덱스를 사용할 수 있다.
  • usernameemail을 모두 조건으로 사용: 복합 인덱스를 최적으로 사용할 수 있다.
  • email만 조건으로 사용: username이 첫 번째 컬럼이므로, 인덱스를 사용할 수 없다. 이 경우, email 단독 조건으로 검색하는 쿼리에서 성능을 높이고 싶다면 email 필드에 별도의 단일 인덱스를 추가하는 것이 좋다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

Dirty CheckingMerge에 대한 제대로 된 이해를 위해 블로그를 작성하려고 한다. JPA를 사용할 때 엔티티를 어떻게 업데이트할까?에 대한 내용인데 결론부터 말하면 Merge를 사용하면 난 안된다고 생각한다. Merge를 사용하는건 변경감지가 아니라 Merge를 사용하는 확실한 이유가 있어야 한다고 본다. 

 

우선, 다음 코드를 보자. 예시 코드를 위해 최대한 간단하게 작성했다. 

@PostMapping("/items/edit")
public String updateItem(@ModelAttribute("form") BookForm form) {

    Book book = Book.builder()
            .id(form.getId())
            .name(form.getName())
            .price(form.getPrice())
            .stockQuantity(form.getStockQuantity())
            .author(form.getAuthor())
            .isbn(form.getIsbn())
            .build();

    itemService.saveItem(book);
    return "redirect:/";
}
  • form으로 입력한 데이터를 전송받아, 엔티티 객체를 만들고 저장하는 메서드를 호출한다.
  • 업데이트한다는 것은 이미 그 데이터가 데이터베이스에 있다는 소리고 그 말은 ID값을 이미 가지고 있기 때문에 form에서 해당 ID값을 전달받을 수도 있다는 말이다.

그리고 호출하는 ItemService.saveItem() 코드를 보자.

 

ItemService의 일부분

@Transactional
public void saveItem(Item item) {
    itemRepository.save(item);
}

ItemRepository의 일부분

public void save(Item item) {
    if (item.getId() == null) {
        entityManager.persist(item);
    } else {
        entityManager.merge(item);
    }
}
  • ItemService는 그저 ItemRepository를 호출하는 위임 클래스 역할을 할 뿐이고, ItemRepository에서 ID값이 있는지 확인해서 ID값이 없다면 persist()를, 있다면 merge()를 호출한다.

 

이렇게 코드를 작성하면 문제없이 코드가 잘 동작할 것이다. 지금 이 코드는 병합을 통해 엔티티를 업데이트 한 것이다. 근데 이게 왜 문제가 될 수 있고 잘못된 것인지 알아야 한다. 

 

 

자, 병합은 사실 이런 행위를 한다.

merge(Item item) {
    Item findItem = itemRepository.findById(item.getId());

    findItem.setName(item.getName());
    findItem.setPrice(item.getPrice());
    findItem.setStockQuantity(item.getStockQuantity());
    
    그 외 모든 필드들 값 세팅...
}
  • 우선, 넘겨받은 엔티티의 기본키를 통해 데이터베이스에서 해당 기본키를 가지고 있는 엔티티를 찾는다.
  • 그 찾아온 엔티티의 정말 모든 필드를 넘겨받은 엔티티의 값으로 교체한다.
  • 그리고 Dirty Checking을 통해 데이터를 업데이트한다.

이 코드의 문제가 뭘까? 실무에서의 업데이트는 전체 데이터를 모두 업데이트하는 경우보다 일부분을 업데이트하는 경우가 대부분일 것이다. 일단 실세계에서 생각해봐도 나만 해도 그렇다. 어떤 계정 정보를 업데이트할 때 계정 정보를 전체 다 업데이트한 적은 없다. 딱 필요한 몇 부분만 수정하곤 하지. 그 말은, 넘긴 데이터도 수정할 데이터만 일부 채워졌을 확률이 있다는 얘기고 나머지 필드에는 값이 안 채워져 있을 수도 있다는 얘기다. 근데 그 값을 그대로 병합해버리면? 기존에 있는 값을 null로 변경해버릴 것이다. 그러니까 이 말을 코드로 보면 아래와 같은 상황도 있을 수 있단 얘기다.

@PostMapping("/items/edit")
public String updateItem(@ModelAttribute("form") BookForm form) {

    Book book = Book.builder()
            .id(form.getId())
            .name(form.getName())
            //.price(form.getPrice())
            //.stockQuantity(form.getStockQuantity())
            //.author(form.getAuthor())
            //.isbn(form.getIsbn())
            .build();

    itemService.saveItem(book);
    return "redirect:/";
}
  • 내가 수정하고자 하는 값은 책의 이름뿐이었기에 다른 값은 채우지 않았다. 그리고 업데이트한다.
  • 이렇게 되면 저 Book이라는 객체의 나머지 필드들은 다 null이다.
  • 병합을 하는 순간 기존에 있는 멀쩡한 데이터에 null이 채워질 것이다.

 

물론, 간단하고 제약이 잘 갖춰져 있고 딱 수정하고자 하는 값만 수정한다면 병합 시 문제를 해결할 수 있을지도 모른다. 근데 굳이 리스크를 스스로 끌어 안을 필요가 있을까? 그럼 Dirty Checking으로 어떻게 데이터를 변경하면 될까?

 

 

Dirty Checking을 통한 업데이트

가장 핵심은 이 변경감지는 영속성 컨텍스트가 관리하고 있는 엔티티여야 한다. 즉, 영속 상태의 엔티티만 변경 감지가 제대로 수행된다.

여기서 많은 실수가 일어나는데, 다음 코드를 보자.

 

예시를 위해 최대한 단순히 만들었다. 

@PostMapping("/items/edit")
public String updateItem(@ModelAttribute("form") BookForm form) {

    Book book = new Book();

    book.setId(form.getId());
    book.setName(form.getName());

    return "redirect:/";
}
  • Book은 엔티티이다. 이 코드가 의도한건 엔티티의 일부 값을 변경했으니 변경감지가 일어나겠지?! 이다.
  • 그러나 아무런 일도 일어나지 않을 것이다.

왜냐하면, 지금 이 Book은 영속성 컨텍스트가 관리하고 있는 대상이 아니기 때문이다. 그래서 준영속 엔티티나 영속되지 않은 엔티티에 변경감지를 기대하고 실수하는 경우가 자주 있다. 그럼 어떻게 해야하지?

 

1. 먼저 영속성 컨텍스트가 해당 엔티티를 영속한다.

@PostMapping("/items/{itemId}/edit")
public String updateItem(@PathVariable("itemId") Long itemId, @ModelAttribute("form") BookForm form) {

    itemService.updateItem(itemId, form);

    return "redirect:/";
}
  • 컨트롤러에서 애매하게 엔티티를 만들고 지지고 볶는게 아니라, 서비스 레이어에 데이터를 넘겨준다.
@Transactional
public void updateItem(Long itemId, BookForm form) {
    Item findItem = itemRepository.findById(itemId);
    findItem.change(form);
}
  • 그리고 엔티티의 기본키를 통해 엔티티를 찾아와 영속시키면 된다. 영속 시킨 후 데이터를 변경하면 된다.
  • 여기서는 의미있는 이름의 메서드를 만들어 추적이 쉽게 만들어주자. 위에서는 예제를 단순히 하기 위해 세터를 사용했지만 세터도 사용하지 않는다. 좋아하지도 않고. 

2. 값을 변경한다.

public void change(BookForm form) {
    name = form.getName();
    price = form.getPrice();
    stockQuantity = form.getStockQuantity();
}
  • 그리고 이 변경 메서드에서 변경하고자 하는 값만 넣어주면 된다.
  • 아이템에서 변경 가능한 데이터는 Name, Price, StockQuantity 이 세개뿐이라고 비즈니스 규칙으로 정해놓으면 이 코드에 문제는 아무것도 없다. 어차피 여기서는 form안에 데이터는 null일수도, null이 아니어도 상관없다. 이 form은 기존에 데이터베이스에 저장된 해당 값을 불러서 화면에 뿌려주고 사용자가 입력한 데이터를 그대로 가져온 것이기 때문이다.
  • 그리고 이렇게 값을 변경해주고 save() 같은 메서드는 호출하지 않아도 된다.
  • 왜냐? 영속성 컨텍스트가 관리하는 엔티티의 값을 변경했고, 트랜잭션이 끝나는 순간 변경감지를 통해 데이터가 바뀔테니까. 즉, updateItem 메서드가 끝나는 순간, 데이터베이스에 업데이트 쿼리가 나갈것이다.

 

병합이 아닌 변경감지를 사용하면 어떤점에서 좋은가?

  • 예상하지 못한 오류를 방지할 수 있다. (사람은 언제나 실수를 한다. 실수 안하기를 기대하는 코드를 작성하는 사람보다 실수조차 하지 못하게 제약을 두는 코드가 더 좋은 코드라고 생각한다.)
  • save() 메서드는 결국 엔티티를 넘겨야 한다. 엔티티를 넘긴다는건 수정한 엔티티를 넘겨야 하고, 엔티티를 수정하기 위해 Setter를 사용하던 뭘 하던 해야 할텐데, 데이터베이스에서 값을 가져와서 Setter를 통해 값을 변경해 save()를 호출하나, 엔티티를 직접 만들고 ID를 데이터베이스에 기존에 있는 동일한 기본키로 적용한 후 값을 수정해 save()를 호출하나 일을 두번하는 것이다. 그냥, 의미 있는 메서드를 하나 만들어두면 이 메서드만 추적하면 값을 어디서 변경하는지 바로 캐치할 수 있고, 세터와 같은 나쁜놈들을 사용하지 않아도 된다. 

 

결론은,

값을 변경할땐 변경감지를 사용하자! 

728x90
반응형
LIST
728x90
반응형
SMALL

2024.11.10 업데이트


JPA를 사용할 때 "Open Session In View"라는 게 있다. 이 녀석이 은근 골치가 아픈 녀석인데 Spring Boot와 JPA를 같이 사용할 때 서버를 실행해 보면 (OSIV 관련 어떠한 작업도 하지 않았을 때) 이런 경고 문구가 노출된다.

2023-11-16T08:31:26.467+09:00  WARN 76673 --- [  restartedMain] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning

 

그러니까 이 옵션은 기본값이 "true"인데 얘가 true이면 화면이 렌더링되는 시점에도 데이터베이스에 쿼리가 날아갈 수 있다는 경고 문구를 날리고 있다. 이 속성값은 application.yml 파일에서 spring.jpa.open-in-view 키로 값을 줄 수 있다.

 

반응형
SMALL

OSIV가 그래서 정확히 어떻게 동작을 하느냐? 아래 컨트롤러 코드를 보자.

@GetMapping("members")
public Result<List<MemberDto>> members() {
    List<Member> findMembers = memberService.findMembers();

    List<MemberDto> collect = findMembers
            .stream()
            .map(m -> new MemberDto(m.getUsername()))
            .toList();

    return new Result<>(collect.size(), collect);
}

 

코드를 보면 memberService.findMember()를 호출하고 있다. 이 MemberService를 들어가 보면 이렇게 생겼다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {

    private final MemberRepository memberRepository;

	public List<Member> findMembers() {
        return memberRepository.findAll();
    }
}

 

클래스 레벨에 @Transactional() 어노테이션이 달려있고 findMembers() 메소드는 데이터베이스에서 멤버를 전부 가져온다. 이때 OSIV 속성이 true라면 이 클래스의 메소드가 시작하고 끝나는 시점에 트랜잭션이 커밋이 되거나 영속 컨텍스트가 닫히지 않는다. 이 메소드를 호출한 컨트롤러도 트랜잭션 안에서 동작하게 된다. 이게 OSIV 속성이 true일 때 일어나는 현상이다.

 

즉, 트랜잭션이 활성화된 메서드가 끝나도 트랜잭션은 닫히지 않고 그 메서드를 호출한 녀석도 역시 트랜잭션이 활성화된 상태로 남아있는다. 그렇기 때문에 위 경고 문구처럼 화면을 뿌리는 컨트롤러가 화면을 렌더링 하는 시점에도 역시 데이터베이스 쿼리가 날아갈 수 있다는 경고 문구를 하고 있는 것.

 

그럼 OSIV 속성은 항상 false로 설정해야 할까?

 

OSIV가 활성화된 상태일 때 장단점

장점은 다음과 같다.

  • 트랜잭션 어노테이션이 붙어 있는 곳이 아니더라도 그것을 호출한 곳도 트랜잭션이 활성화 된 상태이다 보니 지연로딩을 초기화하는데 좀 더 유연하다. 즉, 서비스에서 데이터베이스 쿼리를 하고 받은 데이터를 가지고 컨트롤러에서 지연 로딩 관련 데이터를 초기화할 수 있다는 얘기다.

단점은 다음과 같다.

  • 트랜잭션이 예상하는 것보다 오래 살아 있기 때문에 네트워크 상황에 따라 트랜잭션이 부족할 수 있다. 즉, 컨트롤러에서 서비스를 호출해서 데이터베이스에 쿼리를 했고 받은 데이터를 컨트롤러가 받을 때 네트워크 상황이 좋지 않아 컨트롤러가 재빨리 응답을 줄 수 없는 경우 그 시간 동안 내내 트랜잭션은 유효하게 된다. 그러면 트래픽이 많은 애플리케이션은 이러한 경우 때문에 커넥션풀이 부족할 수 있고 부족하게 되는 경우 장애로 이어질 수 있다.

OSIV가 비활성화된 상태일 때 장단점

장점은 다음과 같다.

  • 트랜잭션이 시작하고 끝나는 시점이 명확하고 짧아지기 때문에 커넥션 풀에 여유가 생긴다.

단점은 다음과 같다.

  • 트랜잭션 하나가 영속 컨텍스트 하나와 매핑되기 때문에 트랜잭션이 끝나는 동시에 지연로딩을 초기화할 수 없고 그렇게 되면 지연 로딩으로 데이터를 가져올 때 언제나 트랜잭션 안에서 모든 지연로딩을 초기화한 상태로 돌려주어야 한다.

 

OSIV를 비활성화했을 때 단점을 극복하는 방법

만약, OSIV를 비활성화한 경우엔 모든 지연 로딩 엔티티를 트랜잭션 안에서 처리해야 하므로 다음과 같이 해결할 수 있을 것이다.

  • 페치 조인을 사용해 데이터를 쿼리 하는 시점에 모두 받아온다.
  • 페치 조인으로 모두 받아오지 못할 경우 지연 로딩을 초기화하는 새로운 트랜잭션 안에서 처리한다.
  • 하나의 트랜잭션에서 모든 지연 로딩 엔티티를 초기화한다.

 

페치 조인으로 모든 데이터를 전부 한 번에 로딩

@GetMapping("orders")
public List<OrderDto> orders() {
    List<Order> orders = orderRepository.findAllWithItem();
    return orders.stream().map(OrderDto::new).toList();
}

 

위 코드는 컨트롤러에서 주문 정보를 모두 가져와서 DTO로 반환 후 JSON으로 데이터를 돌려준다. 여기서 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().getUsername();
        orderDate = order.getOrderDate();
        orderStatus = order.getOrderStatus();
        address = order.getDelivery().getAddress();

        orderItems = order.getOrderItems().stream().map(OrderItemDto::new).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의 생성자에서 OrderItems를 처리한다. OrderItem은 지연 로딩 엔티티이다. 그래서 OSIV가 비활성화된 경우 위 컨트롤러에서 지연로딩을 처리할 수 없는 상태이다. 그러나, 페치 조인으로 지연 로딩 데이터를 모두 한 번의 쿼리로 로딩해 온다면 가능하다.

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

 

 

지연 로딩을 초기화하는 새로운 트랜잭션에서 처리

다른 방법으로는 새로운 트랜잭션을 받아오면 된다. 지금 문제가 되는 부분은 레포지토리에서 받아온 주문 데이터들을 DTO로 변환하는 이 코드이다.

return orders.stream().map(OrderDto::new).toList();

 

이 부분을 처리할 때 DTO 클래스가 생성자에서 지연 로딩 엔티티를 처리하기 때문에 문제가 발생하는데 이 코드 처리를 새로운 트랜잭션에서 하면 된다.

package com.example.jpabook.service;

import com.example.jpabook.domain.Order;
import com.example.jpabook.domain.OrderDto;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional(readOnly = true)
public class OrderQueryService {

    public List<OrderDto> transferOrderDto(List<Order> orders) {
        return orders.stream().map(OrderDto::new).toList();
    }
}

 

새로운 트랜잭션을 가지는 OrderQueryService 클래스를 가지고 처리하는 로직을 둔다. 그리고 이 클래스의 메서드 transferOrderDto()를 컨트롤러에서 호출 후 반환한다.

@GetMapping("orders")
public List<OrderDto> orders() {
    List<Order> orders = orderRepository.findAllWithItem();
    return orderQueryService.transferOrderDto(orders);
}

 

 

하나의 트랜잭션에서 모든 것을 처리

즉, 서비스 또는 레포지토리에서 모든것을 다 처리한 후 컨트롤러는 받기만 하고 돌려주는 것을 말한다.

나는 비즈니스 로직을 레포지토리에 넣는 것을 매우 싫어하므로 (그래서도 안되고 항상 참조 방향은 다음과 같아야 한다).

컨트롤러 -> 서비스 -> 레포지토리, 엔티티 

역방향이 되면 안 된다. 유지보수에 굉장히 큰 타격을 주므로. 

아무튼 지금 코드에서 레포지토리에서 모든 로직을 넣지 않을 것이다. 근데 만약 서비스를 호출해서 처리하는 경우라면 서비스 안에서 DTO로 변환까지 같이 해준 데이터를 컨트롤러가 받으면 된다.

 

 

결론

OSIV를 활성화 또는 비활성화는 Trade-off가 있다. 그래서 뭐가 더 좋고 나쁘다가 아니라 상황에 맞게 사용하면 된다. 예를 들어 트래픽이 많이 필요 없는 애플리케이션은 활성화 상태로 두면 코드의 복잡도가 떨어지는 장점을 가지면서 애플리케이션에 장애 없이 서비스를 할 수 있을 것이다. 그러나 트래픽이 많은 애플리케이션은 이 옵션을 활성화해서는 안된다. 트랜잭션이 부족할 가능성이 농후하기 때문에. 따라서 상황에 맞게 사용하는 것이 중요하다고 본다.

 

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] 엔티티와 인덱스  (0) 2024.11.10
[JPA] Dirty Checking, Merge  (8) 2024.11.10
[JPA] Part 18. 벌크 연산  (0) 2023.10.30
[JPA] Part 17. Named Query  (2) 2023.10.30
[JPA] Part 16. Fetch JOIN (JPQL)  (0) 2023.10.30
728x90
반응형
SMALL

2024.11.01 업데이트


쿼리 한 번으로 여러 테이블 로우(레코드)를 변경하는 걸 말한다. UPDATE, DELETE, INSERT 같은 것들이 이제 벌크 연산이 가능하다.

뭐 별건 아닌데 조금 주의할 사항이 있다.

 

벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리한다.

이게 뭐가 문제냐면 벌크 연산을 처리한 전과 후에 만약 영속성 컨텍스트에 관리하고 있는 레코드(객체)가 있다면 벌크 연산이 적용되지 않은 채 영속성 컨텍스트에 그대로 관리될 수 있다. 그래서 이걸 해결하는 방법은 벌크 연산을 처리하고 영속성 컨텍스트를 초기화하는 것이다. 또는 영속성 컨텍스트에 뭔가를 관리하기 전 벌크 연산을 먼저 수행하는 것이다. 

반응형
SMALL

 

예를 들어보자.

멤버 3명이 있고 최초에 age0으로 설정했다. 그리고 영속성 컨텍스트에 멤버 3명을 영속시켰는데 그 이후에 벌크 연산으로 멤버 모두의 age값을 20으로 설정했다. 설정한 후 3명의 멤버 중 한명을 아무나 잡고 age를 찍어보면 내가 기대하는 건 20인데 0으로 나온다. 왜냐하면, 영속성 컨텍스트에 관리되는 멤버의 age0에서 변경된 상태가 아니다. 위에서도 말했지만 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리하기 때문이다. 실제 결과 출력을 보면 다음과 같이 반영된 개수는 3개지만 그 중 하나를 임의로 찍었을 때 나이가 0으로 나온다. 이럴때가 문제가 될 수 있다. 

그래서, 벌크 연산은 그냥 무조건 벌크 연산을 처리하고 영속성 컨텍스트를 초기화하자.

플러시를 한다고 초기화하는게 아니다. 플러시는 영속 컨텍스트 데이터를 DB에 반영하는거고 초기화는 clear()를 호출해야 한다.

 

 

참고로, 이렇게 JPQL을 사용하면 무조건 사용하는 시점에 flush()를 호출한다. 벌크 연산도 마찬가지다. 그래서 JPQL 호출하기 전에 flush()를 먼저하고 JPQL을 실행한다. 근데 그거랑 clear()는 다른 얘기다. 이미 영속성 컨텍스트에서 관리하는 객체가 있으면 flush()를 하더라도 그 데이터는 그대로 남아있는거다. 그러니까 벌크 연산을 수행할 때 flush()가 자동 호출되지만, clear()를 하지 않은 상태에서 영속성 컨텍스트에 있는 데이터를 그대로 가져오면 DB를 거쳐서 가져오는게 아니고 영속성 컨텍스트에 있는 데이터를 가져오고 이 데이터는 벌크 연산에 적용되는 데이터라고 할 지라도 데이터가 반영되어 있는 상태가 아닌건 똑같다. 그래서 결론은 벌크 연산은 반드시 수행하고 clear()를 호출하자! 

그런데! 이 역시도 Spring Data JPA를 사용하면 굉장히 깔끔하고 편리하게 해결해준다. 아래 코드를 보자.

interface UserRepository extends Repository<User, Long> {

  @Modifying
  @Query("delete from User u where u.role.id = ?1")
  void deleteInBulkByRoleId(long roleId);
}
  • 지금 저기 NamedQuery가 있다. 그리고 저건 벌크 연산이다. Role이 특정 Role인 유저들을 모두 지우니까. 
  • 그런데, 그 위에 @Modifying 애노테이션이 있다. 저 애노테이션은 영속성 컨텍스트를 clear()하는 애노테이션이다. 즉, 이 쿼리가 수행된 후 clear()를 호출해주는 애노테이션이다. 그래서 이렇게 이쁘고 간단하게 벌크 연산이 가능하다.

 

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] Dirty Checking, Merge  (8) 2024.11.10
[JPA] OSIV (Open Session In View)  (0) 2023.11.16
[JPA] Part 17. Named Query  (2) 2023.10.30
[JPA] Part 16. Fetch JOIN (JPQL)  (0) 2023.10.30
[JPA] Part 15. JPQL  (0) 2023.10.28
728x90
반응형
SMALL
반응형
SMALL

2024.11.01 업데이트


NamedQuery란, 문자열로만 이루어진 쿼리문에 이름을 부여해서 가져다가 사용할 수 있는 방식을 말하는데, 이게 굉장히 유용하다. 특히 Spring Data JPA와 같이 사용하게 된다면.

 

우선 Spring Data JPA가 없다고 가정했을 때 작성하는 방법은 @Entity 어노테이션이 있는 엔티티에 @NamedQuery 어노테이션을 사용하면 된다.

 

@Entity
@NamedQuery(
        name = "Member.findByUsername",
        query = "select m from Member m where m.username = :username")
public class Member {
	...
}
  • 이처럼 작성하면 된다. 그리고 name은 마음대로 지어도 되지만, 이렇게 엔티티 위에 작성하는 경우에는 `엔티티.Xxx`로 사용하는게 관례다. 
  • 사용은 아래 코드처럼 하면 된다.
List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
                            .setParameter("username", "회원1")
                            .getResultList();

 

근데 여기서 NamedQuery 주는 이점은 컴파일 시점에 쿼리에 문제가 있으면 잡아준다는 것이다.

이 덕분에 쿼리에 문제, 문법 오류가 있는 경우를 방지할 수 있게 된다. 나중에 Spring Data JPA를 사용하면 다음과 같이 정말 편리하고 보기 좋게 사용할 수 있다.

public interface UserRepository extends JpaRepository<User, Long> {

  @Query("select u from User u where u.emailAddress = ?1")
  User findByEmailAddress(String emailAddress);
  
}

인터페이스에서 메서드 시그니쳐만 생성해놔도 @Query 어노테이션으로 쿼리를 작성하고 쿼리에 오류가 있는지도 잡아주고 가져다가 사용만 하면 된다.

 

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] OSIV (Open Session In View)  (0) 2023.11.16
[JPA] Part 18. 벌크 연산  (0) 2023.10.30
[JPA] Part 16. Fetch JOIN (JPQL)  (0) 2023.10.30
[JPA] Part 15. JPQL  (0) 2023.10.28
[JPA] Part 14. 값 타입 컬렉션  (0) 2023.10.25
728x90
반응형
SMALL
반응형
SMALL

2024.11.01 업데이트


페치 조인은 JPQL에서 엄청 엄청 중요한 내용 중 하나라는 생각이 든다. 왜 그러냐면 이 페치 조인이 연관된 엔티티를 SQL 한 번에 다 가져올 수 있는 방법인데 이게 왜 중요하냐? 이전에 N+1 문제에 대해 얘기하면서 이 N+1 문제는 즉시로딩뿐 아니라 지연로딩에서도 발생한다고 했다. 왜냐? 팀과 멤버를 생각해보면, 멤버를 전체 다 가져왔을 때 팀을 지연로딩으로 설정해 놓으면 최초 멤버를 가져올때는 팀을 쿼리해서 가져오지 않는다. 지연로딩이니까. 근데 이렇게 가져온 결과로 멤버에 접근해서 getTeam().getName() 이런 메서드를 호출하는 순간 팀을 가져오기 위한 프록시 초기화를 실행하고 이때 쿼리가 나간다. 즉, 지연로딩도 이렇게 N+1 문제가 발생한다는 이야기다. 그 문제를 해결할 수 있는 방법이 최초 한번의 SQL 쿼리로 멤버를 가져올 때 팀도 한번에 다 조인해서 가져오는 페치 조인을 사용하는 것이다.

 

사용법도 너무 간단하다. 아래처럼 JOIN FETCH로 조인할 연관 엔티티만 호출하면 된다.

SELECT m FROM Member m JOIN FETCH m.team

JPQL은 다음과 같은 SQL로 변환되어 나간다.

SELECT m.*, t.* FROM Member m INNER JOIN TEAM t ON m.team_id = t.id

m.*, t.*은 멤버와 팀의 모든 필드를 축약해서 작성한 것 뿐이고 그냥 내부 조인이 발생한다. 내부 조인이 발생하면 멤버와 팀의 데이터를 다 가져오기 때문에 한 번에 모든 데이터를 가져올 수 있는데 이게 사실 즉시 로딩이다. 그러나 즉시 로딩은 사용하면 안된다고 했었는데 즉시 로딩은 내가 멤버를 조회할 때 팀을 같이 조회할 필요가 없음에도 조인으로 가져오기 때문이었다. 그래서 지연 로딩이 기본으로 적용되어야 한다고 했는데 지연 로딩일 때 멤버를 조회하면 팀을 같이 조회해야 하는 경우 이 한 번의 쿼리로 모든 걸 다 해결할 수 있게 하는 것.

 

 

실제 사용 코드를 예시로 보자.

FETCH JOIN을 사용하지 않았을 때

package cwchoiit;

import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            for (int i = 0; i < 3; i++) {
                Team team = new Team();
                team.setName("Team" + i);
                entityManager.persist(team);

                Member member = new Member();
                member.setName("member" + i);
                member.setTeam(team);

                entityManager.persist(member);
            }

            entityManager.flush();
            entityManager.clear();

            List<Member> findMembers = entityManager
                    .createQuery("SELECT m FROM Member m", Member.class)
                    .getResultList();

            for (Member findMember : findMembers) {
                System.out.println("findMember.getName() = " + findMember.getName());
                System.out.println("findMember.getTeam().getName() = " + findMember.getTeam().getName());
            }

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • 멤버와 그 멤버가 속할 팀을 루프안에서 만든다. 3명의 멤버가 각각 다른 팀에 속해있다. 멤버를 영속 컨텍스트에 영속시킨 후 루프가 끝난 후 영속 컨텍스트를 플러시 한다. 그 후에 모든 멤버를 가져와 각 멤버의 정보를 출력한다. 
  • 이때 발생되는 결과 SQL쿼리를 확인해 보자.
Hibernate: 
    /* SELECT
        m 
    FROM
        Member m */ select
            member0_.id as id1_0_,
            member0_.name as name2_0_,
            member0_.TEAM_ID as team_id3_0_ 
        from
            Member member0_
findMember.getName() = member0
Hibernate: 
    select
        team0_.id as id1_3_0_,
        team0_.name as name2_3_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
findMember.getTeam().getName() = Team0
findMember.getName() = member1
Hibernate: 
    select
        team0_.id as id1_3_0_,
        team0_.name as name2_3_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
findMember.getTeam().getName() = Team1
findMember.getName() = member2
Hibernate: 
    select
        team0_.id as id1_3_0_,
        team0_.name as name2_3_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
findMember.getTeam().getName() = Team2

나가는 SQL 쿼리에 집중해 보자. 최초에 멤버를 전부 조회하는 쿼리가 한 번 나간다. 합리적이다. 그런데 멤버의 팀을 찍으려고 할 때마다 팀을 조회하는 쿼리가 매번 나간다. 왜냐하면 멤버를 가져왔을 때 멤버가 가지고 있는 팀은 엔티티가 아닌 프록시이기 때문에 실제 사용하는 시점에 프록시에 엔티티 값을 집어넣어 줘야 하므로. 보면 팀 3개를 출력하기 위해 3번의 SQL문이 나간 게 보이는가? 이게 N+1 문제다. 지연로딩이든 즉시로딩이든 어떻게 사용하느냐에 따라 N+1 문제는 발생할 수 있게 된다.

 

그럼 지연로딩이었던 것을 즉시로딩으로 바꿀까? 절대 안 된다. 이 경우는 멤버를 조회할 때 팀도 조회하는 비즈니스 로직이기 때문에 이렇게 팀을 모두 조회하지만 그렇지 않은 로직을 타는 경우에는 불 필요한 조인이 발생하면 안된다. 이걸 해결해 주는 것이 페치조인이다.

 

FETCH JOIN을 사용할 때

package cwchoiit;

import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            for (int i = 0; i < 3; i++) {
                Team team = new Team();
                team.setName("Team" + i);
                entityManager.persist(team);

                Member member = new Member();
                member.setName("member" + i);
                member.setTeam(team);

                entityManager.persist(member);
            }

            entityManager.flush();
            entityManager.clear();

            List<Member> findMembers = entityManager
                    .createQuery("SELECT m FROM Member m JOIN FETCH m.team", Member.class)
                    .getResultList();

            for (Member findMember : findMembers) {
                System.out.println("findMember.getName() = " + findMember.getName());
                System.out.println("findMember.getTeam().getName() = " + findMember.getTeam().getName());
            }

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • 코드에서 변경된 부분은 딱 하나. 쿼리문에 JOIN FETCH가 추가된 것뿐이다. 그러나 결과는 엄청난 차이가 있다. 결과문을 보자.
Hibernate: 
    /* SELECT
        m 
    FROM
        Member m 
    JOIN
        FETCH m.team t */ select
            member0_.id as id1_0_0_,
            team1_.id as id1_3_1_,
            member0_.name as name2_0_0_,
            member0_.TEAM_ID as team_id3_0_0_,
            team1_.name as name2_3_1_ 
        from
            Member member0_ 
        inner join
            Team team1_ 
                on member0_.TEAM_ID=team1_.id
findMember.getName() = member0
findMember.getTeam().getName() = Team0
findMember.getName() = member1
findMember.getTeam().getName() = Team1
findMember.getName() = member2
findMember.getTeam().getName() = Team2
  • SQL문 딱 하나에 모든 데이터가 들어오고 한 번에 모두 조회가 가능해졌다. 위에서 고작 멤버 3명을 조회하기 위해 쿼리 4번이 나간 반면, 지금은 단 한 번의 쿼리로 모든 걸 만족시켰다. 이게 페치 조인이다. 
  • 엔티티 타입을 페치 조인해봤으니 컬렉션 값 타입도 페치 조인해보자. 살짝 다르고 알아야 할 부분이 있다. 

 

컬렉션 페치 조인 (일대다 관계, 컬렉션 페치 조인)

당연히 ManyToOne에서 페치 조인(멤버에서 팀을 가져올 때)이 가능하면 OneToMany에서도 페치 조인(팀에서 멤버들을 가져올 때)이 가능할건데 이 일대다에서 조인을 했을 때 조심할 부분이 있다. 우선 페치 조인으로 데이터를 가져와보자. 이건 페치 조인을 떠나서 그냥 일대다 입장에서 조인을 할 때 항상 조심할 부분이다.

package cwchoiit;

import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            Team team = new Team();
            team.setName("TEAM1");
            entityManager.persist(team);

            Team team2 = new Team();
            team2.setName("TEAM2");
            entityManager.persist(team2);

            Member member1 = new Member();
            member1.setName("MEMBER1");
            member1.setTeam(team);

            Member member2 = new Member();
            member2.setName("MEMBER2");
            member2.setTeam(team);

            Member member3 = new Member();
            member3.setName("MEMBER3");
            member3.setTeam(team2);

            entityManager.persist(member1);
            entityManager.persist(member2);
            entityManager.persist(member3);

            entityManager.flush();
            entityManager.clear();

            List<Team> findTeams = entityManager
                    .createQuery("SELECT t FROM Team t JOIN FETCH t.members", Team.class)
                    .getResultList();

            for (Team findTeam : findTeams) {
                System.out.println(findTeam.getName() + "|members.size(): " + findTeam.getMembers().size());
            }

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}
  • TEAM1, TEAM2 두 개의 팀이 있고, MEMBER1, MEMBER2, MEMBER3 세 명의 멤버가 있다.
  • TEAM1에는 MEMBER1, MEMBER2가 포함되어 있다.
  • TEAM2에는 MEMBER3이 포함되어 있다. 
  • 이 상태에서 팀을 뽑을건데 팀에 속한 Members를 페치 조인으로 가져와보자.
Hibernate: 
    /* SELECT
        t 
    FROM
        Team t 
    JOIN
        FETCH t.members */ select
            team0_.id as id1_3_0_,
            members1_.id as id1_0_1_,
            team0_.name as name2_3_0_,
            members1_.name as name2_0_1_,
            members1_.TEAM_ID as team_id3_0_1_,
            members1_.TEAM_ID as team_id3_0_0__,
            members1_.id as id1_0_0__ 
        from
            Team team0_ 
        inner join
            Member members1_ 
                on team0_.id=members1_.TEAM_ID
TEAM1|members.size(): 2
TEAM1|members.size(): 2
TEAM2|members.size(): 1
  • 역시, 단 한번의 쿼리로 모든걸 해결했지만 이상한 점이 있다. TEAM1 데이터가 두번 찍힌다는 점이다. 즉, 가져온 findTeams3개의 레코드가 있다는 의미다. 왜 그럴까? 일대다 입장에서의 조인이기 때문이다.

아래 그림을 보면 이해가 된다.

팀을 기준으로 멤버와 조인하게 되면 팀A에 소속된 멤버가 두명이니까 두개의 레코드를 가진다. 이렇기에 팀A가 위처럼 두번 출력되는 것이다. 이렇게 일대다 조인은 데이터가 불어나게 되는데 이를 해결하기 위해서는 JPA에서는 해줄 수 있는게 없다. JPA입장에서는 이 데이터가 어떻게 만들어질지 전혀 알 지 못하는 상태이기 때문에.

 

그래서 DB로부터 조인한 쿼리로 받은 데이터는 이렇게 두 개가 들어오고 JPA는 그것을 객체로 표현해주기 때문에 다음과 같은 그림이 된다.

그래서 우선은 받은 객체가 두개니까 메모리에 주소값이 두개가 할당된다. 그러나, 그 두개의 객체는 같은 객체이므로 바라보는 주소는 같다. 영속 컨텍스트에는 같은 객체라면 하나만을 사용하면 되므로 영속 컨텍스트에는 하나의 객체만 있지만 조회한 findTeams 컬렉션에는 두개가 담겨있는 것. 그럼 중복을 제거하고 싶을 땐 어떻게 할까? DISTINCT를 사용하면 된다.

 

허나, SQLDISTINCT는 위 문제를 해결해주지 않는다. SQL에서 DISTINCT는 중복된 결과(완전히 똑같은)를 제거하는 명령이기 때문에 두 레코드를 중복으로 바라보지 않는다. 중복이 아니잖아. 레코드 기준으로 회원아이디도 회원이름도 다르니까.

 

그러나, JPQLDISTINCT는 2가지의 기능을 제공해준다.

  • SQLDISTINCT를 추가해주는 작업
  • 애플리케이션에 올라온 객체에서 엔티티 중복(주소가 같은 Team 객체)을 제거해준다. 이 엔티티 중복을 제거해줄 때 위 상황 같은 중복이 제거가 된다.

 

근데 그럼 중복 제거가 되면 문제가 없나요? SQL에서 DISTINCT는 완전히 똑같아야 제거를 해주는데 완전히 똑같지는 않잖아요?

▶ 맞다. 그런데 우리는 지금 객체지향 세계에 있다. 우리는 하나의 Team에 속한 모든 Member들을 가져오는 리스트를 Team 객체가 가지고 있기 때문에 어차피 그 안에 회원 2명이 담겨 오기 때문에 팀에 대한 중복 제거가 되도 아무런 문제가 없다.

 

그래서 결론은 DISTINCT 예약어를 추가해보자. 

List<Team> teams = em.createQuery("SELECT DISTINCT t FROM Team t JOIN FETCH t.members", Team.class)
                    .getResultList();

실행 결과

Hibernate: 
    /* SELECT
        DISTINCT t 
    FROM
        Team t 
    JOIN
        FETCH t.members */ select
            distinct team0_.id as id1_3_0_,
            members1_.id as id1_0_1_,
            team0_.name as name2_3_0_,
            members1_.name as name2_0_1_,
            members1_.TEAM_ID as team_id3_0_1_,
            members1_.TEAM_ID as team_id3_0_0__,
            members1_.id as id1_0_0__ 
        from
            Team team0_ 
        inner join
            Member members1_ 
                on team0_.id=members1_.TEAM_ID
TEAM1|members.size(): 2
TEAM2|members.size(): 1

 

 

일반 조인과 페치 조인의 차이점

일반 조인과 페치 조인은 어떤점이 다를까? 일반 조인은 조인을 하는건 맞다. 근데 그 조인 대상의 데이터를 메모리에 즉각적으로 퍼올리지는 않는다. 코드로 보는게 제일 빠르니 코드로 봐보자.

package cwchoiit;

import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            Team team = new Team();
            team.setName("TEAM1");
            entityManager.persist(team);

            Team team2 = new Team();
            team2.setName("TEAM2");
            entityManager.persist(team2);

            Member member1 = new Member();
            member1.setName("MEMBER1");
            member1.setTeam(team);

            Member member2 = new Member();
            member2.setName("MEMBER2");
            member2.setTeam(team);

            Member member3 = new Member();
            member3.setName("MEMBER3");
            member3.setTeam(team2);

            entityManager.persist(member1);
            entityManager.persist(member2);
            entityManager.persist(member3);

            entityManager.flush();
            entityManager.clear();

            List<Team> findTeams = entityManager
                    .createQuery("SELECT DISTINCT t FROM Team t JOIN t.members", Team.class)
                    .getResultList();

            for (Team findTeam : findTeams) {
                System.out.println(findTeam.getName() + "|members.size(): " + findTeam.getMembers().size());
            }

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}

 

  • 딱 아래 코드 부분에 집중해보자. 페치 조인이 아닌 일반 조인이다.
List<Team> findTeams = entityManager
                    .createQuery("SELECT DISTINCT t FROM Team t JOIN t.members", Team.class)
                    .getResultList();

 

실행 결과

Hibernate: 
    /* SELECT
        DISTINCT t 
    FROM
        Team t 
    JOIN
        t.members */ select
            distinct team0_.id as id1_3_,
            team0_.name as name2_3_ 
        from
            Team team0_ 
        inner join
            Member members1_ 
                on team0_.id=members1_.TEAM_ID
Hibernate: 
    select
        members0_.TEAM_ID as team_id3_0_0_,
        members0_.id as id1_0_0_,
        members0_.id as id1_0_1_,
        members0_.name as name2_0_1_,
        members0_.TEAM_ID as team_id3_0_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?
TEAM1|members.size(): 2
Hibernate: 
    select
        members0_.TEAM_ID as team_id3_0_0_,
        members0_.id as id1_0_0_,
        members0_.id as id1_0_1_,
        members0_.name as name2_0_1_,
        members0_.TEAM_ID as team_id3_0_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?
TEAM2|members.size(): 1
  • 최초에 SQL 쿼리를 보면, 멤버를 조인을 하는건 맞다. 그러나, SELECT절엔 팀 관련 데이터밖에 없다. 그렇기 때문에 내가 가져온 팀들에서 멤버를 조회하려고 할 때 다시 멤버관련 SQL문이 나가게 된다.
  • 즉, 일반 조인은 일반 조인 과정에서 관련 엔티티 데이터를 퍼올리지 않는다. 프록시와 비슷한 상태인거지. 그래서 결국 멤버까지 가져오려면 다시 쿼리를 날리게 된다. 이런 차이가 있다.

 

페치 조인 한계

  • 페치 조인 대상에는 별칭을 줄 수 없다.(하이버네이트는 가능하나 가급적 사용하지 않아야 한다). 
List<Team> findTeams = entityManager
                    .createQuery("SELECT DISTINCT t FROM Team t JOIN FETCH t.members m", Team.class)
                    .getResultList();

저 코드에서 보면 JOIN FETCH 이후에 `t.members m` 이렇게 `m`으로 별칭을 주고 있는데 이런식으로 사용할 수 없다는 얘기다. 물론 하이버네이트는 가능하게 해준다. 그러나, 진짜로 페치 조인을 몇단계로 가져가야 할 경우 아닌 이상 절대 쓰지 않기로 하자. 그 외에 별칭을 가져가야 할 경우가 있다고 생각이 들면 그땐 다른 쿼리로 해결할 수 있는 경우가 무조건 있다고 생각해라.

 

  • 둘 이상의 컬렉션은 페치 조인 할 수 없다. 

예를 들어, 팀이 '멤버'라는 일대다 관계를 가지고 있고, 또 다른 뭐 '업무분야'라는 일대다 관계를 가지고 있을 때 이 두개를 같이 페치 조인하면 안된다. 그 이유는 위에서 봤지만 일대다만 해도 데이터가 부풀려 나오는데 이 경우는 일대다대다이다. 더 심한 데이터 부풀림 현상이 일어날 가능성이 농후하고 데이터 정합성에 문제가 발생할 수 있기 때문에 안된다.

 

  • 컬렉션을 페치 조인(일대다 조인)하면 페이징 API(setFirstResult(), setMaxResults())를 사용할 수 없다.

일대다 페치 조인일 때 데이터 부풀림 현상이 일어나는것을 확인했다. 그런데 이것을 페이징한다? 데이터 정합성에 문제가 생길 가능성이 농후하다. 그냥 하면 안된다. 근데 문제는 하이버네이트는 경고 로그를 남기고 메모리에 데이터를 올린 후 그 메모리의 데이터로 페이징을 결국은 해준다는 것이다. 절대 절대 사용하면 안된다. 그냥 일대다 페치 조인을 할거면 다대일로 페이징을 하면 된다. 아래는 경고 로그를 남기고 페이징을 하는 예시이다.

List<Team> findTeams = entityManager
                    .createQuery("SELECT DISTINCT t FROM Team t JOIN FETCH t.members", Team.class)
                    .setFirstResult(0)
                    .setMaxResults(1)
                    .getResultList();

실행 결과

WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

 

전 죽어도 일대다로 페이징 하고 싶은데요?

▶ 그러면 방법이 있다. 가장 좋은 방법은 다대일로 페이징을 하면 되지만, 정말 때려죽어도 일대다로 페이징을 하고 싶다면, 다음과 같이 해보자.

List<Team> findTeams = entityManager
                    .createQuery("SELECT t FROM Team t", Team.class)
                    .setFirstResult(0)
                    .setMaxResults(2)
                    .getResultList();

for (Team findTeam : findTeams) {
    System.out.println(findTeam.getName() + "|members.size(): " + findTeam.getMembers().size());

    for (Member member : findTeam.getMembers()) {
        System.out.println("member.getName() = " + member.getName());
    }
}
  • 우선은 페치 조인을 하지 말고 팀을 가져오는데 여기서 페이징을 원하는 만큼 한다. (위 코드에서는 0부터 2까지)
  • 그 가져온 팀에서 멤버들을 가지고 와서 뿌려주면 된다. (위 코드에서 findTeam.getMembers()로 루프를 돌리는 부분)
  • 그런데, 이렇게 되면 팀에 있는 멤버를 가져올때마다 지연로딩에 대한 초기화가 일어나기 때문에 쿼리가 계속 나간다 즉, N+1 문제가 발생한다. 이때 BatchSize를 설정해서 한번에 여러 멤버를 가져올 수가 있다! 아래 글로벌 설정 코드를 보자.

persistence.xml

...
<property name="hibernate.default_batch_fetch_size" value="100"/>
...
  • 이렇게 batch_fetch_size를 100 정도로 설정을 하면, 팀에 있는 멤버들을 가져올 때 최대 100개까지 한번에 조회를 한다.

실행 결과

Hibernate: 
    /* SELECT
        t 
    FROM
        Team t */ select
            team0_.id as id1_3_,
            team0_.name as name2_3_ 
        from
            Team team0_ limit ?
Hibernate: 
    /* load one-to-many cwchoiit.jpql.Team.members */ select
        members0_.TEAM_ID as team_id3_0_1_,
        members0_.id as id1_0_1_,
        members0_.id as id1_0_0_,
        members0_.name as name2_0_0_,
        members0_.TEAM_ID as team_id3_0_0_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID in (
            ?, ?
        )
TEAM1|members.size(): 2
member.getName() = MEMBER1
member.getName() = MEMBER2
TEAM2|members.size(): 1
member.getName() = MEMBER3

위 쿼리를 보면 지연로딩으로 인한 프록시 초기화를 하기 위해 두번째 쿼리를 날리는데 그때 WHERE절을 잘보면 IN을 사용해서  TEAM_ID가 그 중 하나에 속했다면 멤버들을 다 가져오는 쿼리를 날리고 있다. 지금은 팀이 전체 2개밖에 없으니까 딱 IN 안에 2개만 들어갔지만, 만약 200개 300개가 넘으면 내가 설정한 BatchSize인 100개를 IN에 넣어 한번에 좀 여러개의 멤버를 가져올 수 있다. 이렇게 최적화를 하면서 일대다 페이징을 처리할 수도 있다. 

 

페치조인 한계 마무리

  • 모든 것을 페치 조인으로 해결할 수는 없다. (그러나, 거의 95%는 페치 조인으로 다 해결이 된다고 본다)
  • 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다. 그러니까 멤버와 팀이 있으면 각각이 가진 필드들이나 참조를 그대로 반환하고 사용하는 경우를 말한다. 그렇지 않은 경우가 아래 경우다.
  • 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면 페치 조인보다는 일반 조인을 사용해서 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적이다.

 

다형성 쿼리

그렇게 중요한 부분은 아닌데 알고 가면 좋으니까 추가적으로 작성한다. 이전 포스팅에서 상속 관계 매핑에서 배운 부모-자식 관계에 대한 엔티티가 있을 때에 대한 이야기이다.

 

이렇게 생겨먹은 엔티티 구조가 있을 때, 조회 대상을 특정 자식으로 한정하는 쿼리에 대해 알아보자.

예를 들어, "Item 중에 Book, Movie를 조회해라" 라는 질의가 있으면 이렇게 하면 된다.

// JPQL
SELECT i 
FROM Item i
WHER TYPE(i) IN (Book, Movie)

// SQL
SELECT i
FROM Item i
WHERE i.DTYPE IN ('Book', 'Movie')

 

 

또는 캐스팅과 같은 방법도 있다. TREAT라는 키워드인데, 예를 들어 부모인 Item과 자식 Book이 있을 때, 이런식으로 작성할 수 있다.

// JPQL
SELECT i 
FROM Item i
WHERE TREAT(i as Book).author = 'kim'

// SQL
SELECT i.*
FROM Item i
WHERE i.DTYPE = 'Book' AND i.author = 'kim'

 

 

엔티티 직접 사용

무슨 말이냐면, 바로 JPQL을 보자.

엔티티 직접 사용 - 기본키 값

// JPQL
SELECT COUNT(m.id) FROM Member m // 엔티티의 아이디를 사용
SELECT COUNT(m) FROM Member m // 엔티티를 직접 사용
  • 이런 식으로 COUNT를 사용할 때 엔티티의 기본키를 사용하는게 아니라 엔티티 자체를 가지고 사용하는 걸 말하는데 이러면 어떻게 될까? 

 

▶ SQL로 번역될 때 해당 엔티티의 기본키 값을 사용한다.

// 번역된 SQL
SELECT COUNT(m.id) as cnt FROM Member m

 

 

엔티티 직접 사용 - 파라미터 값

위와 같은 경우 말고, 파라미터로 전달할 때도 엔티티를 넘길 수 있는데 이것 역시 식별자로 변환된다.

엔티티를 파라미터로 전달

String jpql = "select m from Member m where m = :member";
List result = em.createQuery(jpql).setParameter("member", member).getResultList();

 

식별자를 전달

String jpql = "select m from Member m where m.id = :memberId";
List result = em.createQuery(jpql).setParameter("memberId", memberId).getResultList();

 

실행된 SQL (둘 다 마찬가지로)

SELECT m.*
FROM Member m
WHERE m.id=?

 

 

엔티티 직접 사용 - 외래키 값

이번엔 외래키 자리에 넣을 값도 엔티티 자체를 넘길수도 있는데 이것 역시 마찬가지로 식별자로 변환된다.

 

외래키 자리에 엔티티를 직접 전달

String jpql = "select m from Member m where m.team = :team";
List result = em.createQuery(jpql).setParameter("team", team).getResultList();

 

외래키 자리에 식별자를 전달

String jpql = "select m from Member m where m.team.id = :teamId";
List result = em.createQuery(jpql).setParameter("teamId", teamId).getResultList();

 

실행된   (둘 다 마찬가지로)

SELECT m.*
FROM Member m
WHERE m.team_id=?

 

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] Part 18. 벌크 연산  (0) 2023.10.30
[JPA] Part 17. Named Query  (2) 2023.10.30
[JPA] Part 15. JPQL  (0) 2023.10.28
[JPA] Part 14. 값 타입 컬렉션  (0) 2023.10.25
[JPA] Part 13. 임베디드 값 타입  (0) 2023.10.23
728x90
반응형
SMALL
반응형
SMALL

2024.10.31 업데이트


JPQL(Java Persistence Query Language) 데이터베이스의 SQL과 유사하나 객체지향 쿼리 언어이며 그렇기에 테이블을 대상으로 쿼리하는 게 아니라 엔티티 객체를 대상으로 쿼리한다는 차이점이 있다. JPQLSQL을 추상화해서 특정 데이터베이스 SQL에 의존하지 않는다. 이 말은 데이터베이스마다 다른 방언(MySQL은 LIMIT, Oracle은 ROWNUM 같은)에 상관없이 동작한다는 의미이다.

 

그러나 결국, DB는 SQL만을 받기 때문에 JPQL은 결국에는 SQL로 변환된다. 

JPQL을 잘 이해하고 사용할 줄 알아야 기본적인 쿼리문을 사용하는데 문제가 없고 복잡한 쿼리를 처리해주는 QueryDsl도 편하게 사용할 수 있다. 그래서 JPQL을 잘 사용할 줄 알아야 한다.

 

JPA의 다양한 쿼리 방법

JPQL을 공부하기 앞서, JPA는 다양한 쿼리 방법을 지원한다.

  • JPQL
  • JPA Criteria
  • QueryDsl
  • native SQL
  • JDBC API 직접 사용

지금까지는 이런식으로 간단하게 작성했다. 

AddressEntity ae = entityManager.find(AddressEntity.class, 1L);

근데, 개발을 하다보면 이런 간단한 쿼리도 있지만 복잡한 쿼리도 필요할 때가 있기 마련이다. 예를 들어, "유저의 이름이 중간에 'hello'가 들어가는 유저들" 이런 쿼리를 원한다면? 사실 이것도 복잡한 쿼리도 아니다. 근데 여기서 말하고자 하는건 이렇게 조건이 들어가는 경우가 비일비재 하다는 것이다. 

 

그러면 이제, 다음과 같이 쿼리를 작성할 수 있다.

이게 바로 JPQL이다. 그리고 이게 지금 IDE가 잘 도와주고 있기 때문에 이렇게 보이는거지 사실 저건 다 문자열이다. 그래서 IDE의 도움을 받지 못하는 경우에는 문자열에 잘못된 부분이 있어도 컴파일러는 체크할 수 없다.

 

그리고 이 문자열로 쿼리를 만들어 낼 때 가장 까다로운 것은 동적 쿼리를 만들기가 정말 어렵다는 것이다. 그래서 JPA Criteria라는게 있는데 결론부터 말하면 이건 사용하지 마라. 근데 일단 스펙에 있기 때문에 뭔지는 알아야 하니까 말하자면 아래와 같이 사용하면 된다.

 

JPA Criteria 사용 예시

// Criteria 사용 준비
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Member> query = cb.createQuery(Member.class);

//루트 클래스 (조회를 시작할 클래스)
Root<Member> m = query.from(Member.class);

// 쿼리 생성
CriteriaQuery<Member> cq = query.select(m).where(cb.equal(m.get("username"), "kim"));
List<Member> resultList = entityManager.createQuery(cq).getResultList();
  • 문자가 아닌 자바 코드로 JPQL을 작성할 수 있다. 
  • JPQL의 빌더 역할을 하는게 JPA Criteria이다.
  • JPA에서 공식적으로 지원하는 기능이다. 
  • 그러나, 너무 복잡하고 실용성이 없다. 실용성이 없다는 말은 저 코드를 보고 이게 어떤 쿼리인지 인지하기가 너무 어렵다. 지금이야 아주 간단한 쿼리니까 읽다보면 이해할 수 있겠지만, 조금만 복잡해져도 알아보기가 너무 어렵다.

그래서 결론을 말하면 그냥 딱 이렇게 생각하면 된다. 

JPQL + QueryDsl 두 개를 같이 사용하면 거의 95% 경우의 쿼리를 다 수행할 수 있다. 만약, 정말 정말 복잡한 쿼리가 있어서 저 둘로 해결하지 못한다면, 그럴때 nativeQuery를 사용하면 된다. 실제로 SQL을 작성해서 그 SQL을 수행할 수 있게 해주는 녀석이다. 아래가 그 예시 코드이다. 

String sql = "SELECT ID, AGE, TEAM_ID, NAME FROM MEMBER WHERE NAME = 'kim'";
List<Member> result = entityManager.createNativeQuery(sql, Member.class).getResultList();

 

JPQL 문법

JPQL 문법이라고 SQL과 다를 것 없다. 다만 지켜야 할 규칙이 있다.

select m from Member as m where m.username = "member"
  • 엔티티와 속성은 대소문자 구분을 한다 (Member, username)
  • JPQL 키워드는 대소문자 구분을 하지 않는다 (SELECT, FROM, where)
  • 엔티티 이름을 사용한다 (테이블 이름이 아니다)
  • 별칭은 필수(m), as는 생략 가능

 

TypedQueryQuery 타입이 있는데 TypedQuery는 반환되는 데이터의 타입이 명확할 때를 말하고 Query는 명확하지 않을 때이다.

크게 중요한 내용은 아닌데 알아둬야 하니까.

TypedQuery<Member> members = em.createQuery("select m from Member m", Member.class);

이렇게 createQuery()에 두번째 인자로 타입을 지정할 수 있는데 아무 타입이나 지정할 수 있는건 아니고 거의 대부분은 엔티티를 지정한다. 이렇게 타입을 명확히 명시를 한 상태에서의 반환타입이 TypedQuery가 된다.

 

그러나 아래와 같이 idLong, usernameString인 데이터를 받아올 때는 타입을 명시할 수 없으므로 반환되는 데이터 타입이 Query가 된다. 이런 차이가 있다.

Query members = em.createQuery("select m.id, m.username from Member m");

 

 

그러나, 저런식으로 사용하지 않고 체인으로 사용하는게 일반적이고 그 예시는 다음과 같다.

List<Member> members = em.createQuery("select m from Member m", Member.class)
                                    .getResultList();
Member singleMember = em.createQuery("select m from Member m where m.id=1", Member.class)
                                    .getSingleResult();

이렇게 한번에 getResultList() 또는 getSingleResult()로 데이터를 받아온다. 메소드 명만 봐도 알겠지만 getResultList()는 복수의 데이터를 가져오는 방식이고 getSingleResult()는 단일값을 가져오는 방식이다.

 

그러나 주의할 점이 있다.

getResultList()는 데이터가 없는 경우 빈 리스트를 반환하기 때문에 큰 문제가 되지 않는다. 그러나 getSingleResult()는 무조건 딱 더도 덜도 말고 딱! 하나만 있어야 한다. 만약, getSingleResult()를 사용하는데, 데이터가 없는 경우 NoResultException이 발생하고 데이터가 둘 이상인 경우 NonUniqueResultException이 발생한다. 이것을 주의해야한다. (Spring Data JPA는 결과가 없으면 이 에러를 처리해서 null로 반환해주긴 함)

 

파라미터 바인딩 - 이름 기준, 위치 기준

파라미터를 던져줄 상황이 굉장히 많이 발생할텐데 사용방법은 또 간단하다.

이름 기준

List<Member> members = em
                    .createQuery("select m from Member m where m.username = :username", Member.class)
                    .setParameter("username", "Member1")
                    .getResultList();

username이라는 파라미터를 던져줄 때 setParameter()를 호출한다. 그리고 받는 쪽은 where m.username = :username 여기가 파라미터를 받는 부분이다.

 

위치 기준

위치기준은 사용하지 말자. 그러나 뭐가 위치 기준인지는 알아야 하니 일단 예시는 다음과 같다.

List<Member> members = em
                    .createQuery("select m from Member m where m.username=?1", Member.class)
                    .setParameter("1", "Member1")
                    .getResultList();

"?1" 이 부분이 파라미터를 받는 부분, 주는 부분은 setParameter("1", "value") 이 부분이다. 보면 알겠지만 코드 길어지거나 안보다가 보면 "1"이런 값들이 가시성이 확 떨어진다. 또한 중간에 추가하게 되면 순서가 달라지기 때문에 다 밀리게 되고 아주 불편하다.

 

 

프로젝션

프로젝션이란, SELECT절에 조회할 대상을 지정하는 것을 말한다.

 

프로젝션 대상

  • 엔티티
  • 임베디드 타입
  • 스칼라 타입 (숫자, 문자 등 기본 데이터 타입)

엔티티 프로젝션

// 엔티티 프로젝션
SELECT m FROM Member m
  • 멤버 전체를 가져오는 쿼리이므로 엔티티 프로젝션을 말한다.
  • 그리고 이렇게 엔티티 프로젝션을 통해 엔티티를 가져오면, 엔티티 매니저에 의해서 영속성 컨텍스트가 관리하는 대상이 된다. 즉, 1차 캐시에도 들어가고 변경감지도 수행된다는 의미다.
// 엔티티 프로젝션
SELECT m.team FROM Member m
  • 멤버의 팀을 가져오는 쿼리로 팀이 엔티티이기 때문에 엔티티 프로젝션이라 한다.
  • 근데 이 코드를 수행하면 어떤 쿼리가 나갈까? 멤버가 속한 팀을 가져오고 있는데 그럼? 맞다. 조인을 하게 된다.
List<Team> result = entityManager
                    .createQuery("SELECT m.team FROM Member m", Team.class)
                    .getResultList();

실행된 쿼리

Hibernate: 
    /* SELECT
        m.team 
    FROM
        Member m */ select
            team1_.id as id1_3_,
            team1_.name as name2_3_ 
        from
            Member member0_ 
        inner join
            Team team1_ 
                on member0_.TEAM_ID=team1_.id

이렇듯, 조인을 한다. 물론 당연한 소리다. 당연히 조인을 해야지. 근데 JPQL만 보면 조인을 한다고 명시적으로 적어주지 않았기 때문에 JPQL과 JPA에 대해 잘 이해하고 있는 사람이야 알겠지만 그렇지 않은 사람은 모를수도 있다. 즉, 명시적으로 JPQL을 작성해 주는게 훨씬 더 좋은 방향이라고 본다. 아래처럼 말이다.

List<Team> result = entityManager
                    .createQuery("SELECT t FROM Member m JOIN m.team t", Team.class)
                    .getResultList();

 

임베디드 타입 프로젝션

// 임베디드 타입 프로젝션
SELECT m.address FROM Member m
  • 멤버의 값 타입으로 임베디드한 Address를 가져오는 쿼리이므로 임베디드 타입 프로젝션이라 한다.

스칼라 타입 프로젝션

// 스칼라 타입 프로젝션
SELECT m.username, m.age FROM Member m
  • 멤버가 가지고 있는 문자, 숫자 등 기본 데이터 타입을 가져오므로 스칼라 타입 프로젝션이라 한다.
// DISTINCT로 중복을 제거
SELECT DISTINCT m.username, m.age FROM Member m
  • 참고로 중복제거도 역시 가능하다. 그래서 풀 코드로 작성하면 이렇게 쓰면 된다.
Query members = em.createQuery("select distinct m.id, m.username from Member m");
근데 여기서 한가지 의문이 발생한다. "이 경우에는 타입을 지정못하는데 가져올 땐 어떻게 가져와야 하지?"

 

방법은 여러가지가 있다.

첫번째는, 위에서 작성한 코드처럼 Query 타입으로 가져오는 방법이다.

Query query = em.createQuery("select m.username, m.id From Member m");
List<Object[]> result = query.getResultList();

Object[] data = result.get(0);
System.out.println("username: " + data[0]);
System.out.println("id: " + data[1]);

//Output:
username: Member1
id: 1
  • 모든 데이터 타입의 상위 타입은 Object 이므로, Object[]로 받는다. []로 받는 이유는 m.username, m.id[]에 0번, 1번으로 들어가기 때문이다. 그래서 0과 1을 찍어보면 각각 usernameid를 출력한다.
  • 그러나 좀 불편한 부분이 있어보인다. 그래서 좀 더 좋은 방법은 DTO 객체로 받아오는 것이다.

두번째는, DTO 객체로 받아오는 방법이다.

SelectMemberDTO

package org.example.entity.jpql;

public class SelectMemberDTO {
    
    private Long id;
    private String username;

    public SelectMemberDTO(Long id, String username) {
        this.id = id;
        this.username = username;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }
}
  • 이렇게 받아줄 DTO 클래스를 만들고 이 객체로 데이터를 받아오는 방법이다. 아래 코드를 보자.
List<SelectMemberDTO> result = em
            .createQuery(
                    "select new org.example.entity.jpql.SelectMemberDTO(m.id, m.username) From Member m",
                    SelectMemberDTO.class)
            .getResultList();

    for (SelectMemberDTO selectMemberDTO : result) {
        System.out.println("selectMemberDTO id= " + selectMemberDTO.getId());
        System.out.println("selectMemberDTO username= " + selectMemberDTO.getUsername());
    }
  • 보면 new org.example.entity.jpql.SelectMemberDTO(m.id, m.username) 이라고 작성되어 있다. 여기가 문자열이기 때문에 패키지명까지 쭉 작성해줘야하는 번거로움이 있긴하지만 위에 Object로 받는 방법보다는 훨씬 가시적이다.
  • 그리고 이후에 QueryDsl을 배우고 사용하면 저렇게 패키지도 쭉 안 적어도 된다. 이후에 같이 배워보자!

 

페이징

JPA에서 페이징하는 방법이 너무 간단하게 잘 되어있다. setFirstResult(), setMaxResults()를 사용하면 끝이다.

List<Member> resultList = em
                .createQuery("select m from Member m order by m.id desc", Member.class)
                .setFirstResult(0)
                .setMaxResults(10)
                .getResultList();
  • setFirstResult(0), setMaxResults(10)을 주면 0부터 10개까지를 가져온다. 이게 끝이다. 결과는 다음과 같다.
Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_0_,
        member0_.TEAM_ID as team_id3_0_,
        member0_.username as username2_0_ 
    from
        Member member0_ 
    order by
        member0_.MEMBER_ID desc limit ?
member = Member99
member = Member98
member = Member97
member = Member96
member = Member95
member = Member94
member = Member93
member = Member92
member = Member91
member = Member90

 

조인

조인의 경우 크게 세 가지가 있다.

  • 내부 조인: [INNER] JOIN
  • 외부 조인: LEFT [OUTER] JOIN
  • 세타 조인

 

내부 조인 ([INNER] JOIN)

내부조인은 아래 JPQL을 보면 멤버를 기준(`FROM Member m`)으로 조인하고 있는데 이때, 팀의 값이 있는 데이터들만 뽑아낸다. 그러니까 팀의 값이 없는 멤버 레코드는 아예 결과로 나오지가 않는게 내부 조인이다.

SELECT m FROM Member m [INNER] JOIN m.team t

 

외부 조인 (LEFT [OUTER] JOIN)

외부조인은 아래 JPQL을 보면 멤버를 기준(`FROM Member m`)으로 조인하고 있는데 이때, 팀의 값이 없어도 결과로 레코드가 나온다. 대신 팀의 값이 null로 표현된다. 참고로, LEFT [OUTER] JOIN이다. OUTER는 생략가능하고 보통은 LEFT JOIN이라고 많이 말한다. OUTER JOIN 이런건 없다!

SELECT m FROM Member m LEFT [OUTER] JOIN m.team t

 

세타 조인

아무런 연관성이 없는 데이터들도 뽑아낸다. 보면 `m.team t` 가 아니라, `FROM Member m, Team t`다. 즉, 어떠한 연관도 없는 두 엔티티끼리도 조인이 가능한데 이것을 세타 조인이라고 한다.

SELECT count(m) FROM Member m, Team t WHERE m.username = t.name

 

 

그래서 조인을 사용하는 코드를 한번 작성해보면 다음과 같다.

// 내부 조인
String query = "select m from Member m join m.team t where t.name =:teamName";
List<Member> resultList = em
        .createQuery(query, Member.class)
        .setParameter("teamName", "Team1")
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();
        
// 외부 조인 
String query = "select m from Member m left join m.team t";
List<Member> resultList = em
        .createQuery(query, Member.class)
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();
        
        
// 세타 조인
String query = "SELECT m FROM Member m, Team t WHERE m.username = t.name";
List<Member> resultList = em
        .createQuery(query, Member.class)
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();

 

조인 대상 필터링 - ON

조인 하기 전 필터링을 해서 데이터 양을 좀 줄일 수 있고 JPQL도 역시 유사한 키워드로 지원한다.

List<Member> result = entityManager
                    .createQuery("SELECT m FROM Member m JOIN m.team t ON t.name = :teamName", Member.class)
                    .setParameter("teamName", "teamA")
                    .getResultList();
  • 저렇게 ON절을 사용해서 팀의 이름을 특정하여 필터링을 할 수가 있다.
  • 실제 나가는 쿼리는 어떻게 나갈까? 바로 아래와 같이 나간다.
SELECT m.*, t.* 
FROM Member m
INNER JOIN Team t ON m.TEAM_ID = t.id AND (t.name = 'teamA')

 

 

연관 관계가 없는 엔티티의 외부 조인도 필터링을 걸 수 있다.

List<Member> result = entityManager
                    .createQuery("SELECT m FROM Member m JOIN Team t ON t.name = m.name", Member.class)
                    .getResultList();
  • 보면 m.team을 사용한게 아니라 JOIN Team t 이렇게 작성했다. 이렇게 연관관계가 없는 상태에서도 조인을 하는게 가능하고 이걸 세타 조인이라고 하며, ON절을 사용해서 필터링할 수 있다.
  • 실제 나가는 쿼리는 어떻게 나갈까? 바로 아래와 같이 나간다.
SELECT m.*, t.* 
FROM Member m
INNER JOIN Team t ON m.name = t.name

 

서브 쿼리

SQL문에서 사용하던 서브 쿼리랑 같은 것을 말하고 JPQL도 역시 이를 지원한다. 

SELECT m FROM Member m WHERE m.age > (SELECT avg(m2.age) FROM Member m2)

이렇게 서브 쿼리를 사용할 때 위에서처럼 mm2로 서브 쿼리랑 본 쿼리의 멤버를 분리해야 일반적으로 SQL 성능이 좋다. 

 

 

서브 쿼리 지원 함수로 다음과 같은 것들이 있다.

  • [NOT] EXISTS (subquery): 서브 쿼리에 결과가 존재하면 참
# 팀A 소속인 회원
SELECT m FROM Member m WHERE EXISTS (SELECT t FROM m.team t WHERE t.name = '팀A')

 

  • {ALL | ANY | SOME} (subquery)
    • ALL: 모두 만족하면 참
    • ANY, SOME: 같은 의미, 조건을 하나라도 만족하면 참
# 전체 상품 각각의 재고보다 주문량이 많은 주문들
SELECT o FROM Order o WHERE o.orderAmount > ALL (SELECT p.stockAmount from Product p)
# 어떤 팀이든 팀에 소속된 회원
SELECT m FROM Member m WHERE m.team = ANY (SELECT t FROM Team t)
  • [NOT] IN (subquery): 서브쿼리의 결과 중 하나라도 같은 것이 있으면 참
# 어떤 팀이든 팀에 속한 멤버들
SELECT m FROM Member m WHERE m.team IN (SELECT t FROM Team t)

JPA 서브 쿼리 한계

  • JPA는 WHERE, HAVING 절에서만 서브 쿼리 사용 가능
  • SELECT절도 가능 (하이버네이트에서 지원)
  • FROM 절의 서브 쿼리는 현재 JPQL에서 불가능 (조인으로 풀 수 있으면 풀어서 해결)
FROM절의 서브 쿼리도 이제 가능하다! Hibernate 6.1.0.Final 버전부터 가능하다! (아래 참조링크 확인)
 

Hot features of Hibernate ORM 6.1 - In Relation To

Hibernate ORM version 6.1.0.Final was just announced a few days ago, but the announcement didn’t go into a too much detail. Read on if you want to know more about some of the hot new features this shiny new release comes with.

in.relation.to

 

 

JPQL 타입 표현

  • 문자  'HELLO', 'She"s'
  • 숫자  10L(Long), 10D(Double), 10F(Float)
  • Boolean → TRUE(true), FALSE(false)
String query = "SELECT m.username, 'HELLO', TRUE, 10L FROM Member m";

List<Object[]> resultList = em
        .createQuery(query)
        .setFirstResult(0)
        .setMaxResults(1)
        .getResultList();

for (Object[] objects : resultList) {
    System.out.println("objects = " + objects[0]);
    System.out.println("objects = " + objects[1]);
    System.out.println("objects = " + objects[2]);
    System.out.println("objects = " + objects[3]);
}

//Output:
objects = Member0
objects = HELLO
objects = true
objects = 10

 

  • ENUM jpa.MemberType.ADMIN (패키지명을 다 포함)
String query = "SELECT m.username, 'HELLO', TRUE, 10L " +
                    "FROM Member m " +
                    "WHERE m.memberType = org.example.entity.jpql.MemberType.ADMIN";

근데 보통은 파라미터를 바인딩해서 사용할테니까 저렇게까지 불편할 정도는 아니다. 파라미터 바인딩을 하면 다음과 같이 조금 더 편리해지고 보기도 좋아진다.

String query = "SELECT m.username, 'HELLO', TRUE, 10L, m.memberType " +
                    "FROM Member m " +
                    "WHERE m.memberType = :memberType";

List<Object[]> resultList = em
        .createQuery(query)
        .setParameter("memberType", MemberType.ADMIN)
        .setFirstResult(0)
        .setMaxResults(2)
        .getResultList();

 

 

  • 엔티티 타입  TYPE(m) = Member (상속 관계에서 사용)

이렇게 ITEM, BOOK이 상속관계일 때 ITEM으로부터 가져온 레코드 중 DTYPEBook인 녀석들만 가져오는 쿼리도 할 수 있다는 것을 보여준다.

String query = "SELECT i " +
                    "FROM Item i " +
                    "WHERE TYPE(i) = Book";

List<Item> resultList = em
        .createQuery(query, Item.class);
        .getResultList();

 

  • JPQL 기타 → EXISTS, IN, AND, OR, NOT, =, >, >=, <, <=, BETWEEN, LIKE, IS NULL 모두 다 가능하다.
String query = "SELECT m " +
                    "FROM Member m " +
                    "WHERE m.age BETWEEN 0 AND 10";

List<Member> resultList = em
        .createQuery(query, Member.class);
        .getResultList();

 

조건식

CASE 식

//기본 CASE식
SELECT
	case when m.age <= 10 then '학생요금'
    	 when m.age >= 60 then '경로요금'
         else '일반요금'
     end
FROM Member m


//단순 CASE식
SELECT
	case t.name
        when '팀A' then '인센티브110%'
     	when '팀B' then '인센티브120%'
     	else '인센티브105%'
     end
FROM Team t

이렇게 생긴 문장 많이 봤을거다. SQL하면서. JPQL에서도 동일하게 지원해준다.

 

String query = "SELECT " +
                    "       case when m.age <= 10 then '학생요금'" +
                    "       when m.age >= 60 then '경로요금'" +
                    "       else '일반요금'" +
                    "       end " +
                    "FROM Member m";
            
List<String> resultList = em.createQuery(query, String.class)
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();

for (String s : resultList) {
    System.out.println("s = " + s);
}

COALESCE, NULLIF

COALESCE는 조회 결과가 NULL이 아니면 반환하는 조건식이다. 

SELECT COALESCE(m.username, '이름 없는 회원') FROM Member m

그러니까 찾은 유저의 m.usernameNULL인 경우 '이름 없는 회원'으로 대체한다는 의미이다.

 

NULLIF는 두 값이 같으면 NULL반환, 다르면 첫번째 값 반환

SELECT NULLIF(m.username, '관리자') FROM Member m

찾은 유저 각각의 유저네임이 관리자인 유저는 NULL로 유저네임을 반환하고 그게 아닌경우, 유저네임 그대로를 반환한다는 의미이다. 

 

 

JPQL 함수 

JPQL이 제공하는 표준 함수

JPQL이 표준으로 제공하는 함수들 (데이터베이스에 종속적이지 않아서 어떤 데이터베이스이든 상관없이 사용 가능하다)

  • CONCAT
  • SUBSTRING
  • TRIM
  • LOWER, UPPER
  • LENGTH
  • LOCATE
  • ABS, SQRT, MOD
  • SIZE, INDEX(JPA 용도)
// CONCAT
String query = "SELECT CONCAT('A', 'B') FROM Member m";

// SUBSTRING
String query = "SELECT SUBSTRING(m.username, 2, 3) FROM Member m";

....

//SIZE (컬렉션의 크기를 돌려줌)
String query = "SELECT SIZE(t.members) FROM Team t";
  • 기본으로 제공하는 함수가 있는 반면 DB에 종속적인 방언적 함수가 각 DB마다 또 있을 수 있는데 그런 함수가 JPQL이 지원을 즉각적으로 안하는 경우 사용자가 직접 함수를 등록해서 사용할 수 있다.

 

사용자 정의 함수

사용자 정의 함수란, JPQL이 공식적으로는 지원하지 않지만 해당 데이터베이스에서 지원하는 함수를 사용하고 싶을 때 그 함수를 등록해서 사용하는 방법을 말한다. 예를 들어, H2 데이터베이스는 `group_concat`이라는 함수가 있다. 이 함수는 조회한 레코드들로 사용자로부터 받은 특정 컬럼을 한 줄로 쭉 이어 붙이는 함수인데 이건 H2 데이터베이스에서 지원하는 함수다. 그리고 내가 만약 이 함수를 사용하고 싶다면?

 

아래와 같이 Dialect를 커스텀할 클래스하나를 만들고 H2 데이터베이스의 H2Dialect를 상속받는다. 그리고 내가 원하는 함수를 생성자 안에서 등록한다. 

package cwchoiit.jpql;

import org.hibernate.dialect.H2Dialect;
import org.hibernate.dialect.function.StandardSQLFunction;
import org.hibernate.type.StandardBasicTypes;

public class MyH2Dialect extends H2Dialect {

    public MyH2Dialect() {
        registerFunction("group_concat", new StandardSQLFunction("group_concat", StandardBasicTypes.STRING));
    }
}

 

그리고 JPA 설정 파일에서 Dialect를 이 파일로 변경만 해주면 끝. 아래는 XML로 설정했기 때문에 XML 파일 형식인거고 요즘은 거의 YAML, properties 파일에서 설정할 것이다.

...
<property name="hibernate.dialect" value="cwchoiit.jpql.MyH2Dialect"/>
...

 

위와 같이 설정을 한 다음 사용할 수 있다. 사용하는 코드를 아래에서 확인해보자.

Main

package cwchoiit;

import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.getTransaction().begin();
        try {

            Member member = new Member();
            member.setName("member");
            entityManager.persist(member);

            Member member2 = new Member();
            member2.setName("member2");
            entityManager.persist(member2);

            entityManager.flush();
            entityManager.clear();

            List<String> resultList = entityManager
                    .createQuery("SELECT FUNCTION('group_concat', m.name) FROM Member m", String.class)
                    .getResultList();

            for (String s : resultList) {
                System.out.println("s = " + s);
            }

            entityManager.getTransaction().commit();
        } catch (Exception e) {
            entityManager.getTransaction().rollback();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}

실행 결과

Hibernate: 
    /* SELECT
        FUNCTION('group_concat',
        m.name) 
    FROM
        Member m */ select
            group_concat(member0_.name) as col_0_0_ 
        from
            Member member0_
s = member,member2

 

 

경로 표현식

경로 표현식은 크게 세가지로 분류할 수 있다.

  • 상태 필드: 단순히 값을 저장하기 위한 필드 (예: m.username
  • 단일 값 연관 필드: @ManyToOne, @OneToOne 대상이 엔티티 (예: m.team)
  • 컬렉션 값 연관 필드: @OneToMany, @ManyToMany. 대상이 컬렉션 (예: m.orders)

 

  • 상태 필드: 경로 탐색의 끝, 이후 탐색 불가능
// m.username이 상태 필드 경로 표현식
select m.username from Member m;

 

  • 단일 값 연관 경로: 묵시적 내부 조인 발생, 이후 탐색 가능 
// m.team이 단일 값 연관 경로 표현식
select m.team from Member m;
  • 컬렉션 값 연관 경로: 묵시적 내부 조인 발생, 이후 탐색 불가능
// t.members가 컬렉션 값 연관 경로 표현식
select t.members from Team t;

 

여기서 솔직히 어려운 내용은 하나도 없다. 객체로 생각해보면 당연히 상태 필드는 그 녀석이 더 담고 있는 데이터가 없기 때문에 이후 탐색이 불가능한거고 특정 엔티티가 타입인 경우 그 녀석이 가지고 있는 다른 데이터가 있기 때문에 그 이후에 탐색이 가능한것이다. 컬렉션은 컬렉션의 하나하나의 데이터를 뽑아오는 게 아니라 컬렉션 자체를 반환하기 때문에 그 이후에 탐색이 불가능한 것이다.

 

이게 중요해서가 아니라 묵시적 내부 조인이 발생한다는 것이 중요하다.

// m.team이 단일 값 연관 경로 표현식
select m.team from Member m;

위 코드에서 멤버의 팀을 가져오기 위해서는 DB입장에서는 조인을 할 수 밖에 없다. 그래서 내부조인이 일어난다. 하지만 우리가 직접 명시하지 않았기 때문에 묵시적으로 조인이 발생한다. 코드 수행 결과를 한번 보면 다음과 같다.

String query = "SELECT m.team FROM Member m";

List<Team> resultList = em.createQuery(query, Team.class)
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();

for (Team team1 : resultList) {
    System.out.println("team1 = " + team1);
}

실행 결과

select
        team1_.TEAM_ID as team_id1_1_,
        team1_.name as name2_1_ 
    from
        Member member0_ 
    inner join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID limit ?
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e

보다시피 내부 조인이 일어난다. 당연하다. 근데 작성한 코드에서는 명시적으로 조인을 사용하지 않았기에 조인을 사용할 것이라고 예측하기는 쉽지는 않다. JPA를 잘 이해하고 잘 하는 사람이야 바로 보고 알수도 있겠다만. 

 

또 컬렉션에서도 경로 탐색이 더이상 불가능한데 다음 코드를 보자.

// t.members가 컬렉션 값 연관 경로 표현식
select t.members from Team t;

이렇게 생긴 쿼리를 실제로 수행하면 당연히 묵시적 내부 조인이 발생한다. 팀에 해당하는 멤버를 가져와야 하기 때문에.

그리고 여기서는 더 이상 탐색이 불가하다. 근데 탐색하고 싶을 수 있다. 멤버 하나하나의 이름이나 뭐 다른 데이터를 알고싶을 수 있잖아.

그럴 때는 FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색이 가능하다.

String query = "SELECT m.username FROM Team t join t.members m";

이렇게 컬렉션은 위 방법으로 이후 탐색을 진행할 수 있다. 그러나, 가장 중요한 내용은 다음과 같다.

묵시적 조인이 발생하는 경우를 모두 차단해라.

 

묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵고 조인은 SQL 튜닝에 중요 포인트다. 그렇기 때문에 묵시적 조인은 가급적(아예) 사용하지 말자. 

 

728x90
반응형
LIST

+ Recent posts