N+1 총 정리

nuyh99·2023년 9월 12일
6

JPA 사용자라면 피해갈 수 없는 N+1 문제에 대해서 정리해보려고 합니다.

발생 원인

예시 엔티티

@Entity
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(cascade = {PERSIST, MERGE}, fetch = FetchType.EAGER)
    private List<Member> members = new ArrayList<>();

    @OneToMany(cascade = {PERSIST, MERGE}, fetch = FetchType.EAGER)
    private List<Dog> dogs = new ArrayList<>();
}

아주 간단한 예시로 살펴봅시다!
위와 같이 Team이라는 엔티티에서 Member, Dog 라는 두 엔티티를 @OneToMany로 가지고 있습니다.

cascade 옵션과 fetch 옵션은 테스트하기 쉽게 만들기 위해서 걸어두었습니다.
cascade는 테스트용 데이터를 넣기 쉽게 만들기 위해서이고,
fetchLAZY이든 EAGER이든 N+1은 발생하기 때문에 즉시 발생하기 위해서 사용했습니다.

예시 테스트

@Test
void select() {
    teamRepository.save(new Team());

    System.out.println("==========조회==========");
    final List<Team> all = teamRepository.findAll();
}

그리고 위와 같이 간단한 테스트를 작성했습니다.
그럼 어떤 SQL문이 로그로 찍힐까요??

테스트 결과

Hibernate: 
    select
        t1_0.id 
    from
        team t1_0
Hibernate: 
    select
        m1_0.team_id,
        m1_1.id 
    from
        team_members m1_0 
    join
        member m1_1 
            on m1_1.id=m1_0.members_id 
    where
        m1_0.team_id=?
Hibernate: 
    select
        d1_0.team_id,
        d1_1.id 
    from
        team_dogs d1_0 
    join
        dog d1_1 
            on d1_1.id=d1_0.dogs_id 
    where
        d1_0.team_id=?

위와 같이 Team 하나를 조회하는데 MemberDog에 대한 SELECT 문이 추가로 발생했습니다.

이것이 N+1 문제입니다.

Team 엔티티 하나를 조회하는데 (2+1)개의 SELECT 쿼리가 발생했습니다.
왜냐하면 해당 Team 엔티티의 필드인 MemberDog를 가져와야 하기 때문입니다.

그럼 Team 엔티티 3개를 조회할 때는 어떻게 될까요?

SELECT 문 하나로 Team 3개를 가져오고,
SELECT 문 하나로 3개의 Team에 대한 Member 들을 가져오고,
SELECT 문 하나로 3개의 Team에 대한 Dog 들을 가져오면 좋겠습니다.

결과는…?

Hibernate: 
    select
        t1_0.id 
    from
        team t1_0
Hibernate: 
    select
        m1_0.team_id,
        m1_1.id 
    from
        team_members m1_0 
    join
        member m1_1 
            on m1_1.id=m1_0.members_id 
    where
        m1_0.team_id=?
Hibernate: 
    select
        d1_0.team_id,
        d1_1.id 
    from
        team_dogs d1_0 
    join
        dog d1_1 
            on d1_1.id=d1_0.dogs_id 
    where
        d1_0.team_id=?
Hibernate: 
    select
        m1_0.team_id,
        m1_1.id 
    from
        team_members m1_0 
    join
        member m1_1 
            on m1_1.id=m1_0.members_id 
    where
        m1_0.team_id=?
Hibernate: 
    select
        d1_0.team_id,
        d1_1.id 
    from
        team_dogs d1_0 
    join
        dog d1_1 
            on d1_1.id=d1_0.dogs_id 
    where
        d1_0.team_id=?
Hibernate: 
    select
        m1_0.team_id,
        m1_1.id 
    from
        team_members m1_0 
    join
        member m1_1 
            on m1_1.id=m1_0.members_id 
    where
        m1_0.team_id=?
Hibernate: 
    select
        d1_0.team_id,
        d1_1.id 
    from
        team_dogs d1_0 
    join
        dog d1_1 
            on d1_1.id=d1_0.dogs_id 
    where
        d1_0.team_id=?

Team 전체를 가져온 뒤, 각 TeamMember, Dog를 각각 SELECT 해오는 것을 볼 수 있습니다.
따라서 (6 + 1) 개의 SELECT 문이 발생했습니다.

Team의 로드해야 할 연관 관계 갯수 X Team의 갯수 + 1 이므로
2 X 3 + 1 개의 쿼리가 발생한 것입니다.

  • 연관 관계의 차수가 늘어나면 어떨까요?
    • Member도 다른 @OneToMany를 가지고 있다면?
    • 모두 즉시 로딩이라면?
  • Team의 갯수가 늘어나면 어떨까요?
  • Team의 연관 관계의 갯수가 늘어나면 어떨까요?

빠른 WAS의 응답속도를 위해서는 외부 리소스와의 통신 비용을 줄여야 합니다.
그리고 DB와의 매 통신 횟수는 매우 큰 비중을 차지합니다.
TeamRepository#findAll 한 번 호출했더니 SELECT 문이 몇 백개가 날아갈 수 있습니다.
해결해야겠죠…?

Fetch Join

public interface TeamRepository extends JpaRepository<Team, Long> {
    @Query("""
            SELECT t FROM Team t
            JOIN FETCH t.members
            """)
    List<Team> findAll();
}

이런 식으로 JPQL에서 제공하는 Fetch Join을 사용할 수 있습니다.
결과는 어떻게 될까요??

Hibernate: 
    select
        t1_0.id,
        m1_0.team_id,
        m1_1.id 
    from
        team t1_0 
    join
        (team_members m1_0 
    join
        member m1_1 
            on m1_1.id=m1_0.members_id) 
                on t1_0.id=m1_0.team_id

오…Team을 조회할 때 Member를 JOIN해서 한 번에 가져오는 군요…!
그럼 Dog도 같이 조회하면 한 번의 쿼리로 Team, Member, Dog 모두 가져올 수 있지 않을까요?

@Query("""
        SELECT t FROM Team t
        JOIN FETCH t.members
        JOIN FETCH t.dogs
        """)
List<Team> findAll();

이런 식으로 한 번에 모든 연관 관계를 가져와봅시다.

MultipleBagFetchException이 발생했습니다.

왜…? 그게 뭐죠...?

저희는 Spring Data JPA를 사용할 때 JPA의 구현체로 Hibernate를 사용합니다.
그리고 Hibernate에는 Bag이라는 타입이 있고, 자바의 List 타입은 Hibernate 측의 Bag 타입으로 매핑됩니다.

List의 특징은 순서를 보장하고, 중복을 허용하는 자료구조이죠??
하지만 거기에 매핑되는 Bag은 순서를 보장하지 않고, 중복을 허용하는 자료구조입니다.

이게 무슨 문제가 되냐구요?

자바 객체에서는 하나의 Team 인스턴스에 여러 Member 인스턴스와 여러 Dog 인스턴스가 존재합니다.
하지만 이것을 DB에서 동시에 세 엔티티를 JOIN해서 가져온다고 생각해보세요.

Team 1에 Member {연어, 참치}가 있고, Dog {푸들, 닥스훈트}가 있을 경우입니다.

TeamMemberDog
1연어푸들
1연어닥스훈트
1참치푸들
1참치닥스훈트

위와 같이 Cartesian Product가 발생하게 됩니다. 참고

이렇게 DB에서 가져온 결과는 우선 Hibernate의 Bag 타입으로 매핑되겠죠?
자, 매핑이 됐습니다.
Team Bag은 {1, 1, 1, 1}
Member Bag은 {연어, 참치, 참치, 연어}
Dog Bag은 {닥스훈트, 푸들,닥스훈트, 푸들}

순서가 보장되지 않고...중복된 인스턴스가 존재하고...
그게 Hibernate의 Bag 타입입니다.

이제 자바의 Team 인스턴스로 만들 때를 생각해봅시다.
중복을 제거하면 불가능해보이지는 않습니다.
1. 그럼 여러 Team을 조회할 때는요?
2. Member@OneToMany 등의 연관 관계를 가지고 있을 때는요?

결론은,
Bag 타입은 중복을 허용하고 순서를 보장하지 않기 때문에 메모리가 부족할 수 있고 특정 객체에게 필요한 연관 관계의 인스턴스를 알맞게 매핑하기 힘들 수 있습니다.

그래서 하나의 쿼리 안에서 2개 이상의 List를 동시에 Fetch Join 하는 것이 불가능합니다.

어떻게 해결할까요?

@Query("""
        SELECT t FROM Team t
        JOIN t.members
        JOIN t.dogs
        """)
List<Team> findAll();

위의 쿼리는 어떨까요?

Hibernate: 
    select
        t1_0.id 
    from
        team t1_0 
    join
        team_members m1_0 
            on t1_0.id=m1_0.team_id 
    join
        team_dogs d1_0 
            on t1_0.id=d1_0.team_id

얼마든지 여러 List 연관 관계들을 매핑할 수 있습니다.
이게 해결 방법일까요…?

Join으로 가져온 결과는 영속화하지 않습니다.

대상 엔티티를 찾기 위한 조건으로 쓰기 위해서 Join을 할 수는 있지만, 엔티티로 매핑된 이후 연관 관계를 사용하려면 결국 다시 SELECT 해와서 영속화하게 됩니다.

Set

@Entity
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(cascade = {PERSIST, MERGE}, fetch = FetchType.EAGER)
    private Set<Member> members = new HashSet<>();

    @OneToMany(cascade = {PERSIST, MERGE}, fetch = FetchType.EAGER)
    private List<Dog> dogs = new ArrayList<>();

둘 이상의 List 타입을 동시에 Fetch Join 하는 것이 불가능하다고 했습니다.
따라서 members를 Set으로 바꾸면…?

Hibernate: 
    select
        t1_0.id,
        d1_0.team_id,
        d1_1.id,
        m1_0.team_id,
        m1_1.id 
    from
        team t1_0 
    join
        (team_members m1_0 
    		join
        		member m1_1 
            on m1_1.id=m1_0.members_id) 
    	on t1_0.id=m1_0.team_id 
    join
        (team_dogs d1_0 
        	join
                dog d1_1 
            on d1_1.id=d1_0.dogs_id) 
    	on t1_0.id=d1_0.team_id

원했던 대로 한 번에 Fetch Join을 해서 가져오게 됩니다!

그럼 전부 일단 Set으로 해두는 게 좋지 않나요??

결론만 말하자면,
1. @ManyToMany의 경우는 무조건 Set이 좋습니다.
2. 다른 연관 관계의 경우는 순서를 보장해야 하는지, 중복을 제거해야 하는지 따져보고 사용해야 합니다.

  • ex) 인덱스로 접근이 필요하거나 중복을 허용해도 된다면 List가 좋겠다!
  • +) Set의 경우 순서 보장을 위해서 LinkedHashSet을 사용하면 됩니다

EntityGraph

그렇다면 Fetch Join을 하기 위해서 항상 JPQL을 써야 할까요?

@EntityGraph(attributePaths = {"members", "dogs"})
List<Team> findAll();

고맙게도 JPA 측에서는 Entity Graph라는 것을 제공합니다.
직접 쿼리를 쓸 필요 없이 Fetch하고 싶은 필드를 위와 같이 작성해주면 끝입니다.

그럼 Fetch Join이랑은 차이가 없나요?

  • Entity Graph는 Left Outer Join만 가능합니다.
  • Fetch Join은 Inner, Outer Join 모두 자유롭게 가능합니다.

따라서 편하게 Outer Join을 하고 싶을 때 쓰면 될 것 같습니다.

Distinct

@Entity
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(cascade = {PERSIST, MERGE}, fetch = FetchType.EAGER)
    private Set<Member> members = new HashSet<>();

    @OneToMany(cascade = {PERSIST, MERGE}, fetch = FetchType.EAGER)
    private List<Dog> dogs = new ArrayList<>();
}

현재 Team 내에서 Dog는 List 타입입니다.
혹시 List(Bag)는 중복 제거가 되지 않는다는 것 기억하시나요??

@Test
void select() {
    teamRepository.save(new Team());
    teamRepository.save(new Team());
    teamRepository.save(new Team());
    System.out.println("====================");

    //각 Team의 Dog들을 출력
    teamRepository.findAll().stream()
            .map(Team::getDogs)
            .forEach(System.out::println);
}

위와 같이 세 개의 팀을 저장하고, 각 팀의 Dog 연관관계를 출력해봅시다.

각 팀에 Dog 인스턴스가 중복해서 들어있는 것을 볼 수 있습니다.

원인은 바로 위에서 살펴봤던 Cartesian Product 입니다.

그럼 어떻게 해결할 수 있을까요?

@Query("""
        SELECT DISTINCT t FROM Team t
        JOIN FETCH t.members
        JOIN FETCH t.dogs
        """)
List<Team> findAll();

DISTINCT 구문을 넣으면 제거해주지 않을까요?

달라진 것이 없는 모습입니다.
SQL을 공부하신 분들은 아시겠지만 DISTINCT는 조회할 대상 컬럼들에 대해서 중복 처리를 해주므로 Dog에 대해서는 중복 제거를 해주지 않습니다.

그럼 어떻게 하나요??

Hibernate 5까지는 PASS_DISTINCT_THROUGH 라는 힌트 옵션을 같이 주면 제거가 됩니다.
Hibernate 6부터는 항상 저 힌트 옵션을 자동으로 같이 넘겨서 중복을 제거해준다고 합니다.
참고: Distinct Queries in HQL

하지만…
저는 Spring Boot 3, Hibernate 6 에서 예시를 진행하고 있는데 중복 제거가 되지 않는군요…
이 부분은 혹시나 답을 아신다면 알려주세요ㅠㅠ

@Test
void select() {
    teamRepository.save(new Team());
    teamRepository.save(new Team());
    teamRepository.save(new Team());
    System.out.println("====================");

    teamRepository.findAll().forEach(System.out::println);
}

조회 대상 엔티티인 Team의 경우는 DISTINCT 없이 써도 중복 제거가 자동으로 됩니다.
List로 가진 연관 관계의 중복 이슈에 대해서 생각하고 있어야겠습니다.

Batch Size

그럼 2개 이상의 List를 꼭 써야할 때는 N+1 문제를 해결할 수 없을까요?

저희는 제일 처음 N+1이 발생할 때도 Team에 대한 SELECT 쿼리는 한 번만 필요했습니다.
그리고 이때 필요한 모든 Team의 PK 값을 알 수 있죠.
그럼 이 PK 값들을 FK로 가지는 Member, Dog 들을 각각 한 번만에 가져올 수 있지 않을까요??

SELECT d FROM Dog d 
WHERE d.team.id 
IN (1, 2, 3, 4)

위와 같은 식으로 모든 Team에 대한 Dog들을 한 번의 쿼리로 가져올 수 있을 것 같습니다.

이것이 바로 Batch Size 라는 기능입니다.

@BatchSize(size = 100)
@OneToMany(cascade = {PERSIST, MERGE}, fetch = FetchType.EAGER)
private List<Dog> dogs = new ArrayList<>();

위와 같이 어노테이션으로 손쉽게 설정해주면,

Hibernate: 
    select
        d1_0.team_id,
        d1_1.id 
    from
        team_dogs d1_0 
    join
        dog d1_1 
            on d1_1.id=d1_0.dogs_id 
    where
        array_contains(?,d1_0.team_id)

몇 개의 Team을 조회하더라도 N+1이 발생하지 않고 해당 Size 만큼의 Dog들을 한 번에 가져올 수 있습니다.

다만 이는 Hibernate 버전에 따라서 실제 해당 Size보다 적은 갯수만 가져올 수도 있습니다.
Hibernate 내부 사정에 따라서 정확히는 자신의 상황에서 직접 테스트해봐야 할 것 같습니다.
공식 문서에도 별 내용이 없는 것 같습니다.

추가로 어노테이션 기반이 아닌 application.properties 등에서도 설정할 수 있습니다.
조회 뿐 아니라 삽입, 삭제, 수정 등에서의 배치는 아래 글을 참고해주세요!
MySQL 환경의 스프링부트에 하이버네이트 배치 설정 해보기 | 우아한형제들 기술블로그

결론

지금까지 여러가지 방법들을 하나씩 살펴봤습니다.
사용하는 JPA의 구현체인 Hibernate의 버전에 따라서 위의 실제 동작 과정은 달라질 수도 있지만 전체적인 개념은 동일합니다.

또한 운영 환경에서 문제가 되지 않는다면, 냅둬도 전혀 나쁠 것이 없다고 생각합니다.(Premature Optimization)

문제를 발견했을 때 그 상황에서 가장 적합한 방법을 생각해보며 N+1 문제를 해결하면 좋을 것 같습니다!!
감사합니다.

profile
渽晛

4개의 댓글

comment-user-thumbnail
2023년 9월 13일

좋은 글 잘 읽고갑니다😝

1개의 답글
comment-user-thumbnail
5일 전

트렌딩 구경하다 왔는데 연어 글이어서 반가웠네요ㅋㅋㅋㅋ
잘 읽고 갑니다아

1개의 답글