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을 생성하여 실행
- JPQL은 SQL을 추상화한 객체 지향 쿼리 언어로서 특정 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 관계에서는 페이징이 가능
- 연관관계 설정해놓은 FetchType을 사용할 수 없다는 것
@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
- 두 번의 쿼리로 해결하는 방법
- 부모 엔티티 조회
- 조회된 부모 엔티티의 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
'Language > Spring' 카테고리의 다른 글
[STS] JSP 사용법 (0) | 2024.12.10 |
---|---|
[SQL Mapper] Mybatis 개념 및 구조 (2) | 2024.12.02 |
[JPA] 엔티티 관계의 로딩 전략 - 즉시 로딩 / 지연 로딩 (1) | 2024.11.18 |
[Spring] Controller와 Rest Controller 차이 (0) | 2024.11.17 |
[Spring] DispatcherServlet의 개념과 흐름 (0) | 2024.11.17 |