Spring boot/JPA

JPA N+1 문제

eunnnn 2023. 4. 17. 08:17

N+1 문제란

N+1 문제란 1번의 쿼리를 날렸을 때 의도하지 않은 N번의 쿼리가 추가적으로 실행되는 것을 의미한다.

이는 1:N 또는 N:1 관계를 가진 엔티티를 조회할 때 발생하는데, JPA Fetch 전략이 EAGER인지 LAZY인지에 따라 두 가지의 경우로 나뉜다.

 

1) Fetch 전략이 EAGER일 때

특정 엔티티를 조회할 때 연관된 모든 엔티티를 같이 로딩하는 것을 즉시 로딩(EAGER Loading)이라고 한다.

 

집사 - 고양이의 관계를 표현할 때 다음과 같은 전제조건을 가지고 있다고 가정하자.   

  • 고양이 집사는 여러 마리의 고양이를 키우고 있다.
  • 고양이는 한 명의 집사에 종속되어 있다.  
     
 
/**
 * @author Incheol Jung
 */
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Owner {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String name;

    @OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
    private Set<Cat> cats = new LinkedHashSet<>();
		
		...
}

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Cat {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String name;

    @ManyToOne
    private Owner owner;

    public Cat(String name) {
        this.name = name;
    }
}

이 상황에서 고양이, 고양이 집사를 각각 10마리 10명 생성하고 고양이 집사를 조회해보자.

@Test
void exampleTest() {
    Set<Cat> cats = new LinkedHashSet<>();
    for(int i = 0; i < 10; i++){
        cats.add(new Cat("cat" + i));
    }
    catRepository.saveAll(cats);

    List<Owner> owners = new ArrayList<>();
    for(int i = 0; i < 10; i++){
        Owner owner = new Owner("owner" + i);
        owner.setCats(cats);
        owners.add(owner);
    }
    ownerRepository.saveAll(owners);

    entityManager.clear();

    System.out.println("-------------------------------------------------------------------------------");
    List<Owner> everyOwners = ownerRepository.findAll();
    assertFalse(everyOwners.isEmpty());
}

고양이를 조회하는 쿼리가 고양이 집사를 조회한 row 만큼 쿼리가 호출한 것을 확인할 수 있다.고양이를 조회하는 쿼리가 고양이 집사를 조회한 row 만큼 쿼리가 호출한 것을 확인할 수 있다.

 

2) Fetch 전략이 LAZY일 때

public class Owner {
    @OneToMany(mappedBy = "owner", fetch = FetchType.LAZY)
    private Set<Cat> cats = new LinkedHashSet<>();
		...
}

FetchType만 변경하고 다음의 테스트 코드를 그대로 실행해보겠다FetchType만 변경하고 다음의 테스트 코드를 그대로 실행해보면 쿼리가 한 번 호출 된 것을 확인할 수 있다.

그러나 Lazy Loading은 연관된 엔티티 데이터가 필요한 경우, 연관된 엔티티 데이터가 필요한 경우, 즉 자신과 연관된 엔티티를 실제로 사용할 때 연관된 엔티티를 조회(SELECT) 하는 방식이기 때문에 고양이 집사가 보유하고 있는 고양이의 이름을 추출해보자.고양이 집사가 보유하고 있는 고양이의 이름을 추출해보면 같은 결과가 나오게 된다.

로그를 확인해보면 결국 동일하게 발생한다는 것을 알 수 있다. FetchType을 변경하는 것은 단지 N+1 발생 시점을 연관관계 데이터를 사용하는 시점으로 미룰지, 아니면 초기 데이터 로드 시점에 가져오느냐에 차이만 있는 것이다.로그를 확인해보면 결국 동일하게 발생한다는 것을 알 수 있다. FetchType을 변경하는 것은 단지 N+1 발생 시점을 연관관계 데이터를 사용하는 시점으로 미룰지, 아니면 초기 데이터 로드 시점에 가져오느냐에 차이만 있는 것이다.


N+1 문제의 해결 방법

1) Fetch Join

 

N+1 자체가 발생하는 이유는 한쪽 테이블만 조회하고 연결된 다른 테이블은 따로 조회하기 때문이다.  미리 두 테이블을 JOIN 하여 한 번에 모든 데이터를 가져올 수 있다면 애초에 N+1 문제가 발생하지 않을 것이다.

그렇게 나온 해결 방법이 두 테이블을 JOIN 하는 쿼리를 직접 작성하는 Fetch Join 이다.

@Query("select DISTINCT o from Owner o join fetch o.pets")
List<Owner> findAllJoinFetch();

@Test
void test() {
        ...
        
    System.out.println("-------------------------------");
    List<Owner> ownerList = ownerRepository.findAllJoinFetch(); 
}

결과를 보면 쿼리가 1번만 발생하고 미리 owner와 pet 데이터를 조인(Inner Join)해서 가져오는 것을 볼 수 있다.

 

Fetch join의 단점은 아래와 같다.

  • 쿼리 한번에 모든 데이터를 가져오기 때문에 JPA가 제공하는 Paging API 사용 불가능(Pageable 사용 불가)
  • 1:N 관계가 두 개 이상인 경우 사용 불가
  • 패치 조인 대상에게 별칭(as) 부여 불가능
  • 번거롭게 쿼리문을 작성해야 함

 

2) Entity Graph

@EntityGraph 의 attributePaths는 같이 조회할 연관 엔티티명을 적으면 된다. ,(콤마)를 통해 여러 개를 줄 수도 있다.
Fetch join과 동일하게 JPQL을 사용해 Query문을 작성하고 필요한 연관관계를 EntityGraph에 설정하면 된다.

@EntityGraph(attributePaths = {"pets"})
@Query("select DISTINCT o from Owner o")
List<Owner> findAllEntityGraph();

    @Test
    void test() {
        ...
        
        System.out.println("-------------------------------");
        List<Owner> ownerList = ownerRepository.findAllEntityGraph();
    }

결과를 보면 쿼리가 1번만 발생하고 미리 owner와 pet 데이터를 조인(outerJoin)해서 가져오는 것을 볼 수 있다.

 

3) FetchMode.SUBSELECT

해당 엔티티를 조회하는 쿼리는 그대로 발생하고 연관관계의 데이터를 조회할 때 서브 쿼리로 함께 조회하는 방법이다.해당 엔티티를 조회하는 쿼리는 그대로 발생하고 연관관계의 데이터를 조회할 때 서브 쿼리로 함께 조회하는 방법이다.

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Owner {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String name;

    @Fetch(FetchMode.SUBSELECT)
    @OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
    private Set<Cat> cats = new LinkedHashSet<>();

}

즉시로딩으로 설정하면 조회시점에, 지연로딩으로 설정하면 지연로딩된 엔티티를 사용하는 시점에 위의 쿼리가 실행된다. 모두 지연로딩으로 설정하고 성능 최적화가 필요한 곳에는 JPQL 페치 조인을 사용하는 것이 추천되는 전략이다즉시로딩으로 설정하면 조회시점에, 지연로딩으로 설정하면 지연로딩된 엔티티를 사용하는 시점에 위의 쿼리가 실행된다. 모두 지연로딩으로 설정하고 성능 최적화가 필요한 곳에는 JPQL 페치 조인을 사용하는 것이 추천되는 전략이다

 

4) BatchSize

하이버네이트가 제공하는 org.hibernate.annotations.BatchSize 어노테이션을 이용하면 연관된 엔티티를 조회할 때 지정된 size 만큼 SQL의 IN절을 사용해서 조회한다.

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Owner {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String name;

    @BatchSize(size=5)
    @OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
    private Set<Cat> cats = new LinkedHashSet<>();
}

즉시로딩이므로 Owner를 조회하는 시점에 Cat를 같이 조회한다.@BatchSize가 있으므로 Cat의 row 갯수만큼 추가 SQL을 날리지 않고, 조회한 Owner 의 id들을 모아서 SQL IN 절을 날린다.

5) Query Builder

Query를 실행하도록 지원해주는 다양한 플러그인이 있다. 대표적으로 Mybatis, QueryDSL, JOOQ, JDBC Template 등이 있을 것이다. 이를 사용하면 로직에 최적화된 쿼리를 구현할 수 있다.Query를 실행하도록 지원해주는 다양한 플러그인이 있다. 대표적으로 Mybatis, QueryDSL, JOOQ, JDBC Template 등이 있을 것이다. 이를 사용하면 로직에 최적화된 쿼리를 구현할 수 있다.

// QueryDSL로 구현한 예제
return from(owner).leftJoin(owner.cats, cat)
                   .fetchJoin()

 

 

출처