본문 바로가기

Language/Spring

[JPA] N+1 문제

N+1 문제란?
  • ORM 기술에서 특정 객체를 대상으로 수행한 쿼리가 해당 객체가 가지고 있는 연관관계 엔티티를 조회하게 되면서 N번의 추가적인 쿼리가 발생하는 문제

 N+1 문제 발생 상황 예시
  • Owner Entity : 고양이 집사는 여러 마리의 고양이를 키움
  • Cat Entity : 고양이는 한 명의 집사에게 종속
@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명 생성
  • 고양이 집사는 각각 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());
}
ownerRepository.findAll() 결과
  • 1번의 고양이 집사 조회 + 10번의 고양이 조회 = 총 11번의 쿼리 발생

 

FetchType.EAGER → FetchType.LAZY 변경
  • FetchType이 EAGER이기 때문에 발생한 문제가 아닐까하여 즉시 로딩 → 지연 로딩 변경
public class Owner {
    @OneToMany(mappedBy = "owner", fetch = FetchType.LAZY)
    private Set<Cat> cats = new LinkedHashSet<>();
		...
}
테스트 코드 실행
  • select 쿼리가 한 개만 발생했기 때문에 해결되었다고 착각 할 수 있음
  • 하지만, 즉시 로딩과 지연 로딩 포스팅에서 지연 로딩(Lazy Loading)은 프록시 객체를 생성하여 해당 프록시 객체를 사용하려 할 때 데이터베이스에 접근을 한다고 하였음
  • 즉, Owner가 가지고 있는 Set<Cat> cats에 접근을 할 때 데이터베이스에 접근을 한다는 의미

집사가 키우는 고양이들의 이름 출력
  • 즉시 로딩 Owner를 조회하는 시점에 Cat의 모든 정보를 데이터베이스에 접근하여 로드
  • 지연 로딩은 Onwer를 조회하는 시점에는 Cat의 정보를 가지고 오지 않고 Proxy 객체만 생성해두고 있다가 실제로 Cat을 사용하는 시점데이터베이스에 접근하여 로드
  • 결국 즉시 로딩과 지연 로딩은 어느 시점에 데이터를 가지고 오느냐의 차이일 뿐 N+1 문제를 해결하는 방법이 아님
List<Owner> everyOwners = ownerRepository.findAll();
List<String> catNames = everyOwners.stream()
		.flatMap(it -> it.getCats().stream()
        .map(cat -> cat.getName()))
        .collect(Collectors.toList());
assertFalse(catNames.isEmpty());


N+1 문제의 발생 이유
  • JpaRepository에 정의한 인터페이스 메서드를 실행하면 JPA는 메서드 이름을 분석해서 JPQL을 생성하여 실행
  • JPQLSQL을 추상화한 객체 지향 쿼리 언어로서 특정 SQL에 종속되지 않고 엔티티 객체와 필드 이름을 가지고 쿼리를 발생
  • JPQL은 findAll()이란 메소드를 수행하였을 때 해당 엔티티를 조회하는 select * from Owner 쿼리만 실행을 함
    → JPQL 입장에서는 연관관계 데이터를 무시하고 해당 엔티티 기준으로 쿼리를 조회하기 때문
  • 따라서, 연관된 엔티티 데이터가 필요한 경우 FetchType으로 지정한 시점에 조회를 별도로 호출

해결 방안
Fetch join
  • 최적의 쿼리 한 번의 쿼리로 Owner와 Cat 모두 조회할 수 있는 쿼리
    select * from owner left join cat on cat.owner_id = owner.id
  • 위 쿼리는 JPA에서 제공해주는 것이 아니라 @Query 어노테이션을 이용하여 다음과 같이 JPQL로 작성해야 함
  • 단점
    • 연관관계 설정해놓은 FetchType을 사용할 수 없다는 것
      → Fetch Join을 사용하게 되면 데이터 호출 시점에 모든 연관 관계의 데이터를 가져오기 때문 지연 로딩이 무의미
    • @OneToMany와 같은 컬렉션 연관관계에서는 페이징이 불가능하지만, @ManyToOne 관계에서는 페이징이 가능
@Query("select o from Owner o join fetch o.cats")
List<Owner> findAllJoinFetch();

 

EntityGraph
  • @EntityGraph의 attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면 Lazy가 아닌 Eager 조회
  • Fetch join과 동일하게 JPQL을 사용하여 query 문을 작성하고 필요한 연관관계를 EntityGraph에 설정
  • Fetch join과는 다르게 inner join이 아닌 outer join으로 실행
@EntityGraph(attributePaths = "cats")
@Query("select o from Owner o")
List<Owner> findAllEntityGraph();

 

Fetch Join과 EntityGraph 주의 사항
  • Fetch Join과 EntityGraph는 JPQL을 사용하여 JOIN문을 호출하기 때문에 공통적으로 카테시안 곱(Cartesian Product)이 발생하여 Owner의 수만큼 Cat이 중복 데이터가 존재할 수 있음
  • 따라서 중복된 데이터가 컬렉션에 존재하지 않도록 주의
    컬렉션의 Set을 사용하여 중복 제거 / JPQL에서 distinct 사용

 

FetchMode.SUBSELECT
  • 두 번의 쿼리로 해결하는 방법
    1. 부모 엔티티 조회
    2. 조회된 부모 엔티티의 ID을 데이터베이스의 IN 절에 사용하여 한 번의 쿼리로 자식 엔티티 조회
  • 즉시로딩으로 설정하면 조회 시점, 지연로딩으로 설정하면 지연로딩된 엔티티를 사용하는 시점에 쿼리가 실행
  • 모두 지연로딩으로 설정하고 성능 최적화가 필요한 곳에는 JPQL 패치 조인을 사용하는 것이 추천되는 전략
  • 단점
    • 부모 엔티티의 수가 매우 많아 IN 절의 크기가 커질 경우 데이터베이스 성능이 저하될 수 있음
    • 특정 상황에서는 Fetch Join이 더 효율적
@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<>();

}

 

BatchSize
  • 하이버네이트가 제공하는 @BatchSize 어노테이션을 이용하면 연관된 엔티티를 조회할 때 지정된 Size만큼 SQL의 IN절을 사용하여 조회
  • 부모 엔티티를 조회한 뒤 @BatchSize에서 지정한 크기(Size)만큼만 IN 절에 들어감
    Ex) 부모 엔티티 8개  → IN (1, 2, 3, 4, 5) → IN (6, 7, 8)
  • hibernate.default_batch_fetch_size 설정을 통해 글로벌하게 적용 가능
  • 단점
    • 부모 엔티티의 수가 많을 경우 여러 번 배치 쿼리가 실행되므로 Fetch Join보다 성능이 떨어질 수 있음
    • 적절한 배치 크기를 설정하지 않으면 쿼리 최적화 효과가 제한될 수 있음

 

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

 

QueryBuilder 사용
  • Query를 실행하도록 지원해주는 다양한 플러그인이 존재
    Ex) Mybatis, QueryDSL, JOOQ, JDBC Template 등
  • 이를 사용하면 로직에 최적화된 쿼리를 구현 가능
// QueryDSL로 구현한 예제
return from(owner).leftJoin(owner.cats, cat)
                   .fetchJoin()

참고 자료

https://incheol-jung.gitbook.io/docs/q-and-a/spring/n+1#fetch-join

 

N+1 문제 | Incheol's TECH BLOG

JPA N+1 문제에 대해 알아보자

incheol-jung.gitbook.io