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