연관 관계가 설정된 엔티티를 조회할 경우에 데이터의 갯수 n 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상
DB 구조는 유저는 한개의 팀에만 속할 수 있고 팀 하나는 여러명의 유저가 가입할 수 있다. 테스트 데이터로는 4개당 팀당 유저의 4명씩 총 20명의 유저를 추가했다.
@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;
}
@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()
을 호출하면
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 시점 확인용 ===================
위와 같이 N+1문제가 발생하게 된다.
Team, User 객체에서 Fetch 모드만 Lazy로 변경한 후 똑같이 findAll을 호출하면
Hibernate: select team0_.id as id1_0_, team0_.name as name2_0_ from team team0_
이번에는 N+1이 발생하지 않은 것 처럼 보인다.
그러나 아래와 같이 users를 사용하려고 하면 N+1 문제가 발생한다.
List<Team> all = teamRepository.findAll();
System.out.println("============== N+1 시점 확인용 ===================");
all.stream().forEach(team -> {
team.getUsers().size();
});
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+1문제가 발생되어 N+1 문제가 발생되는 시점만 즉시 로딩과 다를 뿐이다.
N+1 문제가 발생하는 이유는 JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 fetch 전략을 참고하지 않고 오직 JPQL 자체만 사용한다.
즉 아래와 같은 순서로 동작한다.
- 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 발생)
- 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 발생)
N+1 문제를 해결하는 방법에서는 Fetch join, EntityGraph 어노테이션, Batch Size 등의 방법이 있다.
JPQL을 사용하며 DB에서 데이터를 가져올 때 처음부터 연관된 데이터까지 같이 가져오게 하는 방법이다. SQL Join문을 생각하면 된다.
별도의 메소드를 만들어줘야 하며 @Query 어노테이션을 사용해 join fetch 연관관계_엔티티
구문을 만들어주면 된다.
@Query("select t from Team t join fetch t.users")
List<Team> findAllFetchJoin();
List<Team> all = teamRepository.findAllFetchJoin();
System.out.println("============== N+1 시점 확인용 ===================");
all.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 시점 확인용 ===================
sql 로그를 보면 별도의 지정을 안하면 JPQL에서 join fetch 구문은 SQL문의 inner join 구문으로 변경되어 실행된다.
@EntityGraph는 어노테이션을 사용해 fetch join을 하는 것인데 그냥 이렇게 있구나만 알아두자
사용하는 순간 조금만 관계가 복잡해져도 헬게이트가..
이 옵션은 정확히는 N+1 문제를 안 일어나게 하는 방법은 아니고 N+1 문제가 발생하더라도 select * from user where team_id = ? 이 아닌 select * from user where team_id in (?,?,?) 방식으로 N+1 문제가 발생하게 하는 방법이다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
간단하게 설정 하나 세팅해주면 아래와 같이 in 절로 쿼리가 나간다.
우선 연관관계에 대한 설정이 필요하다면 FetcyType을 성능 최적화를 하기 위해 어려운 즉시 로딩을 사용하는 것이 아니라 지연 로딩 모드로 사용하고 성능 최적화가 필요한 부분에는 Fetch 조인을 사용한다.
또한 기본적으로 Batch Size의 값을 1000 이하로 설정한다. (대부분 DB에서 IN절의 최대 개수 값: 1000)
그 외 케바케긴하지만 꼭 연관관계 설정이 필요 없다면 N+1문제로 인하여 디비가 죽는 불상사를 막기 위해 연관관계를 끊어버리고 사용하는 것도 방법이다.