N+1
N+1 문제란 ?
특정 엔티티를 DB에서 조회를 할 때 나가는 “1” 번의 쿼리와 연관된 객체를 함께 조회하는 “N”번의 예상하지 못한 쿼리가 나가는 문제이다. 이 때문에 DB에 과부하가 발생할 수 있다.
- N+1 문제를 이해하기 위해서는 FetchType 에 대해 이해할 필요가 있다.
- N+1 문제는 특정 FetchType (LAZY, EAGER)에서만 발생하는 문제가 아니다.
FetchType : LAZY and EAGER
EAGER
- EAGER로 세팅이 되면 엔티티를 조회할 때 해당 연관관계를 바로 가져오는 것을 의미한다.
- ManyToOne 연관관계와 OneToOne 연관관계일 때 Default FetchType이 Eager로 세팅이 됨
//Member와 Team은 다대일 관계이다.
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "TEAM_ID")
private Team team;
}
@Entity
public class Team {
@Id
@GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
private List<Member> members = new ArrayList<>();
}
멤버 조회 (EAGER 로 팀 함께 조회)
List<Member> members = em.createQuery("select m from Member m", Member.class)
.getResultList();
//for (Member m : members) {
// System.out.println("m.getTeam().getName() = " + m.getTeam().getName());
//}
- SQL은 한 번만 실행된다. (조인해서 한 번에 가져옴)
- members에서 Team 을 조회하더라도 추가 쿼리가 실행되지 않음
select
team0_.TEAM_ID as TEAM_ID1_1_0_,
team0_.name as name2_1_0_,
members1_.TEAM_ID as TEAM_ID3_0_1_,
members1_.MEMBER_ID as MEMBER_I1_0_1_,
members1_.MEMBER_ID as MEMBER_I1_0_2_,
members1_.name as name2_0_2_,
members1_.TEAM_ID as TEAM_ID3_0_2_
from
Team team0_
left outer join
Member members1_
on team0_.TEAM_ID=members1_.TEAM_ID
where
team0_.TEAM_ID=?
팀 조회 (EAGER 로 멤버 함께 조회)
- SQL이 N+1 번 실행된다.
- List<Team>을 가져오는 데에 1번
- Member 를 조회하는데 N번 (team.size() 만큼)
List<Team> teams = em.createQuery("select t from Team t", Team.class)
.getResultList();
- teams 만 조회했어도 Member를 전부 가져온다.
Hibernate:
/* select
t
from
Team t */ select
team0_.TEAM_ID as TEAM_ID1_1_,
team0_.name as name2_1_
from
Team team0_
Hibernate:
select
members0_.TEAM_ID as TEAM_ID3_0_0_,
members0_.MEMBER_ID as MEMBER_I1_0_0_,
members0_.MEMBER_ID as MEMBER_I1_0_1_,
members0_.name as name2_0_1_,
members0_.TEAM_ID as TEAM_ID3_0_1_
from
Member members0_
where
members0_.TEAM_ID=?
Hibernate:
select
members0_.TEAM_ID as TEAM_ID3_0_0_,
members0_.MEMBER_ID as MEMBER_I1_0_0_,
members0_.MEMBER_ID as MEMBER_I1_0_1_,
members0_.name as name2_0_1_,
members0_.TEAM_ID as TEAM_ID3_0_1_
from
Member members0_
where
members0_.TEAM_ID=?
- N+1 문제 발생
LAZY
- LAZY로 세팅이 되면 엔티티를 조회할 때 해당 연관관계를 바로 조회하지 않고 프록시 객체로 들고 있는다.
- OneToMany 연관관계와 ManyToMany 연관관계일 때 Default FetchType이 LAZY로 세팅이 됨
//Member와 Team은 다대일 관계이다.
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
}
@Entity
public class Team {
@Id
@GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members = new ArrayList<>();
}
멤버 조회
//쿼리 실행
List<Member> members = em.createQuery("select m from Member m", Member.class)
.getResultList();
//결과
Hibernate:
/* select
m
from
Member m */ select
member0_.MEMBER_ID as MEMBER_I1_0_,
member0_.name as name2_0_,
member0_.TEAM_ID as TEAM_ID3_0_
from
Member member0_
//멤버의 팀 이름 접근 시 프록시에서 접근
for (Member m : members) {
System.out.println("m.getTeam().getName() = " + m.getTeam().getName());
}
//결과
Hibernate:
select
team0_.TEAM_ID as TEAM_ID1_1_0_,
team0_.name as name2_1_0_
from
Team team0_
where
team0_.TEAM_ID=?
m.getTeam().getName() = TeamA
m.getTeam().getName() = TeamA
Hibernate:
select
team0_.TEAM_ID as TEAM_ID1_1_0_,
team0_.name as name2_1_0_
from
Team team0_
where
team0_.TEAM_ID=?
m.getTeam().getName() = TeamB
팀 조회
//쿼리 실행
List<Team> teams = em.createQuery("select t from Team t", Team.class)
.getResultList();
//팀의 멤버 이름 접근 시 프록시에서 접근
for (Team t: teams) {
System.out.println("t.getMembers() = " + t.getMembers().get(0).getName());
}
N + 1 ?
- 위의 예제를 보면 Team을 조회하는 Member를 조회하든 연관관계를 Lazy로 설정하게 되면 List를 가져오는 한 번의 쿼리와 그 List의 size인 N번의 쿼리가 발생하게 된다. 이를 N + 1 문제라 한다.
N + 1 문제의 해결
- 크게 두 가지 방법으로 해결할 수 있다.
Fetch Join
em.createQuery("select m from Member m join fetch m.team t", Member.class)
.getResultList();
em.createQuery("select t from Team t join fetch t.members m", Team.class)
.getResultList();
- 위의 쿼리와 같이 join fetch를 사용하여 멤버를 조회할 때 팀을 함께 가져오거나, 팀을 조회할 때 멤버를 함께 가져와서 로딩하도록 하는 방법이다. 이를 페치 조인이라 한다.
- 여기서 ManyToOne 관계에서의 fetch join에서는 문제가 되지 않지만 OneToMany 관계에서 발생하게될 문제가 있다.
- ToOne 관계에서는 페치조인을 하더라도 데이터베이스에서 조회할 때 레코드의 개수가 일정하게 유지된다.
- 그러나 ToMany 관계에서는 조회하는 레코드의 사이즈가 늘어나게된다. 위의 예제에서 팀을 조회할때 멤버를 페치조인하게 되면 실제로 조회되는 팀이 하나인데 멤버의 수만큼의 레코드가 조회된다. (팀을 조회하는것임에도!!) 이는 데이터베이스 관점에서 보면 당연하다.
- 이게 왜 문제일까?
- 페이징을 할 때 문제가 된다. ToMany 형태에서는 페치조인을 하면서 페이징을 할 수 없다.
@BatchSize, spring.jpa.properties.hibernate.default_batch_fetch_size
- 위의 N+1 방법을 해결하는 또 다른 방법이 있다.
- 페치 조인은 N+1 번의 쿼리를 1번의 쿼리로 최적화하는 기법이다.
- batch_size 를 설정하는 방법을 사용하면 1+1의 쿼리로 최적화할 수 있고 페이징까지 처리할 수 있게된다.
사용 방법
- application.yml 파일에 spring.jpa.properties.hibernate.default_batch_fetch_size: 100~1000 으로 설정 (전체 프로젝트에 적용)
- 특정 연관관계에 @BatchSize(500)으로 개별 설정
동작
- 위와 같이 간단한 설정만으로 N+1 문제를 해결할 수 있다.
//쿼리 실행
List<Team> teams = em.createQuery("select t from Team t", Team.class)
.getResultList();
//팀의 멤버 이름 접근 시 프록시에서 접근
for (Team t: teams) {
System.out.println("t.getMembers() = " + t.getMembers().get(0).getName());
}
- 위의 코드에서 첫 번째 쿼리는 예상대로 팀만을 조회한다.
- 두 번째로 멤버를 접근하려 할 때 해당 팀에 연관된 멤버들의 id를 기준으로 memberId in (id1 … idN) 의 형태로 조회하게 된다. (N은 위에서 설정한 값이다.)
- 이러한 방식을 사용하면 team을 조회할 때 페이징 처리를 할 수 있기 때문에 페치조인에서 발생한 문제를 해결할 수 있다.
'Spring' 카테고리의 다른 글
스프링이 제공하는 프록시, 프록시 팩토리 (0) | 2022.06.09 |
---|---|
Servlet ? (0) | 2022.01.05 |
토비의 스프링 vol.2 4장 @MVC (0) | 2021.06.05 |
객체 지향 설계와 스프링 (0) | 2020.10.19 |
SOLID - 좋은 객체지향 설계의 5가지 원칙 (0) | 2020.10.19 |