[JPA] N+1 문제
N + 1 문제와 해결 방법
N + 1 문제
처음 실행한 SQL의 결과 수만큼 추가로 SQL을 실행하는 것
- 쿼리가 N + 1번 발생하는 문제
- Eager / Lazy 모두 발생함
Eager
: JPA에서는 문제 없으나 JPQL을 이용할 시 문제 발생.- JPQL은 즉시 로딩과 지연 로딩에 대해서 전혀 신경 쓰지 않고 JPQL만 사용해서 SQL을 생성
Lazy
: 프록시 객체로 가지고 있던 객체가 실제 사용될 때 쿼리가 발생함- JPQL에서는 N+1 문제 발생하지 않음
- 엔티티 내부 컴포지션으로 갖고 있던 해당 컬렉션에 접근할 때마다 쿼리 발생
Team 클래스
@Entity
@Getter
@Setter
public class Team {
@Id
@GeneratedValue
@Column(name = "team_id")
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<TestMember> testMembers = new ArrayList<>();
}
Member 클래스
@Entity
@Getter
@Setter
public class TestMember {
@Id
@GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
}
N + 1 발생 테스트 코드
@Test
public void N플러스1_테스트() throws Exception {
// given
Team teamA = new Team();
teamA.setName("팀A");
em.persist(teamA);
Team teamB = new Team();
teamB.setName("팀B");
em.persist(teamB);
TestMember testMember1 = new TestMember();
testMember1.setName("회원1");
testMember1.setTeam(teamA);
em.persist(testMember1);
TestMember testMember2 = new TestMember();
testMember2.setName("회원2");
testMember2.setTeam(teamA);
em.persist(testMember2);
TestMember testMember3 = new TestMember();
testMember3.setName("회원3");
testMember3.setTeam(teamB);
em.persist(testMember3);
em.flush();
em.clear();
// when
String query = "select m from TestMember m";
List<TestMember> resultList = em.createQuery(query, TestMember.class).getResultList();
for (TestMember member : resultList) {
System.out.println("member = " + member.getName() + ", " + member.getTeam().getName());
}
// then
assertThat(resultList.size()).isEqualTo(3);
}
N + 1 발생 로그
- 처음에 TestMember를 조회하는 쿼리 - 1회
- TestMember 조회 결과 반복문을 돌면서 Team 정보 조회 - 2회
- 왜 3회가 아니라 2회? 기존 팀A 엔티티가 영속성 컨텍스트에
1차 캐싱
되었기 때문
- 왜 3회가 아니라 2회? 기존 팀A 엔티티가 영속성 컨텍스트에
Hibernate:
select
testmember0_.member_id as member_i1_8_,
testmember0_.name as name2_8_,
testmember0_.team_id as team_id3_8_
from
test_member testmember0_
Hibernate:
select
team0_.team_id as team_id1_7_0_,
team0_.name as name2_7_0_
from
team team0_
where
team0_.team_id=?
member = 회원1, 팀A
member = 회원2, 팀A
Hibernate:
select
team0_.team_id as team_id1_7_0_,
team0_.name as name2_7_0_
from
team team0_
where
team0_.team_id=?
member = 회원3, 팀B
해결 방법
해결방법 1) Fetch Join
- 대부분의 N + 1 문제는
Fetch Join
으로 해결 - fetch join을 사용하면 프록시가 아닌
실제 엔티티
가 담김. 영속성 컨텍스트에 올라가 있는 객체. - 일반 조인과 차이점
- 일반 조인 실행 시 연관된 엔티티를 함께 조회하지 않음. 조인만 해줌. select 절에 연관 엔티티 정보가 포함 되지 않음
- JPQL은 결과를 반환할 때 연관관계 고려 ❌
- 단지 SELECT 절에 지정한 엔티티만 조회
- 페치 조인을 사용할 때만 연관된 엔티티 조회 (사실상 즉시로딩)
- 페치 조인은 객체 그래프를 SQL 한번에 조회하는 개념
- Join 쿼리문 실행으로 인해 중복 결과 발생할 수 있음
DISTINCT
를 사용해서 중복을 제거하는 것이 좋음 : 쿼리문에 distinct가 추가되지만 전체 칼럼에 대한 distinct이기 때문에 사실상 무의미. 하지만, 하이버네이트가 조회 결과에 대한 distinct를 해주기 때문에 중복이 제거되는 것임
@Test
public void N플러스1_테스트() throws Exception {
// ... 상단은 동일하기 때문에 생략합니다.
String query = "select m from TestMember m join fetch m.team";
List<TestMember> resultList = em.createQuery(query, TestMember.class).getResultList();
for (TestMember member : resultList) {
System.out.println("member = " + member.getName() + ", " + member.getTeam().getName());
}
// ... 하단 생략
}
Hibernate:
select
testmember0_.member_id as member_i1_8_0_,
team1_.team_id as team_id1_7_1_,
testmember0_.name as name2_8_0_,
testmember0_.team_id as team_id3_8_0_,
team1_.name as name2_7_1_
from
test_member testmember0_
inner join
team team1_
on testmember0_.team_id=team1_.team_id
member = 회원1, 팀A
member = 회원2, 팀A
member = 회원3, 팀B
해결방법 2) 하이버네이트 @BatchSize
- 연관된 엔티티를 조회할 때 지정한 size 만큼 SQL의 IN 절을 사용해서 조회
- 즉시로딩 시, (총 건수 / 사이즈) + 1 횟수만큼 쿼리가 실행됨
- 지연로딩 시, 처음 사이즈만큼을 가져오는 쿼리 1회를 실행해 미리 로딩. 사이즈 이상의 데이터에 접근하면 그 때 SQL을 추가 실행
@Entity
@Getter
@Setter
public class Team {
@Id
@GeneratedValue
@Column(name = "team_id")
private Long id;
private String name;
@org.hibernate.annotations.BatchSize(size = 5)
@OneToMany(mappedBy = "team")
private List<TestMember> testMembers = new ArrayList<>();
}
해결방법 3) 하이버네이트 @Fetch(FetchMode.SUBSELECT)
- 연관된 데이터를 조회할 때 서브 쿼리를 사용해서 N + 1 문제 해결
- 즉시 로딩 시, 조회 시점 쿼리 실행
- 지연 로딩 시 ,지연 로딩된 엔티티를 사용하는 시점에 쿼리 실행
@Entity @Getter @Setter public class Team { @Id @GeneratedValue @Column(name = "team_id") private Long id; private String name; @org.hibernate.annotations.Fetch(FetchMode.SUBSELECT) @OneToMany(mappedBy = "team") private List<TestMember> testMembers = new ArrayList<>(); }
JPA 글로벌 Fetch 전략 - @OneToOne, @ManyToOne : 즉시로딩 - @OneToMany, @ManyToMany : 지연로딩
⭐️ 기본값이 즉시로딩인 @OneToOne
, @ManyToOne
은 fetch = FetchType.LAZY 로 설정해서 지연 로딩 전략을 사용하자!!
참조
- 김영한님의 인프런 강의 : 자바 ORM 표준 JPA 프로그래밍 - 기본편
- 김영한님의 자바 ORM 표준 JPA 프로그래밍 : 15.4.1 N+1 문제