Spring/JPA

[JPA] 언제 N+1이 발생할까?

churogrammer 2023. 6. 22. 23:56

☑️ 개요

N+1은 DB 액세스 시스템 중 JPA에서 나타나는 문제이고 JPA 사용 경험이 있는 사람들은 대부분 인지하고 있는 부분이다.

하지만 좀 더 상세한 설명이 있었으면 좋겠다고 생각하여 포스팅하였다.

 

✅ @OneToMany

N+1은 JPA의 매핑관계 중 하나인 '@OneToMany'를 사용해서 연관 엔티티를 불러올 때 발생할 수 있습니다.

1. 하나의 데이터를 조회한 후, 연관 엔티티를 조회

2. 데이터 컬렉션을 조회한 후, 연관 엔티티를 조회

이 두 가지 경우를 FetchType별로 확인해보겠습니다.

 

Team-User를 예시로 들어서 @OneToMany를 사용하는 상황을 설명하겠습니다.

 

 

▶️ Team 단건 엔티티 조회

식별자를 기준으로 하나의 Team 엔티티를 조회합니다. 하이버네이트가 아닌 순수 JPA API의 엔티티 매니저를 이용해봅시다.

entityManager.find();

Team 클래스에 선언된 @OneToMany의 fetch 타입에 따라 작동방식이 다르기 때문에 나누어서 확인해봅시다.

 

1. @OneToMany(fetch = FetchType.EAGER)

순수 JPA를 사용한 즉시로딩은 연관 엔티티의 데이터를 하나의 쿼리에서 조회합니다.

 

Team team = em.find(Team.class, 1);
select
        team0_.team_id as team_id1_8_0_,
        team0_.team_name as team_nam2_8_0_,
        users1_.team_id as team_id3_10_1_,
        users1_.user_id as user_id1_10_1_,
        users1_.user_id as user_id1_10_2_,
        users1_.team_id as team_id3_10_2_,
        users1_.user_name as user_nam2_10_2_ 
    from
        team_entity team0_ 
    left outer join
        user_entity users1_ 
            on team0_.team_id=users1_.team_id 
    where
        team0_.team_id=?

 

binding parameter [1] as [INTEGER] - [1]

단건 엔티티를 즉시 로딩하면 자동으로 join한 쿼리문을 생성하여 연관엔티티까지 한 번에 조회합니다.

 

 

2. @OneToMany(fetch = FetchType.LAZY)

순수 JPA를 사용한 지연 로딩은 연관 엔티티가 코드 상에서 직접 사용될 때 조회 쿼리를 발생시킵니다.

Team team = em.find(Team.class, 1);
select
        team0_.team_id as team_id1_9_0_,
        team0_.team_name as team_nam2_9_0_ 
    from
        team_entity team0_ 
    where
        team0_.team_id=?
binding parameter [1] as [INTEGER] - [1]

즉시 로딩과 달리 지연 로딩은 User의 데이터가 함께 조회되지 않습니다. 객체 데이터를 탐색하기 전까지는 프록시 객체를 사용하여 사용하지 않는 데이터에 대한 불필요한 조회를 줄입니다.

 

그렇다면 해당 데이터를 직접 그래프 탐색으로 사용해봅시다.

List<User> users = result.getUsers();
if(users != null){
    users.get(0).getUserId();
}
select
        users0_.team_id as team_id3_11_0_,
        users0_.user_id as user_id1_11_0_,
        users0_.user_id as user_id1_11_1_,
        users0_.team_id as team_id3_11_1_,
        users0_.user_name as user_nam2_11_1_ 
    from
        user_entity users0_ 
    where
        users0_.team_id=?
binding parameter [1] as [INTEGER] - [1]

연관 객체에 직접적으로 접근하니 조회 쿼리를 발생합니다.

 

위 테스트처럼 단건 데이터는 즉시 로딩은 한 번의 쿼리 조회, 지연 로딩에서 1+1 총 두 번의 쿼리 조회를 수행하였습니다.

다만 N+1 문제는 발생하지 않았지만, Many측의 데이터를 전부 조회해오기 때문에 많은 데이터가 있는 경우를 유의해야 합니다.

 

 

▶️ Team 리스트 엔티티 조회

직접 개발자가 쿼리문 형태를 작성하지 않고 쉽게 쿼리문을 생성할 수 있는 Spring Data JPA의 구현체를 사용해봅시다.

 

사실 N+1 문제는 리스트에서 더 자주 발생합니다.

단일 데이터를 조회한다면 기준 엔티티 (Team)의 id를 가지고 있는 연관 엔티티 (User)를 조회하면 기준 엔티티별로 다른 id 값에 대해 추가 쿼리가 발생하기 때문입니다.

한 번의 쿼리로 리스트를 조회하고, List 데이터 갯수인 N개에 대해서 각 row마다 연관 데이터 조회 쿼리가 발생합니다.

 

리스트를 조회하기 위해서 JPA의 쿼리메소드 findAll 사용해봅시다.

teamRepository.findAll();

쿼리메소드를 사용하면 메소드의 이름을 분석해서 JPQL을 생성하고, JPA가 JPQL을 분석해서 쿼리문을 생성할 때 연관관계 매핑을 참고하지 않고 JPQL만을 확인하여 생성합니다.

 

DB로 부터 조회된 엔티티를 생성하면서 어떤 연관관계를 가지고 있는지 확인하고 Fetch 전략에 맞는 타이밍에 연관 엔티티를 조회하는 쿼리를 발생시킵니다.

순수 JPA를 사용했을 때, 즉시 로딩은 join된 데이터를 조회했던 것 과 달리 쿼리메소드를 사용하면 Eager와 Lazy 모두 추가적인 쿼리문이 발생합니다.

 

우리가 예상하는 @OneToMany(fetch = FetchType.EAGER) 로딩은 다음과 같습니다.

1. Team 엔티티와 join된 User 엔티티 데이터를 하나의 쿼리문으로 조회해온다.

2. 여러 데이터가 존재하여 List 형태(N개)로 개발자에게 리턴된다.

 

 

하지만 Eager 연관관계를 가진 쿼리 메소드가 생성하는 쿼리 작업의 형태는 다음과 같습니다.

    select
        team0_.team_id as team_id1_9_,
        team0_.team_name as team_nam2_9_ 
    from
        team_entity team0_
extracted value ([team_id1_9_] : [INTEGER]) - [1]
    select
        users0_.team_id as team_id3_11_0_,
        users0_.user_id as user_id1_11_0_,
        users0_.user_id as user_id1_11_1_,
        users0_.team_id as team_id3_11_1_,
        users0_.user_name as user_nam2_11_1_ 
    from
        user_entity users0_ 
    where
        users0_.team_id=?

바로 위 쿼리문에 대해 아래 파라미터만큼 반복적으로 수행되었습니다.

binding parameter [1] as [INTEGER] - [3]
binding parameter [1] as [INTEGER] - [2]
binding parameter [1] as [INTEGER] - [1]

1. 기준 엔티티인 Team 리스트(N개)를 조회한다.

2. Team 리스트의 id 값을 가지고 있는 연관엔티티가 있는지 id별로 한 번씩 조회한다.

(N개의 리스트를 순회하면서 모든 로우에 대해 연관엔티티를 탐색한다.)

예시에서는 Team의 리스트 요소 갯수인 3만큼 User 연관 엔티티 조회가 이루어졌습니다.

 

이처럼 요소만큼 추가적인 조회 쿼리문이 수행되는 문제를 N+1 문제라고 이야기합니다.

Lazy 로딩에서도 결국엔 연관엔티티를 사용할 때 마다 쿼리가 발생하여 Eager와 Lazy 모두에서 발생합니다.

 

요소만큼 추가적인 조회가 이루어지는 것은 @OneToMany(Lazy), @ManyToOne(Lazy)에서도 볼 수 있습니다.

다만, 이는 지연 로딩에 의한 사용자가 선택한 사항이기에 N+1 문제보다는 지연 로딩의 특성에 더 가깝다고 생각합니다.

 

✅ 결론

N+1 문제는 리스트 데이터의 연관데이터를 @OneToMany로 매핑한 상황에서 JPQL을 사용하여 페치전략이 반영되지 않은 쿼리문을 생성하는 상황에서 발생할 수 있습니다.

 


해결 방법은 다음 포스팅에서..

 

출처

자바 ORM 표준 JPA 프로그래밍