Spring/SpringBoot_JPA

API 개발 고급

민철킹 2021. 5. 27. 19:10

1. 지연 로딩과 조회 성능 최적화

 

엔티티를 직접 노출

Order 조회

  • 저장된 모든 order를 찾아 리스트에 담고 엔티티를 그대로 반환

Order

Postman으로 요청을 전송해보자.

먼저 앞서 공부한 것과 같이 엔티티를 그대로 노출하는 것은 굉장히 좋지 않은 행위이다.

또한, 응답을 보면 무한 loop에 빠져 StackOverflow가 발생하는 것을 확인할 수 있다. 이는 Order와 Member가 현재 양방향 관계로 매핑되어 있기 때문에 양방향관계에 의한 순환 참조로 인해 무한 Loop가 발생하기 때문이다.

 

 

이를 어떻게 해결하여야 할까? 현재 주문을 조회하고 있기 때문에 반대쪽 Member, OrderItem, Delivery에서 Order로

오는 것을 @JsonIgnore을 통해 막아야한다. 즉, 양방향 연관관계가 걸리는 부분의 한쪽을  @JsonIgnore로 막아줘야함.


엔티티를 DTO로 변환

N+1 문제가 발생한다!! 

먼저 Order를 1번 조회한다. 그 결과로 orders에 총 2개(N개)의 주문이 조회된다.

Order는 Member이름을 가져오기 위해 영속성 컨텍스트에서 찾고 없기 때문에 db에 sql문을 날려 총 2번(N번) 조회한다.

마찬가지로 Delivery도 2번(N번) 조회한다. 따라서 총 1 + N + N번 쿼리가 실행된다. ==> 성능 issue 발생!!

지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.실제 엔티티이므로 영속성 컨텍스트에서 분리되어 준영속 상태가 되어도 연관된 엔티티를 조회할 수 있다.

 

N + 1 문제 해결 : fetch join 

페치 조인은 JPQL에서 성능 최적화를 위해 제공하는 기능이다.

연관된 엔티티나 컬렉션을 한번에 같이 조회하는 기능인데 join fetch명령어로 사용할 수 있다.

지연로딩으로 설정했어도 페치 조인을 한다면 연관된 엔티티는 프록시가 아닌 실제 엔티티다.

LAZY로 설정을 해도 fetch join이 우선권을 가진다.

 

주문 조회를 요청해보면, 앞서 N + 1 문제가 발생할 때에는 총 5번의 쿼리가 실행되었다.(주문 1번, 멤버 2번, 배송 2번)

하지만, 페치 조인을 사용하고 실행된 쿼리를 살펴보면

join을 통해 단 1번의 쿼리만 실행되는 것을 확인할 수 있다!!!(성능도 굉장히 좋음)

페치 조인으로 order -> member, order -> delivery는 이미 조회된 상태, 즉 연관된 엔티티를 쿼리 시점에서 조회하므로 지연로딩이 발생하지 않는다.


JPA에서 DTO 바로 조회

앞에서는 엔티티를 조회하고 이를 DTO로 변환하여 반환하는 방식을 사용하였다. 여기서 JPA가 DTO를 바로 조회하도록 한다면 조금 더 성능을 최적화할 수 있다.

 

DTO
jpa에서 바로 조회

일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회한다.

new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환하므로 SELECT 절에서 원하는 데이터를 직접 선택하므로 DB 애플리케이션 네트웍 용량 최적화(생각보다 미비)

실행된 쿼리문을 살펴보면 DTO로 변환하여 반환할 때는 먼저 엔티티를 조회한 후 필요한 정보만 DTO에 담아 반환했지만,

JPA가 DTO를 직접 조회하므로 select절에서 원하는 데이터만 조회한다.

 

하지만 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점이 있다. 

 

리포지토리는 순수한 엔티티를 조회하는 용으로 사용하는 것이 좋기 때문에, 만약 이 방법을 사용한다면 별도의 패키지를 만들고 그 안에 쿼리용 Dto, Repoistory를 만들어 사용하는 것이 좋다.

 


정리

엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다.

둘중 상황에 따라서 더 나은 방법을 선택하면 된다.

엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다. 엔티티를 조회하는 것이기 때문에 값을 변경하거나 가공하는 행위가 가능하다.(장단점 존재)

  1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
  2. 필요하면 페치 조인으로 성능을 최적화 한다. 대부분의 성능 이슈가 해결된다.
  3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
  4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.

 

 


2. 컬렉션 조회 최적화

 

컬렉션인 일대다 관계(OneToMany)를 조회하고, 최적화하는 방법을 알아보자

 

엔티티 직접 노출

 

Hibernate5Module을 사용하여 LAZY 로딩일 때 데이터 뿌리는 것을 막아놓은 상태이다.

그 상태에서 컨트롤러를 통해 필요한 데이터를 강제 초기화 시켜주었기 때문에 해당 주문에 관련된 주문 상품과 그 상품에

대한 정보까지도 컬렉션에 담겨있는 것을 볼 수 있다.

계속 강조한 것과 같이 엔티티를 직접 노출하는 것은 좋지 않다.

 


엔티티를 DTO로 변환

언뜻봐서는 문제가 없어보이지만 현재 이 코드는 OrderItem 엔티티 자체를 그대로 외부에 노출하고 있다. DTO 안에도 엔티티가 있어선 안된다. 엔티티에 대한 의존을 완전히 끊어야함. 따라서, OrderItem 또한 DTO로 감싸서 반환해야한다.

 

OrderItemDto 추가

지연 로딩으로 너무 많은 SQL 실행 

order 1번

member address N번(order 조회 수 만큼)

orderItem N번(order 조회 수 만큼)

item N번(orderItem 조회 수 만큼)

 

 


엔티티를 DTO로 변환 - 페치 조인 최적화

Order와 OrderItems를 join하고 있다. 현재 Order에는 2개의 주문이, OrderItems에는 4개의 상품이 존재한다.

이 두 엔티티를 조인하면 결과값은 당연히 4개가 나온다.

postman으로 요청을 전송해봐도 동일한 주문이 2개씩, 총 4개의 주문이 반환되는 것을 확인할 수 있다.

1대다 조인이 있으므로 데이터베이스 row가 증가한다. 그 결과 같은 order 엔티티의 조회 수도 증가하게 된다.

distinct를 사용하자!!

JPA의 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면, 애플리케이션에서 중복을 걸러준다. 이 예에서 order가 컬렉션 페치 조인 때문에 중복 조회 되는 것을 막아준다. DB에서 가져오는 row수는 그대로 4행이다.

JPA가 자체적으로 중복을 걸러주는 것임!!

 

하지만 단점으로, 페이징이 불가능하다. Order를 기준으로 페이징을 하는 것이 아니라, OrderItem을 기준으로 페이징을 하기 때문에 데이터가 뻥튀기된다.

컬렉션 페치 조인을 사용하면 페이징이 불가능하다.
하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 해버린다(매우 위험하다).

컬렉션 페치 조인은 1개만 사용할 수 있다.
컬렉션 둘 이상에 페치 조인을 사용하면 안된다. 데이터가 부정합하게 조회될 수 있다. 

 

 


엔티티를 DTO로 변환 - 페이징과 한계 돌파

 

 

컬렉션을 페치 조인하면 페이징이 불가능하다.

  • 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가
  • 일대다에서 일(1)을 기준으로 페이징하는 것이 목적이지만 다(N)을 기준으로 row가 생성된다.
  • Order를 기준으로 페이징을 하고싶지만, OrderItem이 기준이 되버림

이 경우 Hibernate는 경로 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다.

 

이를 어떻게 해결해야 할까?

  • 먼저 ToOne (OneToOne, ManyToOne) 관계를 모두 페치조인 한다. ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
  • 컬렉션은 지연 로딩으로 조회한다.
  • 지연 로딩 성능 최적화를 위해 'hibernate.default_batch_fetch_size', '@BatchSize'를 적용한다.

hibernate.default_batch_fetch_size : 글로벌 설정

@BatchSize : 개별 최적화(특정 엔티티에)

이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회한다.

Order와 ToOne 관계인 Member와 Delivery는 fetch join을 통해 가져오고 일대다 관계인 OrderItems는 LAZY 초기화를 통해 가져오므로 N + 1문제가 발생하여 쿼리가 1 + N + M(Item까지 N+1 문제 발생)할 것이다.

 

하지만 application.yml의 spring-jpa-properties-hibernate의 default_batch_fetch_size를 설정해주면 설정한만큼

IN을 사용하여 조회한다.

위와 같이 설정하고 다시 요청을 전송하여 쿼리를 살펴보자.

fetch join

먼저 ToOne 관계인 member와 delivery를 fetch join하였기 때문에 1번의 쿼리가 실행된다.

4, 11번 order_id가 IN을 통해 한번에 조회된 것을 확인할 수 있다.

Item역시 4개의 item이 IN을 통해 한번에 조회된 것을 확인할 수 있다.

 

따라서 총 3번의 쿼리만으로 모든 것을 해결하여 결과적으로 N + 1 문제도 해결하고 페이징또한 가능해진 것이다.

 

개별로 설정하려면 @BatchSize 를 적용하면 된다. (컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용)

 

조인보다 DB 데이터 전송량이 최적화 된다. Order와 OrderItem을 페치 조인하면 쿼리 수는 줄지만 Order가 OrderItem만큼 중복해서 조회되므로, 데이터 전송량이 많아진다.

페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다. 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.

ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다. 따라서 ToOne 관계는 페치조인으로 쿼리 수를 줄여 해결하고, 나머지는 default_batch_fetch_size 로 최적화 하자. 

 

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

 


JPA에서 DTO 직접 조회

주문을 담는 DTO, 주문상품을 담는 DTO를 따로 만들었다. orderItems는 order와 1대다 관계이기 때문에 따로 쿼리를 만들어야한다. 먼저 주문을 조회하여 리스트에 담은 후에 주문 상품을 조회하여 추가해주는 식으로 진행된다.

쿼리는 루트 1번, 컬렉션 N번 실행됨 ==> N+1 문제 발생!

 

ToOne(N:1, 1:1) 관계들을 먼저 조회하고, ToMany(1:N - 예제에서는 OrderItems가 여기에 해당함)는 각각 별도로 처리한다. ToOne 관계는 조인해도 데이터 row수가 증가하지 않지만, ToMany 관계는 증가하기 때문에 이렇게 처리한다.

row수가 증가하지 않는 관계는 조인으로 최적화하기 쉬우므로 한번에 조회하고, 그렇지 않은 것은 별도의 메서드로 조회

 

 


JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화

위와 같이 발생한 JPA에서 DTO를 직접 조회할 때 컬렉션에서 발생한 N+1문제를 해결하는 법에 대해 알아보자.

조회한 order에서 orderId만 뽑아 리스트에 따로 담는다. 이를 IN으로 한번에 주문 상품을 조회한다.

조회한 주문상품 리스트를 OrderId를 key로 리스트를 value로 하여 Map으로 변환한다.

for문을 돌면서 값을 세팅해주고 반환해주면 된다. 위에서는 루프를 돌 때마다 쿼리를 날려 N+1 문제가 발생했지만 여기서는  IN을 통해 한번에 모두 가져오므로 N+1문제를 해결할 수 있다.

따라서 쿼리는 총 2번이 실행된다.(Order 조회 1번, OrderItem과 Item을 조인하여 IN으로 모두 조회 1번)

 

ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계인 OrderItem 을 한꺼번에 조회

MAP을 사용해서 매칭 성능 향상(O(1))

 

 


JPA에서 DTO로 직접 조회 - 플랫 데이터 최적화

전부 join하여 데이터를 한번에 다 가져와 이를 가공하여 반환할 것이다. ==> 쿼리는 1번 실행

dto
조회 메서드(쿼리)

API스펙인 OrderQueryDto로 스펙을 맞춰주기 위한 데이터 가공 과정을 거친다.

참고로 OrderQueryDto에서 값을 묶을 기준을 알려줘야함.

쿼리는 한번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5보다 더 느릴 수도 있다.

애플리케이션에서 추가 작업이 크고, 페이징이 불가능한 단점이 있다.

 

 

 

 

각 방법 별로 장단점이 존재한다. 만들고자 하는 시스템의 상황에 따라 어떤 방법을 사용할지를 잘 판단하는 것이 개발자의 몫이다.

 

권장 순서

엔티티 조회 방식으로 우선 접근

  -  페치조인으로 쿼리 수 최적화

  -  컬렉션 최적화

        + 페이징 필요시, default_batch_fetch_size나 @BatchSize로 최적화

        + 페이징 필요없으면, 페치 조인 그냥 사용

 

엔티티 조회 방식으로 해결안되면 DTO 직접 조회 방식 사용

DTO 직접 조회 방식으로 해결안되면 NativeSQL 이나 Spring JdbcTemplate

 

 

엔티티 조회 방식은 페치 조인이나, default_batch_fetch_size, @BatchSize 같이 코드를 거의 수정하지 않고, 옵션만 변경하여 다양한 성능 최적화를 시도할 수 있다. 하지만 DTO를 직접 조회하는 방식은 많은 코드를 변경해야한다.

 

참고 : 엔티티는 캐싱을 하면 안된다.(영속성 컨텍스트가 관리하고 있기 때문에 꼬일 수 있음)

         ==> DTO로 변환 후 캐싱하여야함.

 

성능과 코드(쿼리)복잡도 사이의 줄타기.....

반응형

'Spring > SpringBoot_JPA' 카테고리의 다른 글

OSIV  (0) 2021.06.07
API 개발 기본  (0) 2021.05.26
변경 감지와 병합(merge)  (0) 2021.05.24
테스트 예외처리  (0) 2021.05.20
엔티티 설계 주의점  (0) 2021.05.18