Backend/JPA

엔티티 조회 성능 최적화

olsohee 2024. 2. 2. 13:44

xToOne 관계의 엔티티 조회 성능 최적화

Order를 조회할 때 연관된 Member와 Delivery를 함께 조회한다고 가정하자. 이때 Order와 Member는 다대일 관계이고, Order와 Delivery는 일대일 관계이다.

지연 로딩 사용으로 인한 N+1 문제

지연 로딩으로 설정해두면, Order를 조회할 때 Member와 Delivery 테이블에는 조회 쿼리가 나가지 않고, 연관된 엔티티인 Member와 Delivery는 프록시 객체가 된다. 그리고 member.getUsername()과 같이 엔티티에 접근할 때, Member 테이블에 조회 쿼리가 나가게 되고, 해당 프록시 객체가 초기화된다. 

 

따라서 만약 주문 정보(Order)를 조회하면서 회원 정보(Member)와 배송 정보(Delivery)를 함께 응답해야 한다면, 이때 Order 테이블 뿐만 아니라 Member와 Delivery 테이블에도 조회 쿼리가 나가게 된다. 이를 N+1 문제라고 한다. 

  • Order 테이블 조회 결과, Order 엔티티가 2개 조회되었다면 (쿼리 1번)
    • Member 테이블에 조회 쿼리가 2번 실행되고 (쿼리 2번)
    • Delivery 테이블에도 조회 쿼리가 2번 실행된다. (쿼리 2번)
  • 참고로, 만약 2개의 Order의 회원이 같은 회원이라면 Member 테이블에 조회 쿼리는 1번만 실행된다. 그 이유는 다음과 같다.
    • 최초로 회원을 조회하고, 조회된 회원 엔티티는 JPA의 영속성 컨텍스트에 보관된다.
    • 다음 회원을 조회할 때 해당 엔티티가 영속성 컨텍스트에 있는지 확인한다.
    • 영속성 컨텍스트에 있기 때문에 DB를 조회하지 않는다.
  • 따라서 위 예제에서 최악의 경우에 1 + 2 + 2번의 쿼리가 실행된다.

페치 조인

이러한 N+1 문제를 해결하기 위해서는 페치 조인을 사용하면 된다. 페치 조인을 사용하면 Order를 조회할 때 Order 테이블과 Member, Delivery 테이블을 조인해서 하나의 쿼리로 연관된 엔티티를 모두 조회해온다. 즉 조회 결과인 Memer와 Delivery 엔티티는 프록시 객체가 아니라 실제 객체인 것이다. 

 

참고로 지연 로딩으로 설정하더라도 페치 조인이 적용되어 있으면 지연 로딩이 무시되고 페치 조인이 우선한다. 

 

결론적으로 조회 성능 최적화를 위한 설정은 다음과 같다.

  • 즉시 로딩으로 설정하면, 연관관계가 필요없는 경우일지라도 항상 연관된 테이블에도 추가적인 sql이 실행되는 문제가 발생한다. 따라서 항상 지연 로딩을 기본으로 하자.
  • 만약 연관된 엔티티를 자주 함께 조회하는 경우라면, 이는 지연 로딩으로 설정되어 있기 때문에 자주 N+1 문제가 발생하게 되고, 성능 저하의 원인이 된다. 따라서 이런 경우에만 특정 엔티티 조회시 연관된 엔티티도 sql 한 번에 함께 조회하도록 페치 조인을 사용하자.

JPA에서 DTO로 바로 조회

우리는 주로 DB에서 엔티티를 조회하고 엔티티를 DTO로 변환한다. 그런데 문제는 DTO로 변환할 필요가 없는 필드까지 DB에서 모두 조회해온다는 점이다. 따라서 DTO로 바로 조회해오는 방법이 있다.

@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {

    private final EntityManager em;

    public List<OrderSimpleQueryDto> findOrderDtos() {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
                        " from Order o" +
                        " join o.member m" +
                        " join o.delivery d", OrderSimpleQueryDto.class)
                .getResultList();
    }
}
@Data
public class OrderSimpleQueryDto {

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

    public OrderSimpleQueryDto(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에 맞춰서 데이터를 조회해오려면 new 명령어를 사용해야 하고, DTO의 전체 패키지 명을 적어주어야 한다.
  • 해당 DTO 클래스에 new 명령어를 통해 활용될 생성자가 정의되어 있어야 한다.
  • DTO로 바로 조회하는 경우, 리포지토리의 조회 메서드가 특정 api 스펙에 의존한다는 문제가 있다. 이는 재사용성이 거의 없고 해당 api에서만 사용 가능하다. 그런데 리포지토리는 가급적 엔티티만을 조회하고 재사용성이 뛰어나야 한다. 따라서 DTO로 바로 조회하는 경우, 해당 코드를 별개의 리포지토리로 분리하여, 엔티티만 조회하는 순수하고 재사용성이 높은 리포지토리(OrderRepository)와 특정 api에 의존하는 리포지토리(OrderSimpleQueryRepository)를 분리하는 것이 좋다.

xToMany 관계의 엔티티 조회 성능 최적화

Order를 조회할 때 연관된 Member와 Delivery 뿐만 아니라 OrderItem과 Item을 함께 조회한다고 가정하자. 이때 Order와 OrderItem은 일대다 관계이고, OrderItem과 Item은 다대일 관계이다.

지연 로딩 사용으로 인한 N+1 문제

지연 로딩으로 설정되어 있기 때문에 Order를 조회할 때 N+1 문제가 발생한다.

  • Order 테이블 조회 결과, Order 엔티티가 2개 조회되었다면 (쿼리 1번) 
    • Member 테이블에 조회 쿼리가 2번 실행되고, Delivery 테이블에도 조회 쿼리가 2번 실행된다. (쿼리 2번 + 2번)
    • 그리고 OrderItem 테이블에도 조회 쿼리가 2번 실행되고, Item 테이블에도 조회 쿼리가 OrderItem 조회 수만큼 실행된다. (쿼리 2번 + OrderItem 조회 수만큼)

페치 조인

따라서 N+1 문제를 해결하기 위해 페치 조인을 사용하면 된다. 그런데 일대다 관계의 엔티티를 조회할 때, 즉 컬렉션을 조회할 때는 주의할 점이 있다.

컬렉션 페치 조인으로 인한 데이터 수 증가 문제 발생

예를 들어 Order 조회 결과가 2개이고, 첫 번째 Order와 두 번째 Order 둘 다 연관된 OrderItem이 2개씩 있다고 가정하자. 페치 조인은 join을 하면서 select절에서 order 뿐만 아니라 페치 조인한 모든 데이터를 함께 조회한다. 즉 JPA를 통해 페치 조인을 하면 DB에서 조인되는데, 이때 2개의 order가 4개로 데이터 수가 증가하게 된다. 그리고 이 4개의 order가 어플리케이션으로 리스트 형태로 반환된다(리스트에는 order1, order1, order2, order2가 들어있다). 따라서 데이터가 중복된다.

 

DB에서 직접 조인해서 조회된 결과를 확인해보면, 컬렉션 페치 조인으로 인해 2개의 Order 데이터가 4개로 증가한 것을 알 수 있다.

select * from orders;
select * from order_item;
select * from orders o 
	join order_item oi 
	on o.order_id = oi.order_id;

 

따라서 쿼리 결과가 담긴 리스트에는 하나의 order 엔티티 참조 값이 두개 중복으로 들어있고(order1의 참조 값, order1의 참조 값, order2의 참조 값, order2의 참조 값), 이 하나의 order 엔티티는 두개의 orderItem을 참조한다. 다음 사진을 보고 구조를 참고하자. (다음 사진은 team -> member 일대다 관계인 경우이다.)

즉 쿼리 결과가 담긴 리스트에는 각각의 order가 중복으로 두 개씩 들어있고(orderItem 수만큼 뻥튀기 됨), 이 각각의 order는 동일한 orderItems를 가리키고 있다.

distinct로 문제 해결

이런 데이터 중복 문제를 해결하기 위해 페치 조인시에 distinct를 넣어주면 된다.

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

 

distinct를 넣으면 다음과 같은 일이 발생한다.

  • SQL에 distinct를 추가한다.
    • SQL의 distinct는 중복되는 row의 중복을 제거해준다. 이때, 모든 칼럼이 중복되어야 중복으로 인정한다.
    • 예제의 경우, DB에서 각 row의 모든 컬럼이 완전히 중복되지는 않는다. 따라서 SQL에 distinct가 적용되었지만, 중복이 제거되지는 않는다.
  • 엔티티가 조회되면 애플리케이션에서 중복을 걸러준다.
    • 예제의 경우, distinct 적용 전에는 리스트에 order1의 참조 값이 2개, order2의 참조 값이 2개가 담긴다.
    • 그러나 distinct를 적용하면 order의 id 값이 같으면 같다고 간주하여 중복을 제거한다.
    • 따라서 리스트에 order1의 참조 값 1개, order2의 참조 값 1개만 담긴다.

페이징 처리

컬렉션 페치 조인시 페이징 처리 불가

앞서 우리는 N+1 문제를 해결하게 위해 페치 조인을 사용하면 되고, 이때 컬렉션 페치 조인시에는 데이터가 중복되는 문제가 발생하기 때문에 distinct를 사용하면 된다는 것을 배웠다. 그런데 컬렉션 페치 조인을 사용하면 페이징 API를 사용할 수 없다. 

지연 로딩 + 배치 사이즈로 문제 해결

그렇다면 컬렉션 엔티티를 페이징 처리하여 조회하려면 어떻게 해야 할까?

  1. 먼저 ToOne(OneToOne, ManyToOne) 관계는 모두 페치 조인한다. (ToOne 관계는 페치 조인을 하더라도 row 수를 증가시키기 않기 때문에 페이징 처리가 정상 동작한다.)
  2. ToMany(OneToMany, ManyToMany) 관계 같은 컬렉션은 지연 로딩으로 조회한다.
  3. 컬렉션을 지연 로딩으로 조회하면 N+1 문제가 발생한다. 따라서 지연 로딩 성능 최적화를 위해 배치 사이즈를 설정한다. 다음 옵션을 사용하면 컬렉션이나 프록시 객체를 설정한 size만큼 한 번에 in 쿼리로 조회한다.
    • jpa.properties.hibernate.default_batch_fetch_size: 글로벌 설정
    • @BatchSize: 개별 최적화
      • 참고로 order -> orderItem과 같이 컬렉션 조회의 경우에는 Order 클래스의 List orderItems 필드 위에 어노테이션을 붙이고
      • orderItem -> item과 같이 하나의 엔티티 조회의 경우에는 orderItem 클래스가 아닌 item 클래스의 클래스명 위에 어노테이션을 붙여주어야 한다.

이렇게 컬렉션 조회를 지연 로딩으로 설정하고 배치 사이즈를 설정하면 다음과 같은 장점이 있다.

  • 지연 로딩만 설정할 때 보다 쿼리수가 최적화된다.
    • 배치 사이즈를 설정하지 않고, 컬렉션 조회를 지연 로딩으로 설정하는 경우 (1+N)
      • order 조회 1번
      • orderItem 조회 N번(order 조회 수 만큼)
      • item 조회 N번(orderItem 조회 수 만큼)
    • 배치 사이즈를 설정하고, 컬렉션 조회를 지연 로딩으로 설정하는 경우(1+1)
      • order 조회 1번
      • orderItem 조회 1번(배치 사이즈로 한 번에 조회 가능한 범위 내에서)
      • item 조회 1번(배치 사이즈로 한 번에 조회 가능한 범위 내에서)
  • 페치 조인을 사용하지 않기 때문에 데이터 row 수가 증가하는 문제가 발생하지 않는다.
    • 따라서 페이징 처리가 가능하고, DB 데이터 전송량이 최적화된다.
      • xToMany 페치 조인은 distinct를 사용해서 중복된 데이터를 제거해준다고 하더라고, DB에서 증가된 데이터 row를 모두 조회해오고 메모리 내에서 중복이 제거된다. 따라서 DB에서 증가된 데이터 row를 모두 조회해오기 때문에 DB 데이터 전송량이 크다.
      • 그러나 페치 조인을 사용하지 않고 배치 사이즈를 설정하면 페치 조인을 사용할 때보다 쿼리 수는 증가하지만, 데이터 row 수가 증가하지 않기 때문에 DB 데이터 전송량은 최적화된다.
[적절한 default_batch_fetch_size의 크기]
default_batch_fetch_size의 크기는 100~1000 사이를 선택하는 것이 권장된다. 이 전략은 sql의 in절을 사용하는데, 데이터베이스에 따라 in절 파라미터를 1000으로 제한하기도 한다.

1000으로 잡으면 한 번에 1000개를 DB에서 애플리케이션으로 불러오기 때문에 DB에 순간 부하가 증가할 수 있다. 반면 100으로 잡으면 1000에 비해 짧게 끊어서 가져오기 때문에 부하 측면에선 안전하지만 쿼리 실행 수가 늘어나며 시간이 더 오래 걸린다.

그러나 결국 100이든 1000이든 애플리케이션은 전체 데이터를 로딩하기 때문에 메모리 사용량은 같다.

결론적으로, 쿼리 수를 줄이고 실행시간을 줄이기 위해 100보다는 1000으로 하는 것이 성능상 가장 좋지만, DB와 애플리케이션이 순간 부하를 얼마나 견딜 수 있는가를 보고 결정해야 한다.

조회 성능 최적화 정리

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

  • 엔티티를 조회하는 경우
    • 장점: 조회 코드의 재사용성이 높다.
    • 단점: DTO 변환시 사용되지 않는 데이터까지 select 절을 통해 조회한다.
  • DTO로 바로 조회하는 경우
    • 장점: select 절에서 DTO 변환에 필요한 데이터만 조회하기 때문에 네트워크 용량이 최적화된다. (단 이는 생각보다 미비하다.)
    • 단점: 조회 코드가 특정 DTO에 의존한다. 즉 리포지토리가 api에 의존하게 되어 리포지토리 재사용성이 떨어진다.

권장되는 컬렉션 조회 최적화 순서는 다음과 같다.

  1. 우선 엔티티 조회 방식을 사용한다. 
    • xToOne 관계: 페치 조인이 페이징에 영향을 주지 않기 때문에(데이터 row 수 증가X)  페치 조인을 사용하여 쿼리 수를 최적화한다. (N+1 문제 해결)
    • xToMany 관계: 페이징이 필요하면 지연 로딩 + 배치 사이즈 설정으로 최적화하고, 페이징이 필요 없으면 지연 로딩 + 배치 사이즈 설정 또는 페치 조인을 사용한다.
      • 지연 로딩 + 배치 사이즈를 설정함으로써, 지연 로딩만 설정할 때보다 쿼리 수가 최적화 된다. 그리고 페치 조인을 사용하지 않기 때문에 데이터 row 수가 증가하는 문제가 발생하지 않는다. 따라서 DB 데이터 전송량이 최적화되고, 페이징 처리가 가능하다.
  2. 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식을 사용한다. (xToMany 관계에서 DTO로 바로 조회하는 방법은 이전에 적은 다음 글을 참고하자: https://velog.io/@dlthgml0108/JPA-%EC%BB%AC%EB%A0%89%EC%85%98-%EC%A1%B0%ED%9A%8C-%EC%B5%9C%EC%A0%81%ED%99%94#4-jpa%EC%97%90%EC%84%9C-dto%EB%A1%9C-%EB%B0%94%EB%A1%9C-%EC%A1%B0%ED%9A%8C%EC%8B%9C-n1-%EB%AC%B8%EC%A0%9C)
  3. DTO 조회 방식으로 해결이 안되면 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 작성한다.

Reference

  • 인프런, 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화