본문 바로가기
코딩 공부/web & Java

[JPA] N + 1 문제

by 현장 2024. 4. 23.

N + 1 문제

연관 관계가 설정된 엔티티를 조회할 경우조회된 데이터 갯수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상입니다.

🏷️ 예시

위와 같이 DB가 만들어져 있었을 때, EAGER(즉시 로딩)으로 한 경우와 LAZY(지연 로딩)으로 한 경우를 살펴보겠습니다.

✅ Fetch 모드를 EAGER(즉시 로딩)으로 한 경우

// User 엔티티
@Entity
public class User {
    @Id
    @GeneratedValue
    private long id;
    private String firstName;
    private String lastName;

    @ManyToOne(fetch = FetchType.EAGER)		// 즉시 로딩
    @JoinColumn(name = "team_id", nullable = false)
    private Team team;
}

// Team 엔티티
@Entity
public class Team {
    @Id
    @GeneratedValue
    private long id;
    private String name;

    @OneToMany(fetch = FetchType.EAGER)
    private List<User> users = new ArrayList<>();
}

JpaRepository를 extends 한 Interface 객체인 TeamRepository에서 findAll을 아래와 같이 호출을 하게 되면

// teamRepository의 findAll()메소드 실행
teamRepository.findAll();
System.out.println("============== N+1 시점 확인용 ===================");
Hibernate: select team0_.id as id1_0_, team0_.name as name2_0_ from team team0_
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
============== N+1 시점 확인용 ===================

위와 같이 team의 데이터를 조회하게 되면 조회하는 데이터 개수만큼 추가적인 N개의 조회쿼리가 발생하게 됩니다.

✅ Fetch 모드를  LAZY(지연 로딩)으로 한 경우

아래와 같이 즉시 로딩과 같이 findAll 메소드를 실행하면

teamRepository.findAll();
Hibernate: select team0_.id as id1_0_, team0_.name as name2_0_ from team team0_

위와 같이 n + 1문제가 발생하지 않는 것 처럼 보이나

List<Team> teamAll = teamRepository.findAll();
System.out.println("============== N+1 시점 확인용 ===================");
teamAll.stream().forEach(team -> {
    team.getUsers().size();
});

조회한 team 리스트의 user를 사용하게 되면

Hibernate: select team0_.id as id1_0_, team0_.name as name2_0_ from team team0_
============== N+1 시점 확인용 ===================
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?

지연 로딩에서는 N+1 문제가 발생하지 않는 것처럼 보였지만 막상 객체를 탐색하려고 하면 N개의 쿼리를 다시 조회하는 문제가 발생되어 N+1문제가 발생되는 시점만 즉시 로딩과 다를 뿐입니다.

🏷️ 발생 이유

N+1 문제가 발생하는 이유는 JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 Fetch 전략을 참고하지 않고 오직 JPQL 자체만을 사용하게 되어 아래와 같은 순서로 동작합니다.

✅ Fetch 전략이 즉시 로딩인 경우

  1. findAll()을 한 순간 select t from Team t 이라는 JPQL 구문이 생성되고 해당 구문을 분석한 select * from team 이라는 SQL이 생성되어 실행됩니다. 
  2. DB의 결과를 받아 team 엔티티의 인스턴스들을 생성합니다.
  3. team과 연관되어 있는 user 도 로딩을 해야 합니다.
  4. 영속성 컨텍스트에서 연관된 user가 있는지 확인합니다.
  5. 영속성 컨텍스트에 없다면 2에서 만들어진 team 인스턴스들 개수에 맞게 select * from user where team_id = ? 이라는 SQL 구문이 생성됩니다. ( N+1 발생 )

✅ Fetch 전략이 지연 로딩인 경우

  1. findAll()을 한 순간 select t from Team t 이라는 JPQL 구문이 생성되고 해당 구문을 분석한 select * from team 이라는 SQL이 생성되어 실행됩니다.
  2. DB의 결과를 받아 team 엔티티의 인스턴스들을 생성합니다.
  3. 코드 중에서 team 의 user 객체를 사용하려고 하는 시점에 영속성 컨텍스트에서 연관된 user가 있는지 확인합니다.
  4. 영속성 컨텍스트에 없다면 2에서 만들어진 team 인스턴스들 개수에 맞게 select * from user where team_id = ? 이라는 SQL 구문이 생성됩니다. ( N+1 발생 )

🏷️ 해결 방법

✅ Fetch Join

JPQL을 사용하여 DB에서 데이터를 가져올 때 처음부터 연관된 데이터까지 같이 가져오게 하는 방법입니다. (SQL Join 문을 생각하면 됩니다.)

public interface TeamRepository extends JpaRepository<Team, Long> {
    @Query("select t from Team t join fetch t.users")
    List<Team> findAllFetchJoin();
}

위와 같이 별도의 메소드를 만들어줘야 하며 @Query 어노테이션을 사용해서 "join fetch 엔티티.연관관계_엔티티" 구문을 만들어 주면 됩니다.

List<Team> teamAll = teamRepository.findAll();
System.out.println("============== N+1 시점 확인용 ===================");
teamAll.stream().forEach(team -> {
    team.getUsers().size();
});

 

위의 예시와 같이 코드를 실행을 시키게 되면

Hibernate: 
select 
    team0_.id as id1_0_0_, 
    user2_.id as id1_2_1_, 
    team0_.name as name2_0_0_, 
    user2_.first_name as first_na2_2_1_, 
    user2_.last_name as last_nam3_2_1_, 
    user2_.team_id as team_id4_2_1_, 
    users1_.team_id as team_id1_1_0__, 
    users1_.users_id as users_id2_1_0__ 
from 
    team team0_ 
    inner join 
        team_users users1_ 
    on 
        team0_.id=users1_.team_id 
    inner join 
        user user2_ 
    on users1_.users_id=user2_.id
============== N+1 시점 확인용 ===================

select문이 한번만 실행이 되는 것을 확인할 수 있습니다.

 @EntityGraph

// Entity Graph
@Override
@EntityGraph(attributePaths = {"user"}) // 객체의 필드명
List<Team> findAll();

엔티티 및 연관된 엔티티를 로드할 때 성능이 향상되고 join전략을 사용하여 그래프의 정의된 방식으로만 데이터를 검색하기 때문에 데이터베이스의 모든 정보를 한 번에 조회할 수 있습니다.

  Batch Size

이 옵션은 정확히는 N+1 문제를 안 일어나게 하는 방법은 아니고 N+1 문제가 발생하더라도 SQL의 in방식으로 조회하여 N+1 문제가 발생하게 하는 방법이다. 이렇게 하면 100번 일어날 N+1 문제를 1번만 더 조회하는 방식으로 성능을 최적화할 수 있습니다.

// application.yml의 경우
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000
        
// application.properties의 경우
spring.jpa.properties.hibernate.default_batch_fetch_size=1000

위와 같이 설정을 하게 되면

Hibernate: select team0_.id as id1_0_, team0_.name as name2_0_ from team team0_
Hibernate: 
select 
    users0_.team_id as team_id1_1_1_, 
    users0_.users_id as users_id2_1_1_, 
    user1_.id as id1_2_0_, 
    user1_.first_name as first_na2_2_0_, 
    user1_.last_name as last_nam3_2_0_, 
    user1_.team_id as team_id4_2_0_ 
from 
    team_users users0_ 
    inner join 
        user user1_ 
    on 
        users0_.users_id=user1_.id 
where 
    users0_.team_id in (?, ?, ?, ?)

위와 같이 in 쿼리가 나가게 됩니다.

📖 Reference

삽질중인 개발자

뽀글이아저씨

'코딩 공부 > web & Java' 카테고리의 다른 글

[JWT] JWT 0.12.5 최신화 문제 해결  (0) 2024.07.20
[Java] 팩토리 메소드  (0) 2024.07.13
[Spring] Proxy  (0) 2024.04.17
[Java] DTO를 Record로 만드는 이유  (0) 2024.04.17
[Java] Lombok  (0) 2024.03.10