N+1 문제란?
N+1 문제는 ORM(객체-관계 매핑) 기술에서 특정 객체를 조회할 때, 그 객체와 연관된 다른 객체들도 각각 조회되면서 N번의 추가 쿼리가 발생하는 문제를 말한다. N+1 문제는 단일 쿼리로 해결할 수 있는 작업이 불필요하게 많은 쿼리로 분산되면서 성능 저하와 시스템 리소스 낭비를 가져오게 된다.
FetchType이 EAGER(즉시 로딩)인 경우
- findAll() 호출
findAll() 메서드를 호출하면, JPQL의 SELECT t FROM Team t 구문이 생성된다. - JPQL 분석 및 SQL 생성
JPQL을 분석한 후, 데이터베이스에서 실행될 SQL 쿼리 SELECT * FROM Team 가 실행된다. - Team 엔티티 인스턴스 생성
데이터베이스에서 Team 엔티티의 모든 결과를 가져와 Team 엔티티 인스턴스들이 메모리에 생성된다. - 연관된 User 엔티티 즉시 로딩(N+1 문제 발생)
- 모든 Team 엔티티에 대해 즉시 로딩 시도
EAGER 로딩 전략에 따라, 각 Team 엔티티와 연관된 User 엔티티도 즉시 로딩해야 한다. - 각 Team에 대해 추가 SQL 실행
각 Team 엔티티에 대해 User 엔티티를 로딩하기 위해 SELECT * FROM User WHERE team_id = ? 라는 추가적인 SQL 쿼리가 각 Team 엔티티마다 실행된다. - N+1 문제 발생
Team 엔티티가 10개라면, 10개의 추가 SQL 쿼리가 실행되어 N+1 문제가 발생한다.
- 모든 Team 엔티티에 대해 즉시 로딩 시도
FetchType이 LAZY(지연 로딩)인 경우
- findAll() 호출
findAll() 메서드를 호출하면, JPQL의 SELECT t FROM Team t 구문이 생성된다. - JPQL 분석 및 SQL 생성
JPQL을 분석한 후, 데이터베이스에서 실행될 SQL 쿼리 SELECT * FROM Team 가 실행된다. - Team 엔티티 인스턴스 생성
데이터베이스에서 Team 엔티티의 모든 결과를 가져와 Team 엔티티 인스턴스들이 메모리에 생성된다. - 연관된 User 엔티티 지연 로딩(N+1 문제 발생)
- User 엔티티를 실제로 필요로 할 때 로딩 시도
LAZY 로딩 전략에 따라, User 엔티티는 실제로 필요할 때까지 로딩되지 않는다. - 각 Team 엔티티에서 User 엔티티에 접근할 때 추가 SQL 실행
team.getUsers()를 호출하는 순간 User 엔티티를 로딩하기 위해 SELECT * FROM User WHERE team_id = ? 라는 추가적인 SQL 쿼리가 각 Team 엔티티마다 실행된다. - N+1 문제 발생
Team 엔티티가 10개라면, 10개의 추가 SQL 쿼리가 실행되어 N+1 문제가 발생한다.
- User 엔티티를 실제로 필요로 할 때 로딩 시도
> EAGER는 데이터를 조회할 때마다 연관된 엔티티를 자동으로 즉시 로딩하면서 N+1 문제가 발생한다.
> LAZY는 연관된 엔티티에 접근할 때마다 지연 로딩이 발생해 N+1 문제가 발생한다.
Eager Loading은 N+1 문제를 부분적으로 해결할 수 있지만, 엔티티 관계가 복잡해지면 N+1 문제를 해결하지 못하는 경우가 많다. 또한 어떤 연관관계까지 Join 쿼리로 조회될지 예측하기 어려워 필요하지 않은 데이터까지 로딩되어 비효율적일 수 있다.
🍓 Fetch Join 사용
Fetch Join은 JPQL을 사용하여 처음 부모 엔티티를 데이터베이스에서 데이터를 조회할 때부터 Join 쿼리를 발생시켜 연관된 데이터까지 같이 가져오는 방법이다.
public interface TeamRepository extends JpaRepository<Team, Long> {
@Query("select t from Team t join fetch t.users")
List<Team> findAllFetchJoin();
}
이처럼 @Query 어노테이션을 사용하여 별도의 메서드를 만들어주어야 한다.
Fetch Join vs Join
Fetch Join은 ORM에서 사용되는 기법으로, 데이터베이스 스키마를 엔티티로 변환하고 영속성 컨텍스트에 저장한다. Fetch Join을 통해 연관된 엔티티를 한 번의 쿼리로 조회하면, 연관 관계는 영속성 컨텍스트의 1차 캐시에 저장되어 이후에 추가 쿼리 없이 데이터를 가져와 성능이 향상된다.
반면 Join은 SQL 쿼리에서 테이블을 결합하여 데이터를 조회하는 방법이며 ORM의 영속성 컨텍스트나 엔티티와는 관련이 없다.
→ Fetch Join은 ORM을 통해 RDB와의 패러다임을 차이를 줄이고 성능을 개선하는데 유리하지만, Join은 데이터 결합에만 중점을 두어 ORM의 캐시나 연관 엔티티 로딩에 영향을 주지 않는다.
Pagination
컬렉션을 Fetch Join하여 페이징 처리하는 경우 다음과 같은 경고 메세지가 발생한다.
WARN 79170 --- [ Test worker] o.h.h.internal.ast.QueryTranslatorImpl : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
이는 페이징이 데이터베이스 레벨에서 수행되지 않고, 애플리케이션 메모리에서 수행된다는 경고이다.
컬렉션을 포함한 Fetch Join을 사용하면 여러 테이블을 조인하여 결과를 가져올 때, 컬렉션의 모든 데이터를 메모리에 로드한 후, 애플리케이션 레벨에서 필요한 페이지만큼 반환하게 된다.
이렇게 되면 사실상 페이징의 목적이 없어지는 것과 마찬가지이다. 예를 들어 100만건의 데이터 중 10건만 페이징하려고 했더라도 100만건을 모두 메모리에 로드하게 되어 OOM(: Out Of Memory)이 발생할 확률이 매우 높아진다.
따라서 컬렉션 Fetch Join에서 페이징이 필요하다면 데이터베이스에서 직접 페이징을 처리할 수 있는 일반 Join 쿼리를 사용하거나 아래에서 설명할 BatchSize 옵션을 설정하는 것이 바람직하다.
🤔 컬렉션의 모든 데이터를 메모리에 로드하는 이유
레코드 Team User
1 Team1 User1
2 Team1 User2
3 Team1 User3
Fetch Join을 사용하게 되면 중복된 데이터가 발생할 수 있다. 예를 들어 Team 객체를 조회하는 경우, 결과는 N개의 자식 엔티티(ex. User)만큼 중복 생성된다. 이러한 중복을 처리하기 위해 JPQL에서는 Distinct를 사용하여 중복된 엔티티를 제거한다.
마찬가지로 페이징을 처리할때도 중복된 데이터가 있을 수 있으니 JPA는 기존 페이징 처리 방식인 SQL 쿼리 레벨에서 LIMIT와 OFFSET을 사용하여 필요한 페이지만큼 결과를 제한적으로 가져오는 것이 아닌, 모든 데이터를 메모리에 로드한 후 애플리케이션 단에서 페이징을 처리하는 것이다.
📌 JPQL Distinct != SQL Distinct
SQL의 Distinct는 데이터베이스 레벨에서 작동하며 조인으로 인해 생성된 결과 세트에서 각 행을 비교하여 중복된 행을 제거한다. SQL Distinct는 행 단위로 중복을 제거하기 때문에, 만약 Article1이 Opinion1과 Opinion2라는 두 개의 연관된 의견을 가지고 있다면, 두 행은 서로 다른 것으로 간주되어 Distinct를 사용하더라도 두 행이 모두 결과에 포함된다.
반면 JPQL의 Distinct는 애플리케이션 레벨에서 작동하며, 조회한 엔티티(ex. Article)의 중복을 제거한다. 이는 SQL의 Distinct와 달리 메모리에 로드된 엔티티 객체를 기준으로 중복 여부를 판단하기 때문에, 중복된 Article 엔티티를 애플리케이션 매모리 내에서 제거하여 동일한 부모 엔티티가 여러 번 생성되는 문제를 해결할 수 있다.
둘 이상의 Collection Fetch Join 제약
SQL 쿼리에서 다수의 컬렉션을 한번에 조인할 경우, 조인 결과가 예상치 못하게 중복되거나 데이터가 의도하지 않은 방식으로 결합되어 잘못된 결과가 발생할 수 있다. 따라서 ~ToMany 컬렉션 조인이 2개 이상을 Fetch Join 할 경우 너무 많은 값이 메모리로 들어와 MultipleBagFetchException이 발생한다. 이는 2개 이상의 bags, 즉 컬렉션 조인이 두 개 이상일 때 발생하는 Exception이다.
MultipleBagFetchException 해결방법 1: 자료형을 Set으로 변경
@Entity
public class Team {
...
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private Set<User> users = emptySet();
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private Set<Event> events = emptySet();
}
List는 순서를 보장하는 컬렉션이기 때문에 각 컬렉션의 순서를 유지하면서 데이터베이스에서 데이터를 로드해야 한다. 하지만 Set는 순서를 보장하지 않는 컬렉션이기 때문에 JPA가 중복을 허용하지 않으면서도 순서에 제한받지 않고 데이터를 처리할 수 있다.
즉, 자료형을 Set으로 변경하게 되면 여러 개의 컬렉션을 동시에 Fetch Join할 때 순서를 유지할 필요가 없어 효율적으로 중복 데이터를 처리할 수 있고, 이로 인해 MultipleBagFetchException이 발생하지 않게 된다.
MultipleBagFetchException 해결방법 2: BatchSize 설정
Fetch Join을 사용하면서 페이징을 하게되면, Set을 사용하더라도 List를 사용할 때와 마찬가지로 인메모리 로딩 문제가 발생한다.
따라서 이전 Pagination의 해결책과 마찬가지로 배치 사이즈 설정은 MultipleBagFetchException을 해결할 수 있는 방법이기도 하다.
배치 사이즈를 설정하면, JPA는 한 번에 모든 데이터를 Fetch Join으로 가져오지 않고 배치 크기만큼 나눠 가져온다. 따라서 한 번에 처리하는 데이터의 양이 줄어들어 다수의 List 형태의 컬렉션을 한꺼번에 로드할 때 발생하는 MultipleBagFetchException 문제를 피할 수 있다.
🍓 배치 사이즈 설정
배치 사이즈를 설정하면, Hibernate가 연관된 엔티티를 한 번의 쿼리로 가져오도록 할 수 있다.
해당 옵션은 정확히 N+1 문제를 해결하는 것은 아니지만, N+1 문제가 발생하더라도 SELECT * FROM user WHERE team_id = ? 대신 SELECT * FROM user WHERE team_id IN (?, ?, ?) 방식으로 쿼리가 실행되도록 한다. 이를 통해 N+1 문제가 100번 발생할 상황을 1번의 추가 조회로 최적화할 수 있다.
배치 사이즈는 application.yml에 default_batch_fetch_size로 전역 설정하거나 @BatchSize를 통해 특정 엔티티나 컬렉션에 대해 배치 사이즈를 설정하여 개별적으로 성능을 최적화할 수 있다.
@Entity
public class ParentEntity {
@BatchSize(size = 20)
@OneToMany(mappedBy = "parent")
private Set<ChildEntity> children;
}
spring:
jpa:
properties:
default_batch_fetch_size: 100
하지만 일반적인 경우 연관 관계 데이터 사이즈에 최적화된 크기를 알기 어렵기 때문에 데이터 사이즈를 모르는 상태에서 100에서 1000 사이의 값을 설정하는 경우 비효율적일 수 있다.
🍓 Entity Graph 사용
@EntityGraph를 사용하면 기본 Lazy 로딩 설정을 무시하고 연관된 엔티티를 Eager 로딩하도록 설정할 수 있다. 하지만 연관관계가 조금만 복잡해져도 예상치 못한 문제가 발생할 수 있어 로딩할 데이터의 양과 연관관계의 복잡성을 충분히 고려하여 사용해야 한다.
@EntityGraph(attributePaths = {"users", "events"})
@Query("SELECT t FROM Team t WHERE t.id = :id")
Team findTeamByIdWithUsersAndEvents(@Param("id") Long id);
위 메서드는 Team 엔티티를 조회할 때 @EntityGraph를 사용하여 users와 events 엔티티들을 함께 로드한다.
Pagination
EntityGraph도 Fetch Join과 비슷한 문제를 가진다. EntityGraph는 내부적으로 FetchType.EAGER로 동작하며 연관된 엔티티를 한 번의 쿼리로 모두 가져오지만, 1:N 관계의 경우 Fetch Join과 동일하게 부모 엔티티가 중복될 수 있기 때문에 페이징이 예상대로 동작하지 않을 수 있다.
Fetch Join vs @EntityGraph
EntityGraph는 기본적으로 fetchType을 Eager로 설정하여 연관된 엔티티를 함께 로드하며, 이 과정에서 LEFT OUTER JOIN을 수행한다. 이는 연관된 엔티티가 존재하지 않더라도 부모 엔티티를 반환하기 위해 사용된다. 따라서 EntityGraph를 사용하면 연관된 엔티티가 없어도 부모 엔티티가 결과에 포함된다.
반면 Fetch Join은 연관된 엔티티를 로드할 때 기본적으로 INNER JOIN을 수행한다. 따라서 연관된 엔티티가 존재하지 않는 경우 부모 엔티티도 결과에서 제외될 수 있다. 따라서 INNER JOIN은 연관된 모든 엔티티가 존재해야만 부모 엔티티가 결과에 포함된다.
Fetch Join은 N개의 연관된 엔티티를 한 번에 조인할 수 없기 때문에, 전체 결과가 중복되거나 성능 저하를 초래할 수 있다. 이 문제는 DISTINCT를 사용하여 해결하지만, 이는 쿼리 성능에 영향을 미칠 수 있다.
하지만 EntityGraph를 사용할 경우, LEFT OUTER JOIN을 사용하여 연관된 모든 데이터를 가져올 수 있기 때문에 Fetch Join의 단점 중 하나인 1:N 컬렉션 조인 시 최대 한 개의 컬렉션만 조인할 수 있는 제약이 없다. 또한 EntityGraph는 DISTINCT를 필요로 하지 않아 중복된 결과나 성능 저하를 방지할 수 있다.
이러한 차이 때문에, @EntityGraph는 모든 부모 엔티티를 가져오되, 연관된 엔티티가 없을 수도 있는 경우에 유용하고, Fetch Join은 연관된 엔티티가 반드시 존재하는 경우에 효율적이다.
🤔 INNER JOIN, LEFT OUTER JOIN 차이
INNER JOIN은 쉽게 말해 교집합이고, LEFT OUTER JOIN은 왼쪽 테이블을 기준으로 JOIN한 데이터를 보여준다.
다음 글: N+1 문제 발생 지점 찾아 해결하기
📚 참고
'Back-end' 카테고리의 다른 글
Hibernate의 배치 처리 성능 개선 (0) | 2024.10.05 |
---|---|
[Java] GC(: Garbage Collection) 로그로 G1GC 동작과정 확인하기 (1) | 2024.09.11 |
[Spring] 정적 코드 분석을 위해 SonarCloud 사용하기 (0) | 2024.08.21 |
[Spring] SonarQube로 프로젝트 정적 코드 분석 (0) | 2024.08.19 |
[Spring Boot] 로컬 환경에서 Github Actions 테스트하기 (0) | 2024.08.16 |