김영한님의 인프런 강의와 PDF를 바탕으로 정리하였습니다.
https://www.inflearn.com/courses?s=%EA%B9%80%EC%98%81%ED%95%9C
지난 포스팅에서는 to ONE 연관관계를 가진 엔티티를 조회시에 패치 조인을 사용해서 쿼리를 최적화할 수 있었다.
하지만 이번에는 to MANY관계일 때인데, 이는 간단하게 패치조인을 사용한다고 해서 해결되지 않는다.
+추가로 그전에 현재 프로젝트에는 hibernate5 module이 빈으로 존재한다.
이 친구가 있으면 엔티티 반환시에 프록시로 존재하는 엔티티는 반환하지 않는다.
만약 Order엔티티에 member엔티티가 연관관계로 있고, Order리포지토리를 사용해서 Order들을 find했다면 member엔티티들은 프록시 객체일 것이다. 이러한 프록시 상태의 member엔티티는 반환하지 않는 것이다.(여기서 반환은 컨트롤러에서 클라이언트로 엔티티를 JSON으로 뿌려주는 것을 말한다.)
먼저 Order엔티티가 어떤 엔티티들을 연관관계로 가지고 있는 보자.
Order엔티티는 현재 OrderItem와 연관관계를 맺고 있는데, 그 관계가 One To Many이다. 즉 리스트로 연관관계를 가지고 있는 것이다. (이말인 즉슨 실제 order테이블에 orderItems에 대한 칼럼은 없다. 반대로 orderItem에는 order의 id가 외래키로 존재한다.)
추가로 OrderItem의 엔티티를 살펴보면 아래와 같이 Item과의 연관관계를 To One의 관계로 가지고 있다.
우리는 Order를 조회하면서 OrderItem과 OrderItem이 가지고 있는 Item엔티티까지 모두 조회하고 싶은 것이다.
이러한 컨트롤러는 아래와 같다(아래는 V1 버전으로 엔티티를 그대로 반환하고 있음. 이는 지양해야함.)
프록시 상태인 OrderItem을 조회하기 위해 orderItem을 order에서 get해서 컬렉션에 담는다.
다음 라인에서 forEach구문으로 orderItem.getItem까지 하면 프록시 객체가 아닌 진짜 OrderItem을 DB로 가서 조회해온다.
또한 getItem()까지 수행하면 OrderItem과 toOne 관계를 가지는 Item까지 조화하게 된다.
즉 orderItem.getItem().getName()까지 해서 orderItem, Item 모두 강제로 select문을 날려 초기화하는 것이다. 이렇게 하면 orderItem, Item 모두 프록시 객체가 아닌, DB에서 긁어온 객체가 된다.
이제는 위의 V1버전에서 진화시켜 엔티티들을 직접 반환하지 않고, DTO객체로 감싸서 반환해보자. 이를 위해서는 OrderDTO와 OrderItemDTO 모두가 필요하다.
Order에 OrderItemDTO타입의 리스트를 가지므로 OrderItemDTO도 반드시 필요하다.
이제 컨트롤러를 보자.
이렇게 DTO로 감싸 반환하는 것은 좋지만 연관관계가 걸려있는 엔티티들에 대해서도 select를 날려서 상당히 많은 쿼리가 나가는
N+1문제가 발생한다. 이제 이 문제를 해결해보자.
일단 이 문제를 해결하기 하기 위해서는 물론 fetch join이 필요하다. fetch join은 연관관계를 가진 엔티티도 한번에 가져오기 때문이다.
위에 보면 findAllWithItem() 메소드가 추가 되었다, 이는 리퍼지토리에 패치조인을 하기 위해 만든 메소드이다.
member, delivery, orderItems, item을 모두 fetch join하여 한번에 가져온다.
그런데 이때 문제가 발생한다.
Order와 OrderItem은 1:N 관계이다. 그렇다면 패치조인된 테이블은 orderItem에 같은 order id를 가진 로우들이 있어서 데이터가 뻥튀기 될 수 있다. 이해가 안간다면 아래 참조.
https://kingchan223.tistory.com/200?category=870827
https://kingchan223.tistory.com/201?category=870827
하여 중복된 데이터가 조회될 수 있다는 것이다. 이를 위해서 DISTINCT 키워드를 사용한다.
이렇게 하면 JPA가 sql을 날릴 때, 맨 앞에 distinct키워드를 삽입해준다.
위에서 보듯이 distinct 키워드를 삽입해주는 것을 볼 수 있다.
하지만1 이도 문제인것이. distinct키워드는 두 줄이 완전히! 같아야만 중복을 제거해준다.
그래서 SQL의 distinct와 더불어 JPA의 distinct는 현재 조회중인 엔티티의 id(order의 id)가 같다면 중복으로 보고 같은 것을 제거해준다.
하지만2
이것에도 큰 단점이 있는데, 페이징이 불가능해진다는 점이다.
한 번 다음과 같이 페이징을 추가하고 실행해보자.
setFirstResult, setMaxResult추가.
실행해보면 아래와 같은 경고가 나온다.
즉 페이징을 메모리에서 하겠다는 것이다. 만약 1000개의 데이터가 있다면 1000개를 모두 메모리로 가져온 후 페이징을 하게 되는 것이다.(왜냐하면 DB의 테이블은 N 관계와의 패치조인으로 인해 데이터가 뻥튀기가 된 상태이다. 하여 중복된 데이터를 가지고 페이징을 하는 것은 결국 Order기준이 아닌, OrderItem기준으로 페이징을 하게 되는 것이나 마찬가지다. 하여 JPA가 그러한 중복도 제거시킨 후, 메모리에서 중복을 없앤 상태에서 페이징을 해주겠다는 것이다.)
이는 매우 위험하다. 서버 바로 죽는다.
참고: 컬렉션 패치 조인은 1개만 사용할 수 있다. 즉 Order와 OrderItem이 1:N인데, 여기서 더 나아가서 OrderItem과 A라는 엔티티가 1:N일 때 요 A엔티티까지 조회하면 안되는 것이다. -> 이는 데이터가 엄청 뻥튀기 되는데, jpa는 이를 부담스러워 한다.
결론적으로 페이징을 할 수 없다는 문제가 발생한다.
이제 이를 해결하는 방법은 다음과 같다.
1. 먼저 To One관계는 모두 패치조인한다. (To One관계는 row수를 증가시키지 않는다.)
2. 컬렉션은 지연 로딩으로 조회한다. (즉 Order에서 OrderItems는 냅둔다.)
3. 지연 로딩 성능을 최적화하기 위해 hibernate.default_batch_fetch_size를 글로벌로 설정하거나 @BatchSize를 적용한다.
BatchSize
글로벌하게 설정하는 것을 보면 아래와 같다.
위와 같이 application.yml에 spring.jpa.properties.hibernate.default_batch_fetch_size: 100
이렇게 하면 배치 사이즈를 설정할 수 있다.
배치사이즈를 100으로 지정하면 연관관계가 걸린 엔티티를 touch하면 최대 100개까지 끌어온다.
Order의 경우를 보면 Order는 OrderItem과 toMany 관계를 가진다. 이때 Order의 OrderItem을 touch하면 OrdetItem을 100개까지 미리 당겨오는 것이다. 또한 OrderItem의 Item도 마찬가지이다. OrdetItem의 Item을 touch하면 Item도 100개까지 끌어온다.
이렇게 조회 쿼리를 최적화 할 수 있는 것이다.
⭐️⭐️⭐️⭐️⭐️ 이렇게 하면 쿼리 최적화와 페이징이라는 두 마리 토끼를 다 잡을 수 있다. ⭐️⭐️⭐️⭐️⭐️
위의 findAllwithMemberDelivery는 to One 관계인 member와 delivery는 fetch join으로 가져온다.(To One관계는 모두 패치조인)
그리고 OrderDTO2를 create하는 부분에서 OrderItems와 Item을 터치한다.
이때 Batch가 발동되어 한번에 OrderItems을 최대 100개 땡겨오고, Item도 최대 100개 땡겨오는 것이다. 즉 1 + N + N 로 쿼리가 나가던 것이 1 + 1 + 1 로 된다.
아래에서 보이듯이 in쿼리로 orderItems과 item을 가져오는 것을 볼 수 있다.
추가로 아래처럼 @BatchSize를 사용해서 디테일하게 적용할 수 도 있다.
Order와 OrderItems처럼 to Many 관계일 때는 위처럼 컬렉션 위에 설정하고,
아래처럼 OrderItems와 Item의 관계처럼 to One 관계일 때는 엔티티 위에 설정한다.
DTO직접 조회하기
이전까지는 엔티티를 전체를 가져온 뒤, 필요한 데이터만 DTO로 만드는 방식을 사용했다.
하지만 이번에는 처음부터 DTO에 맞추어 쿼리를 날리는 방식으로 하고자 한다. 이렇게 되면 확실히 애초에 날리는 데이터의 크기가 작아 효율적일 수 있다. 하지만 (Query DSL을 사용하지 않는 한) 개발자가 직접 해줘야할 것이 꽤 많다.
먼저 Query용 Repository와 Query용 DTO객체를 따로 만들어 줘야 한다.
일단 강의를 따라서 repository 패키지에 만들어 줬다.
Query용 OrderQueryDTO
Query용 OrderItemsQueryDTO
컨트롤러는 아래와 같다.
이제 메인인 orderQueryRepository를 살펴보자.
findOrderQueryDTOs메소드는 내부에서 findOrders와 findOrderItems메소드를 사용한다.
먼저 findOrders로 to One 관계인 member와 delivery를 조인하여 한번에 가져온다.
createQuery에서 : "new"를 사용하여 OrderQueryDto의 규격에 맞추어 필요한 데이터만 골라서 가져온다.
그러면 이에 대한 결과가 result에 담기고 만약 총 주문이 2건이라면 2개의 객체가 담겨있을 것이다.
그리고 19라인 forEach에서 findOrderItems 메소드로 각 orderId를 fk로 갖고 있는 orderItems를 가져온다.
그런데 위와 같은 방식의 문제점은 만약 order가 100개라면 orderItem도 역시 100번을 select해오는 1 + N문제가 발생한다.
⭐️⭐️⭐️⭐️⭐️ 업그레이드 버젼: ⭐️⭐️⭐️⭐️⭐️
하여 이번에는 1+N문제를 해결할 수 있는 방법을 사용해보자.
findAllByDTO_optimization메소드는 아래와 같다.
findOrders로 OrderQueryDTO타입의 리스트를 result에 담는 것은 똑같다.
하지만 48라인을 보면 스트림을 통해 orderId를 리스트에 담고, em.createQuery의 마지막을 보면 in쿼리를 사용해 orderItems를 한번에 땡겨온다.
그리고 땡겨온 결과인 List<OrderItemsQueryDTO>를 스트림을 통해 orderId를 key로한 Map으로 묶어준다.
그렇게 되면 61라인에서 map.get()으로 orderId에 맞는 List<orderItems>를 찾아 set해준다.
정리:
- V4는 코드가 단순하다. 특정 주문 한건만 조회하면 이 방식을 사용해도 성능이 잘 나온다. 예를 들어서 조회한 Order 데이터가 1건이면 OrderItem을 찾기 위한 쿼리도 1번만 실행하면 된다.
- V5는 코드가 복잡하다. 여러 주문을 한꺼번에 조회하는 경우에는 V4 대신에 이것을 최적화한 V5 방식을 사용해야 한다. 예를 들어서 조회한 Order 데이터가 1000건인데, V4 방식을 그대로 사용하면, 쿼리가 총 1 + 1000번 실행된다. 여기서 1은 Order 를 조회한 쿼리고, 1000은 조회된 Order의 row 수다. V5 방식으로 최적화 하면 쿼리가 총 1 + 1번만 실행된다. 상황에 따라 다르겠지만 운영 환경에서 100배 이상의 성능 차이가 날 수 있다.
개인적으로는 엔티티를 직접 조회할 때는 v3.1, DTO로 조회 시에는 v5버전이 가장 좋은 것 같다.
'JPA > JPA + SpringBoot' 카테고리의 다른 글
(X To ONE ) LAZY로딩에 의한 N+1문제 해결 (0) | 2021.09.08 |
---|---|
JPA 엔티티 수정 (merge와 dirty checking) (0) | 2021.07.31 |
회원 관련 기능 구현하기, 테스트 (0) | 2021.07.28 |
어플리케이션 구조, 기능 (0) | 2021.07.28 |
엔티티설계하기 (+엔티티 설계시 주의점) (0) | 2021.07.27 |