다른 사람들의 프로젝트를 보다가 N+1 문제 해결에 대한 내용이 많아 궁금해졌다.
평소에 프로젝트를 진행하면서 쿼리가 예상보다 많이 로그에 찍히는 것을 보긴 했지만, 그때는 성능에 미치는 영향을 깊게 생각하지 않았다.
하지만 이번 기회에 N+1 문제가 DB와 애플리케이션 성능에 큰 영향을 준다는 것을 깨달았고, 같은 실수를 반복하지 않기 위해 정리해 두려고 한다.
연관관계가 있는 엔티티를 조회할 때 조회된 개수 N개 만큼의 쿼리가 추가로 발생하는 것
이렇게 정의되어 있는데 쉽게 생각하면
엔티티 조회 쿼리(1번) + 조회된 엔티티의 개수(N개) 만큼 연관된 엔티티를 조회하기 위한 추가 쿼리 (N번)
이라고 생각하면 편하다.
이런 관계가 존재할 때,
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String name;
@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
private Set<Player> players = new LinkedHashSet<>();
public Team(String name) {
this.name = name;
}
}
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Player {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String name;
@ManyToOne
private Team team;
public Player(String name) {
this.name = name;
}
}
이런 연관 관계가 있는 엔티티들이 존재할 때 팀들을 조회해 보면
@Test
void exampleTest() {
Set<Player> players = new LinkedHashSet<>();
for (int i = 0; i < 10; i++) {
players.add(new Player("player" + i));
}
catRepository.saveAll(players); // 저장
List<Team> teams = new ArrayList<>();
for (int i = 0; i < 10; i++) {
Team team = new Team("team" + i);
team.setPlayers(players);
teams.add(team);
}
teamRepository.saveAll(teams); // 저장
entityManager.clear(); // 영속성 컨텍스트 초기화
System.out.println("-------------------------------------------------------------------------------");
List<Team> everyTeams = teamRepository.findAll();
assertFalse(everyTeams.isEmpty());
}
SELECT t1_0.id, t1_0.name FROM team t1_0;
SELECT p1_0.team_id, p1_0.id, p1_0.name FROM player p1_0 WHERE p1_0.team_id=?;
SELECT p1_0.team_id, p1_0.id, p1_0.name FROM player p1_0 WHERE p1_0.team_id=?;
SELECT p1_0.team_id, p1_0.id, p1_0.name FROM player p1_0 WHERE p1_0.team_id=?;
...
이렇게 는 팀 목록을 가져오는 단일 쿼리(1)가 실행되고
팀마다 개별적으로 선수 데이터를 조회하는 쿼리가 실행된다.
결과적으로
• team을 조회하는 1개의 쿼리 실행
• team의 개수(N)만큼 player 조회 쿼리가 추가 실행
즉시 로딩과 지연 로딩은 DB에서 데이터를 조회하는 방식중 하나로, 객체 간의 연관 관꼐를 어떻게 로딩하고 관리 할 것인지에 대한 개념
각 연관관계의 default 속성
@ManyToOne : EAGER
@OneToOne : EAGER
@ManyToMany : LAZY
@OneToMany : LAZY
그러면 지연 로딩을 사용하면 되는거 아닌가요??
-> 땡!!
N + 1 문제가 발생하는 이유는 한쪽 엔티티만 조회하고 연결된 다른 테이블을 또 조회하기 때문이다.
미리 두 테이블을 조인(Join) 하여 한번에 모든 데이터를 가져오는 것이 가능하면 N + 1 문제가 발생하지 않을 것이다.
JPQL로 fetch Join을 적용해보자면
@Query("select distinct o from Team o join fetch o.players")
List<Team> findAllJoinFetch();
select distinct t1_0.id,t1_0.name,p1_0.team_id,p1_0.id,p1_0.name from team t1_0 join player p1_0 on t1_0.id=p1_0.team_id
이렇게 쿼리가 한번 수행되고 Team과 player의 데이터를 가져오는 것을 확인할 수 있다.
하지만 단점도 존재한다.
@EntityGraph 의 attributePaths는 같이 조회할 연관 엔티티명을 적기
,(콤마)를 통해 여러 개를 전달 가능
@EntityGraph(attributePaths = {"palyer"})@Query("select DISTINCT o from Team o")
List<Team> findAllEntityGraph();
select distinct t1_0.id,t1_0.name,p1_0.team_id,p1_0.id,p1_0.name from team t1_0 join player p1_0 on t1_0.id=p1_0.team_id
결과를 보면 쿼리가 1번만 발생하고 미리 Team과 player 데이터를 조인해서 가져오는 것을 볼 수 있음
Fetch join의 경우 inner join을 하는 반면에 EntityGraph는 outer join을 기본으로 한다.
FetchJoin과 EntityGraph는 공통적으로 카테시안 곱(Cartesian Product)이 발생 하여 중복 가능
참고 : [JPA] N+1 문제가 발생하는 여러 상황과 해결방법
참고 : [JPA] N+1 문제 원인 및 해결방법 알아보기