N+1

Leo·2023년 8월 6일
0

N+1 이란?


하나의 쿼리가 수행되고 수행된 결과에 대한 연관관계 데이터를 얻기 위해 쿼리를 N번 추가 수행하는 문제 입니다.

일대다


한명의 User가 여러개의 Article을 가질 수 있는 일대다 구조를 보겠습니다.

Eager

User의 article list, Article의 user 모두 Fetch Type을 Eager로 설정한다면 어떤 문제가 발상하는지 살펴보도록 하겠습니다.

userRepository.findById(1L) 으로 조회했을 때, 내부적으로 inner join을 통해 조회된 User가 가지고 있는 Article 까지 모두 한번의 쿼리로 조회 됩니다.

하지만 다른 쿼리 메서드(findBy~~) 를 사용하거나 직접 jpql을 작성한 경우, 조회한 user 수 만큼 Article을 조회하는 쿼리가 추가적 발생하여 N+1 문제가 발생합니다.

이유는 즉시로딩의 경우 jpql로 전달되는 과정에서 Fetch Type을 참고하지 않고 쿼리를 생성하고, 이후 Eager 감지로 인한 N 쿼리가 추가적으로 발생하기 때문에 즉시로딩 사용은 자제해야 합니다.

Lazy

연관 대상의 Entity에 대해서 프록시 객체를 이용해 실제로 조회가 일어날 때, 실제 Entity를 조회하는 방식입니다.

실제 객체를 실제로 사용한다면 N+1 문제가 똑같이 발생하게 됩니다.

즉시로딩, 지연로딩 모두 연관관계가 걸려있어도 join을 통해 연관된 객체를 한번에 조회하지 않기 때문에 N+1 문제가 발생합니다.

해결책

Fetch Join

즉시로딩은 개발자가 직접 커스텀할 수 없기 때문에 지연로딩 과정에서 join을 통해 객체를 가져올 수 있게 하는 방법입니다.

@Query("select distinct u from User u left join u.articles")
List<User> findAllJPQL();

fetch join 을 사용하지 않고 일반 조인을 통하면 문제가 해결 될까요?

실제로 실행된 쿼리를 확인하면 N+1 문제가 똑같이 발생하게 됩니다.

이유는 지연로딩이 걸려있어 join을 했어도 프록시를 가져왔기 때문에 실제로 조회하는 로직이 실행된다면 추가적인 N 쿼리가 발생하게 되기 때문입니다.

@Query("select distinct u from User u left join fetch u.articles")
List<User> findAllJPQLFetch();

따라서 지연로딩에서 프록시 객체를 가져오지 않고 즉시로딩처럼 객체를 바로 가져올 수 있는 fetch join을 사용해야 합니다.

distinct로 중복을 제거하는 이유는 join을 통해 collection을 가져오는 경우, 카티시안곱을 한 결과가 나오기 때문에 기대한 결과를 얻기 위해서는 distinct 키워드를 사용해야 합니다.

이러한 데이터 증폭 문제로 두 개 이상의 컬렉션을 fetch join할 수 없습니다.
페이징 처리가 필요한 경우, 컬렉션을 fetch join하는 경우에도 해당 결과에 대한 페이징을 메모리에서 실행해야 하기 때문에 문제가 발생합니다.

해당 문제는 따로 기술하겠습니다.

@EntityGraph

@EntityGraph(attributePaths = {"articles"}, type = EntityGraphType.FETCH)
@Query("select distinct u from User u left join u.articles")
List<User> findAllEntityGraph();

Batch Size

JPA에서 지연로딩을 할 때, 한번에 최대 Batch Size 만큼 엔티티를 wehre절에서 in 절로 가져옵니다.

@OneToMany 를 사용하는 Collections에 붙여 사용하거나 yml 에 정의해서 사용할 수 있습니다.

@BatchSize(size = 100)
spring:
    properties:
      hibernate:
        default_batch_fetch_size: 100

Batch Size가 너무 과도하면 한번에 너무 많은 엔티티가 메모리에 로딩되기 때문에 100 ~ 1000개 수준이 적당합니다.

일대일


양방향 일대일 관계에서 연관관계의 주인이 아닌 쪽에서 조회할때 N+1 문제가 발생합니다.

지연로딩을 사용 하더라도 사용하기 전에 바로 N개의 쿼리가 발생하게 되는데 이유는 다음과 같습니다.

The reason for this is that owner entity MUST know whether association property should contain a proxy object or NULL and it can't determine that by looking at its base table's columns due to one-to-one normally being mapped via shared PK, so it has to be eagerly fetched anyway making proxy pointless.

연관관계 주인이 아닌 테이블에서 프록시로 만들 객체가 null 인지 아닌지 알 수 없기 때문에 조회하는 N개의 쿼리가 발생되게 되는 것입니다.

프록시 또한 실제 객체를 상속받아 사용하기 때문에 null을 상속받아 사용할 수 없기 때문입니다.

따라서 일대일 관계에서 연관관계 주인이 아닌 쪽에서 조회 + lazy를 적용하려면 optional = false를 사용해 해당 객체가 null이 될 수 없는 옵션을 사용해야 합니다.

일대다 에서는?

일대다 관계에서는 연관관계의 주인이 아닌 쪽에서 조회만으로 N+1 문제가 똑같이 발생하지 않습니다.

이유는 연관관계의 주인이 아닌 One은 List 형태로 참조 하고 있기 때문에 null 이 아닌 size 자체가 0 인 객체를 프록시로 만들 수 있기 때문입니다.

해결책

Fetch Join? Batch size?

N+1 문제를 해결하는 방법중에 Batch Size를 이용할 수 있지만 일대일 관계에서는 참조되는 객체 자체가 null 임을 판별하는 조회성 쿼리 때문에 Batch Size로는 해결이 불가능 합니다.

따라서 fetch join을 사용해서 N+1 문제를 해결해야 합니다.

출처

0개의 댓글