N + 1 문제가 어떻게 발생하고 이를 해결하기 위해서 JOIN이 아닌 Fetch JOIN을 사용하는 이유를 코드 레벨에서 살펴보자.
JOIN
먼저 모든 테스트를 수행하기 전에 Member와 Team이 N : 1 양방향 관계를 맺고 Member가 두 명씩 들어가 있는 팀을 3개 만들 것이다.
// 모든 테스트 수행 전에 단 한 번만 수행한다.
@Transactional
@BeforeAll
void beforeAll() {
for(int i = 0; i < 3; i++) {
Member member1 = Member.builder().name("tester" + i).grade(Grade.VIP).build();
Member member2 = Member.builder().name("tester" + (i + 1)).grade(Grade.VIP).build();
Team team = Team.builder().name("team" + i).build();
team.addMember(member1);
team.addMember(member2);
teamJpaRepository.save(team);
memberJpaRepository.save(member1);
memberJpaRepository.save(member2);
}
}
그리고 테스트가 종료되면 모든 데이터를 삭제해 줄 것이다.
// 전체 테스트 종료 후에 한 번 수행한다.
@AfterAll
void afterAll() {
memberJpaRepository.deleteAll();
teamJpaRepository.deleteAll();
}
다음으로 JOIN을 이용하는 JPQL을 작성하고 EntityManager를 사용하여 쿼리 하는 코드이다. 전체 Team을 조회하고 List 형태로 결과를 반환할 것이다.
// join을 사용해서 불러오는 코드. 모든 team을 조회하면서 member 테이블을 join해보려 한다.
@Transactional
List<Team> usingJoin() {
String joinQuery = "SELECT distinct t FROM Team t join t.members";
TypedQuery<Team> typedQuery = em.createQuery(joinQuery, Team.class);
return typedQuery.getResultList();
}
이제 JOIN으로 가져온 List<Team>을 순회하면서 각각의 팀에 속한 멤버의 수를 확인해 볼 것이다.
( 여기서 만약에 @Transactional을 사용하지 않으면 teamList 객체가 준영속 상태가 되기 때문에 해당 객체에 대하여 더 이상 초기화를 진행할 수 없다. 또한 teamList안에 members는 프록시 객체로 초기화된 상태이므로 프록시 객체를 초기화할 수 없다는 org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: hello.core.team.Team.members, could not initialize proxy - no Session 오류가 발생한다. )
// join을 사용해서 불러온 Team List를 순회하면서 team의 members를 조회한다.
@Transactional
@Test
void joinTest() {
List<Team> teamList = usingJoin();
System.out.println("teams size = " + teamList.size());
for(Team team : teamList) {
System.out.println(team.getMembers().size());
}
}
결과는 다음과 같이 N + 1문제가 발생했다. 전체 팀을 조회하는데 1번의 쿼리가 발생했고 + 각 팀의 멤버 수를 얻기 위해서 N번의 쿼리가 발생했다. 굉장히 비효율적인 게 느껴진다.
이런 일이 언제 발생할까? 아마도 전체 팀을 리스트 형태로 출력하면서 해당 팀의 인원수를 함께 표기해야 하는 경우 이런 일이 발생할 수 있을 것이라 보인다.
Hibernate: select distinct team0_.id as id1_1_, team0_.name as name2_1_ from team team0_ inner join member members1_ on team0_.id=members1_.team_id
teams size = 3
Hibernate: select members0_.team_id as team_id4_0_0_, members0_.id as id1_0_0_, members0_.id as id1_0_1_, members0_.grade as grade2_0_1_, members0_.name as name3_0_1_, members0_.team_id as team_id4_0_1_ from member members0_ where members0_.team_id=?
2
Hibernate: select members0_.team_id as team_id4_0_0_, members0_.id as id1_0_0_, members0_.id as id1_0_1_, members0_.grade as grade2_0_1_, members0_.name as name3_0_1_, members0_.team_id as team_id4_0_1_ from member members0_ where members0_.team_id=?
2
Hibernate: select members0_.team_id as team_id4_0_0_, members0_.id as id1_0_0_, members0_.id as id1_0_1_, members0_.grade as grade2_0_1_, members0_.name as name3_0_1_, members0_.team_id as team_id4_0_1_ from member members0_ where members0_.team_id=?
2
이렇게 단순히 JOIN으로는 JPQL에서 N + 1 문제를 해결할 수 없음을 보았다. 그래서 left join과 같은 방식으로 가져오면 안 될까?라고 생각했는데 left join으로 해당 결괏값을 담을 수 있는 Dto를 따로 정의하여 가져오는 방법이 있었다. (이 방법은 다음에 포스팅하도록.. 궁금하면 링크 참조 https://jojoldu.tistory.com/342 )
Fetch JOIN
이제 Fetch Join은 어떻게 동작하는지 확인해보자. usingJoin 쿼리에 fetch 키워드만 추가했다.
// fetch join을 사용하는 쿼리
@Transactional
List<Team> usingFetchJoin() {
String joinQuery = "SELECT distinct t FROM Team as t join fetch t.members";
TypedQuery<Team> typedQuery = em.createQuery(joinQuery, Team.class);
return typedQuery.getResultList();
}
조회는 동일하게 수행한다.
// fetch join을 사용해서 불러온 Team List를 순회하면서 team의 members를 조회한다.
@Transactional
@Test
void fetchTest() {
List<Team> teamList = usingFetchJoin();
System.out.println("teams size = " + teamList.size());
for(Team team : teamList) {
System.out.println(team.getMembers().size());
}
}
결과는 다음과 같이 추가 쿼리가 나가지 않음을 알 수 있다.
Hibernate: select distinct team0_.id as id1_1_0_, members1_.id as id1_0_1_, team0_.name as name2_1_0_, members1_.grade as grade2_0_1_, members1_.name as name3_0_1_, members1_.team_id as team_id4_0_1_, members1_.team_id as team_id4_0_0__, members1_.id as id1_0_0__ from team team0_ inner join member members1_ on team0_.id=members1_.team_id
teams size = 3
2
2
2
Fetch Join은 뭐길래 한 번에 모두 결과를 가져올 수 있는 걸까? 사실 Fetch Join은 SQL의 기능이 아니고 JPQL의 기능이다. 우리가 Team에 속한 member를 한 번에 모두 가져온 것처럼 연관된 엔티티나 컬렉션을 함께 조회하는 기능을 제공한다. 또한 Fetch Join은 글로벌 로딩 전략(Lazy, Eager)보다 우선순위가 높다.
fetch join에도 한계가 있는데 아래와 같다.
- fetch join 대상에는 별칭을 줄 수 없다. (JPA를 제외한 몇몇 구현체는 가능하나, 별칭을 잘못 사용하면 데이터 수가 달라져서 데이터 무결성이 깨질 수 있다)
- 둘 이상의 컬렉션을 fetch 할 수 없다.
- Pagination을 사용할 때 OOM(Out of Memory)이 발생할 수 있다.
Fetch Join에서의 @Transactional
아래 코드에서 @Transactional이 꼭 필요할까? 위에서 살펴 본 바에 의하면 Fetch Join은 Team과 연관된 객체를 함께 초기화함을 알 수 있다. 즉 준영속 상태의 객체라고 하더라도 이미 초기화가 완료되었기 때문에 추가적인 초기화가 필요 없다면 @Transactional이 필요 없으리라 생각된다. 실제로 @Transactional을 제거하고 테스트를 실행해도 문제없이 잘 실행된다.
// fetch join을 사용해서 불러온 Team List를 순회하면서 team의 members를 조회한다.
@Transactional // 필요할까?
@Test
void fetchTest() {
List<Team> teamList = usingFetchJoin();
System.out.println("teams size = " + teamList.size());
for(Team team : teamList) {
System.out.println(team.getMembers().size());
}
}
결론
N + 1 문제가 발생하는 과정을 살펴봤고 Fetch Join이 왜 해결 방법 중 하나인지 확인해 볼 수 있었다. 다음에는 Pagination과 Fetch Join의 문제점을 해결하는 방법을 함께 살펴봐야겠다.
'JPA' 카테고리의 다른 글
OneToOne 정리 (0) | 2022.08.29 |
---|---|
JPA fetch join 대상의 별칭 사용과 주의사항 (0) | 2022.08.16 |
fetch join과 paging 시 OOM 문제 (OneToOne은 괜찮지) (0) | 2022.08.10 |
JPA에서 발생할 수 있는 문제들과 해결방법 정리 (N + 1, 무작위 JOIN..) (0) | 2022.08.09 |
JPA 동시성 문제 해결하기 (낙관적 락, 비관적 락) (0) | 2022.08.01 |