Published on
👁️

JPA N+1 문제 해결에 따른 성능 변화 분석 - 1. JPA N+1 문제 개요

Authors
  • avatar
    Name
    River
    Twitter

목차 페이지로 이동

데이터 규모에 따른 JPA N+1 문제 진단과 해결 전략 비교 (Fetch Join, Entity Graph, Batch Fetching)

1. JPA N+1 문제 개요
1.1. N+1 문제란?

(1)정의

  1. 1번의 쿼리를 날렸을 때 내부적으로 의도하지 않은 N번의 쿼리가 추가적으로 실행되는 문제. 즉, 설계하지 않는 추가적인 쿼리가 N개 발생하는 것이다.

  2. 루프 내에서 반복적으로 실행되는 쿼리 또한 N+1문제로 볼 수 있다.

(2)발생 원인

  • JPA는 객체와 테이블을 매핑하여 JPQL이나 Repository 메서드 호출을 통해 SQL을 자동으로 생성한다.

    또한 연관 엔티티에 대하여 자동으로 추가적인 쿼리문을 생성하기도 한다.

  • N+1 문제는 JPA가 엔티티를 조회할 때 Join이 아닌 별도 추가 쿼리로 연관 엔티티를 가져오기 때문에 발생한다.

(3)JPA Hibernate에서 N+1 문제가 발생하는 경우

1. JPA Fetch 전략이 EAGER인 경우 (즉시 로딩)

EAGER 로딩 시 N+1 문제
그림 1-1: 엔티티 관계
  • EAGER의 경우엔 Hibernate가 최적화를 수행하여 Join 쿼리로 한 번에 조회하는 경우가 있다.
  • findById() 쿼리 메서드
    • Hibernate가 최적화를 수행 EAGER 연관 엔티티를 Join으로 함께 가져온다.

      User user = userRepository.findById(1L).orElseThrow();
      


  • 다른 쿼리 메서드 (내부적으로 JPQL 생성)
    • JPQL 기반으로 쿼리 실행 후, EAGER 연관 엔티티에 대하여 추가적인 쿼리가 발생한다.

      N+1 문제 발생

      List<User> users = userRepository.findAll();
      

  • (중요) EAGER 연관 엔티티를 어떻게 가져오는지는 Hibernate의 내부 전략에 따라 달라진다.

2. JPA Fetch 전략이 LAZY인 경우 (지연 로딩)

LAZY 로딩 시 N+1 문제
그림 1-2: 엔티티 관계
List<Member> members = memberRepository.findAll(); // 1번

...

for (Member m : members) {
    System.out.println(m.getTeam().getName()); // N번
}
  • 우선 상위 엔티티에 대하여 1번의 조회 발생 (1번)
  • Fetch가 LAZY인 엔티티에 대해선 즉시 추가 조회하지 않는다. 우선 프록시 객체를 대신 넣어두고 연관 엔티티를 사용할 때 그 엔티티에 대하여 조회 발생 (N번)

3. 루프 내에서 반복적으로 실행되는 쿼리

루프 내 반복 쿼리
그림 1-3: 엔티티 관계
List<User> users = userRepository.findAll();
for (User user : users) {
    long postCount = postRepository.countByUser(user); // N번
}

SELECT COUNT(*) FROM post WHERE user_id = 1;
SELECT COUNT(*) FROM post WHERE user_id = 2;
SELECT COUNT(*) FROM post WHERE user_id = 3;
...
  • N명의 User에 대해서 where 조건만 다르게 반복되는 쿼리
1.2. N+1 문제의 영향

쿼리 수 증가 → DB 부하 증가

  • 연관 데이터가 많을 수록 실행되는 쿼리 수가 기하급수적으로 증가
    • 1000개의 연관 엔티티 ⇒ 1001번의 쿼리 발생
    • 수백만 건 이상의 데이터에서 큰 문제로 확대될 수 있다.

  • 이로 인해 DB Connection을 장시간 점유하게 되어, Connection Pool 고갈 가능
    • 예시로 Connection Pool이 max = 3인 경우, 동시에 최대 3명만 DB에 접근 가능하다.
    • 누군가 N+1 문제로 커넥션을 오래 점유한다면 나머지 요청은 Connection Pool에서 대기 상태가 된다.
    • 즉, 전체 시스템 처리량이 저하된다.

응답 시간 증가 → 병목 발생

  • 네트워크 Round Trip 증가
    • 쿼리를 많이 날릴수록 Application과 Database 간의 요청/응답 횟수가 많아진다.

      (Round Trip : 요청 후 응답까지 왕복 통신)

    • 그에 따라, 총 소요 시간이 증가한다. 또 Application과 Database가 다른 서버일 경우 네트워크 트래픽 증가


  • API 응답 시간 증가
    • 특히 모든 사용자가 거치는 기능이라면, 전체 사용자 응답이 지연되어 시스템 전체 병목 구간으로 작용할 수 있다.
    • 응답 시간 증가 ⇒ 화면 진입 시 느려짐 ⇒ 실시간 반응 저하

인프라 비용 증가

  • 처리 시간이 늘어나면서 CPU, Memory, Network 자원의 사용량이 증가한다.

    ⇒ 서버를 추가해야 하는 등 비용 증가

1.3. N+1 문제 해결 전략 비교

(1)Fetch Join

@Query("select distinct u from User u left join fetch u.posts")
List<User> findAllWithPosts();
  • JPQL에서 fetch 키워드를 사용하여 연관 엔티티를 한 번의 쿼리로 함께 조회
  • N+1 문제의 원인인 엔티티를 조회 시 연관된 엔티티를 따로 조회하는 것을 방지한다.
  • Inner Join(join fetch)이 기본 (직접 Outer Join(left join fetch) 설정)
  • 중복 제거가 필요한 경우 distinct 키워드 사용 필요
  • 단점
    • 컬렉션(OneToMany) Fetch Join인 경우 페이징을 적용할 수 없다.

      ⇒ ToOne 관계에서만 Fetch Join과 페이징을 함께 적용 가능

(2)EntityGraph

  • JPA 기능으로 연관 엔티티를 로딩하는 전략 중 하나

  • JPQL인 경우

    @EntityGraph(attributePaths = {"posts"}, type = EntityGraphType.FETCH)
    @Query("select u from User u")
    List<User> findAllWithPosts();
    
  • 쿼리 메서드인 경우

    @EntityGraph(attributePaths = {"applicant", "applicant.shelter", "animalCase"})
    List<Protection> findAll();
    
  • Outer Join이 기본

  • JPQL 없이 사용 가능하고, 내부적으로 Fetch Join과 동일한 방식이다.

  • 단점

    • Fetch Join과 마찬가지로 컬렉션(OneToMany)의 경우 페이징을 적용할 수 없다.

(3)Batch Fetching

default_batch_fetch_size

# application.yml

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100
  • 지연 로딩 시 발생하는 N개의 쿼리를 하나의 IN 쿼리로 묶는 전역 설정이다.

    (application.yml에 설정)

  • fetch join 또는 EntityGraph를 먼저 사용하면 Batch Fetching은 아예 실행되지 않는다.

@BatchSize

@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Article> articles = new ArrayList<>();
  • 엔티티의 필드에 직접 설정해야 한다.
  • 특정 연관 엔티티에 대해 배치 크기를 지정하는 것
  • 지연 로딩된 연관 엔티티를 일괄 조회한다.
  • 페이징 처리와 함께 사용 가능

(4)Subselect Fetching

@Fetch(FetchMode.SUBSELECT)

@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@Fetch(FetchMode.SUBSELECT)
private List<Post> posts = new ArrayList<>();
  • @BatchSize와 동일하게 엔티티의 필드에 직접 설정해야 하고 지연로딩된 연관 엔티티를 조회한다.

  • 하지만 내부 적용 방식이 다르다.

    메인 엔티티를 조회하는 쿼리의 결과를 연관 엔티티 조회 쿼리의 서브 쿼리로 사용해 한 번에 조회

    -- 1.
    SELECT * FROM user WHERE name LIKE '%리버%';
    
    -- 2. posts에 접근할 때 SUBSELECT를 사용한 쿼리 발생
    SELECT * FROM post
    WHERE user_id IN (
        SELECT id FROM user WHERE name LIKE '%리버%'
    );
    
  • @BatchSize(size = 무한대)라는 표현처럼 모든 연관 데이터를 다 가져오는 것으로 메모리 부담이 있을 수 있다.

  • 서브 쿼리를 사용하여 쿼리가 무거워진다.

(5)IN 기반 수동 최적화

  • 루프 내 반복되는 조회를 직접 JPQL을 작성하여 IN 쿼리로 묶어 최적화

  • Batch Fetching 처럼 자동 해결이 아닌 직접 명시적으로 해결하는 것

  • JOIN FETCH를 쓰기 힘든 경우나, 다건 조회가 핵심인 곳에서 자주 사용된다.

  • WHERE id IN (:ids) 쿼리 작성

    // N+1 문제 발생
    List<AnimalCase> list = animalCaseRepository.findAll();  // 1번 쿼리
    for (AnimalCase ac : list) {
        Protection p = protectionRepository.findByAnimalCaseId(ac.getId());  // N번 쿼리
    }
    
    // 최적화된 코드
    List<AnimalCase> animalCases = animalCaseRepository.findAll();  // 1번 쿼리
    List<Long> caseIds = animalCases.stream().map(AnimalCase::getId).collect(Collectors.toList());
    
    @Query("SELECT p FROM Protection p WHERE p.animalCase.id IN :caseIds")
    List<Protection> protections = protectionRepository.findByAnimalCaseIds(caseIds);  // 1번 쿼리
    

(6)전체 정리표

전략장점단점페이징 처리다중 컬렉션
Fetch Join
  • 한 번의 쿼리로 연관 엔티티 모두 로딩
  • 쿼리 수 최소화
  • 페이징 처리 문제
  • 다중 컬렉션 문제
XXXToMany
불가능
X
EntityGraph
  • 간결한 코드로 구현 가능
  • 동적 로딩 전략 설정 가능
  • 표준 JPA 스펙
  • 페이징 처리 문제
  • 다중 컬렉션 문제
XXXToMany
불가능
X
default batch
fetch size
  • 애플리케이션 전역 설정 가능
  • Fetch Join, EntityGraph와 함께 사용 불가능
  • 부분 설정 불가능
OO
@BatchSize
  • 개별 맞춤 최적화 가능
  • Fetch Join, EntityGraph와 함께 사용 불가능
  • 개별 엔티티에 직접 설정 필요
OO
@Fetch
(SUBSELECT)
  • 원본 쿼리 결과를 서브쿼리로 재사용
  • Fetch Join, EntityGraph와 함께 사용 불가능
  • 대용량 데이터 시 메모리 부담
OO
IN 기반
수동 최적화
  • 완전한 제어 가능
  • Fetch Join 함께 사용 가능
  • 복잡한 조건에 적합하고 유연
  • 구현 복잡도 높음
  • 직접 구현 필요
OO
1.4.해결 전략의 문제점과 해결 방안

(1)컬렉션(OneToMany, ManyToMany) 페이징 처리 문제

문제 원인

  • 컬렉션 Fetch Join(or EntityGraph) + 페이징 사용 시 Hibernate가 페이징을 처리하지 않고

    모든 데이터를 메모리에 로딩 후 애플리케이션에서 페이징 처리

    ⇒ 페이징의 목적인 필요한 데이터만 가져오는 것이 무의미해진다.


  • 이유는 컬렉션 Join 시 데이터 중복이 발생할 수 있기 때문에 모든 데이터를 메모리에

    로딩후 distinct를 사용하여 중복을 제거하고 이후 메모리에서 페이징 처리를 한다.

    ⇒ 결과적으로 실제 DB에서 페이징(limit)가 적용되지 않는다.


  • HHH000104: firstResult/maxResults specified with collection fetch; applying in memory! 경고 발생

    ⇒ 메모리 사용량 때문에 대용량 데이터에서 OOM(Out Of Memory) 발생 위험 존재

해결책 1 : 지연 로딩 + Batch Fetching(ex. @BatchSize) 조합

// Entity
@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Article> articles = new ArrayList<>();

// Repository
Page<User> findAll(Pageable pageable);
  • 컬렉션을 Fetch Join 하지 않고 지연 로딩으로 유지하면서 Batch Fetching 사용
  • @BatchSize 또는 default_batch_fetch_size 설정으로 N+1 방지

해결책 2 : @Fetch(FetchMode.SUBSELECT) 사용

@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Article> articles = new ArrayList<>();
  • 연관 엔티티를 서브쿼리로 한번에 조회
  • 단, 모든 데이터를 한 번에 가져오기 때문에 대용량 데이터에서는 주의해야 한다.

(2)여러 컬렉션 조인 문제

문제 원인

  • 두 개 이상의 컬렉션(OneToMany, ManyToMany)에 Fetch Join(or EntityGraph) 적용 시 MultipleBagFetchException 발생
  • Hibernate는 둘 이상의 컬렉션 타입(List)을 동시에 Fetch Join할 수 없도록 제한한다.
    @EntityGraph(attributePaths = {"articles", "questions"}, type = EntityGraphType.FETCH)
    @Query("select distinct u from User u")
    List<User> findAllWithArticlesAndQuestions();
    
    에러 발생
  • 이유는 Cartesian Product으로 인한 중복 데이터 및 메모리 사용량 폭증
    • 두 개의 컬렉션의 각 요소의 모든 조합으로 조인된다.

      ⇒ 중복 데이터가 기하급수적으로 증가 ⇒ 메모리 사용량 폭증

    • 결과를 각 컬렉션에 매핑하는 것은 어렵고 비효율적

      ⇒ 명시적으로 제한함

해결책 1 : List 대신 Set 자료구조 사용

@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Article> articles = new HashSet<>();

@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Question> questions = new HashSet<>();
  • 중복 제거 효과가 있는 Set을 사용하면 된다. 순서가 중요하면 LinkedHashSet 사용
  • 단, 페이징 문제는 여전히 발생한다.

해결책 2: @BatchSize 활용

@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Article> articles = new ArrayList<>();

@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Question> questions = new ArrayList<>();
  • List 자료구조를 그대로 사용 가능
  • 페이징과 함께 사용 가능

(3)Batch Fetching과 Fetch Join & EntityGraph의 관계

  • Batch Fetching(default_batch_fetch_size, @BatchSize)은 Fetch Join이나 EntityGraph로 명시적으로 지정된 연관관계에는 적용되지 않는다.
  • 이유는 Fetch Join이 우선적으로 적용되어 Batch Fetching 설정이 무시되기 때문
  • Fetch Join과 batch fetching을 같이 사용하면
    • Fetch Join으로 지정된 엔티티 ⇒ Fetch Join 방식으로 즉시 로딩
    • Fetch Join으로 지정되지 않은 엔티티 ⇒ Batch Fetching 적용 (지연 로딩 시)

  • 예시

    @Query("SELECT p FROM Protection p JOIN FETCH p.applicant")
    List<Protection> findAllWithApplicant();
    
    • Protection의 applicant 필드는 Fetch Join으로 로딩되며,

      다른 지연 로딩 필드(예: comments)는 접근 시 Batch Fetching 설정에 따라 로딩된다


  • 주의사항
    • 동일한 필드에 @BatchSize를 설정하고 Fetch Join을 적용하면 Fetch Join이 우선 적용된다.
    • 따라서 페이징 문제나 두 개이상의 컬렉션 조인 문제를 해결하려면 Fetch Join 대신 @BatchSize만 사용해야 한다.

(4)결론

  1. 연관 관계는 되도록 항상 지연 로딩(LAZY) 설정
  2. default_batch_fetch_size 설정 (100~1000 사이)
    • 전역 설정을 기본으로 깔고 @BatchSize를 추가 설정하는 하이브리드 방식을 사용해 성능 최적화한다.
  3. 1번, 2번만 적용해도 N+1 문제는 거의 해결된 것이다. 다만 더욱 쿼리 수를 줄이고 성능 최적화를 해야 한다면,
    • XXXToOne이라면 페이징 문제가 없으므로 Fetch Join을 항상 적용한다.
    • 컬렉션인 경우, 단일 컬렉션이면서 페이징은 안 쓰는 경우에만 Fetch Join을 적용한다.
    • 루프 내 반복적인 쿼리는 명시적으로 IN을 활용해 최적화한다.

연관 관계 Default 표

관계OneToManyManyToManyOneToOneManyToOne
Default 값LAZYLAZYEAGEREAGER
main / sub기본 sub설정에 따라설정에 따라항상 main
  • Default 값은 main이든 sub든 상관없다. 관계에 따라 정해진다.
  • DB에 FK가 존재하는 쪽이 owner (@JoinColumn을 명시하는 쪽)
  • sub의 경우 ⇒ mappedBy를 사용하는 쪽
참고 자료

다음 페이지로 이동 (2. 성능 측정 방법론)