0. 들어가기 전
JPA를 사용하면서 발생하는 N+1 문제는 널리 알려져 있고, JPA를 사용하다보면 제법 자주 만나게 됩니다.
그래서 N+1 문제를 다룬 블로그나 다른 레퍼런스들이 상당히 많습니다.
저 또한 N+1 문제를 공부하기 위해 많은 글들을 살펴봤었습니다.
하지만, 다른 글들을 읽어보고 더 깊은 N+1 문제에 대한 개인적인 궁금증들이 생겨나고 쉽게 풀리지 않아서
이번 포스팅을 작성하면서 테스트를 진행해보고 기록해보고자 합니다.
N+1 문제 및 근본적인 원인에 대해 추론하고 해결방법을 알아보면서, N+1 문제에 대해서 고찰해보도록 하겠습니다! 😃
(뇌피셜 파티가 될 예정입니다.. 🤣)
예시 엔티티는 가장 간단한 Team-Member 엔티티로 구성하여
도메인 이해가 N+1 문제의 이해에 방해되지 않도록 다음과 같이 간단하게 구성했습니다.
1. JPA의 N+1 문제 정의 및 원인
JPA의 N+1 문제의 정의를 사람들은 다음과 같이 말하고 있습니다.
연관관계가 있는 엔티티를 조회할 때 조회된 개수 N개 만큼의 쿼리가 추가로 발생하는 것
그럼 어떤 상황에서 N개의 쿼리가 추가로 발생하는지 JPA의 N+1 문제가 발생하는 상황을 알아봅시다.
위와 같이 Team-Member 엔티티가 1:N 양방향으로 연관관계를 가진다고 가정하고 진행하겠습니다.
이때 N+1 문제가 발생하는 가장 흔한 상황은 다음과 같은 상황입니다.
기본 엔티티 가정
- Team A, Team B, Team C 3개의 Team 엔티티가 존재하는 상황
- 각 팀 엔티티에는 멤버들이 속해있다고 가정 (멤버가 비어있는 팀은 없다.)
비즈니스 요구 사항
- 각 팀에 속한 멤버들의 닉네임을 볼 수 있다.
이러한 가정 및 요구사항이 존재할 때 해당 요구사항을 코드로 어떻게 구현할까요?
당연히 다음과 같이 구현할 수 있을 것입니다.
(편의를 위해서 Spring Data JPA와 Java 17 & Spring을 사용하여 진행했습니다.)
public List<String> findAllTeamMemberNicknames() {
return teamRepository.findAll().stream()
.flatMap(team -> team.getMembers().stream())
.map(Member::getNickname)
.toList();
}
- TeamRepository의 findAll로 모든 Team(Team A, Team B, Team C) 조회
- 각 팀의 모든 멤버의 닉네임 Member의 getNickname()으로 수집하여 반환
이제 해당 메소드의 테스트 코드를 작성해서 발생하는 쿼리를 살펴봅시다.
@Test
@DisplayName("모든 팀에 속한 닉네임 목록을 가져올 때, 쿼리를 검사한다.")
void inspect_query_findAllTeamMemberNicknames() {
// when & then
teamService.findAllTeamMemberNicknames();
}
위와 같이 테스트 코드를 작성하고, 테스트를 실행해봤습니다.
(Team A, Team B, Team C에 각 멤버 2명씩 데이터를 넣어서 테스트를 진행했습니다.)
1-1. 테스트 실행 결과 쿼리 분석
테스트 실행 결과 쿼리를 분석해보도록 하겠습니다.
JPA를 사용하면 로깅되는 쿼리가 JPQL로 로깅되기 때문에 이해를 위해 순수 SQL로 변환하여 설명드리겠습니다.
결과만 살펴보면, 모든 팀 조회 쿼리 1개와 각 팀에 속한 모든 멤버 조회 쿼리 3개가 발생하는 것을 알 수 있습니다.
이렇게 발생하는 쿼리가 N+1 쿼리입니다!
- 모든 팀 조회 쿼리 : 1
- 각 팀(Team A, B, C)에 속한 모든 멤버 조회 쿼리 : N (3개 - 팀 개수만큼)
만약 팀이 3개가 아니라 100개, 1000개가 된다면,
모든 팀을 조회할 때 1개 쿼리가 아닌 팀의 개수만큼 1 + 100개, 1 + 1000개의 쿼리가 발생하게 됩니다!
그래서 이렇게 발생하는 상황을 보면, 발생시키는 주체? 가 N이 아닌 1이기 때문에
개인적으로는 N+1 문제보다는 1+N 문제(1개 쿼리 시 N개의 추가 쿼리 발생)가 조금 더 적절한 용어가 아닐까 싶습니다.
1-2. N+1 문제 원인 분석
그렇다면, 왜 N+1 문제가 발생하는지 쿼리를 자세히 분석해보면서 원인을 추정해봅시다.
1. 모든 팀 조회 쿼리
- TeamRepository.findAll()에 의한 모든 팀 조회 쿼리
- select * from team
1번 쿼리는 자연스럽습니다.
왜냐하면, 모든 팀을 조회하는 것이기 때문에 select * from team으로 모든 팀을 가져오는 것은 자연스럽기 때문입니다!
문제는 다음 팀의 개수만큼 N개의 쿼리가 발생하는 2번 쿼리입니다.
2. 각 팀에 속한 모든 멤버 조회 쿼리
- 개발자가 직접적으로 호출하지 않은, 1번에서 조회한 각 팀에 속한 멤버 조회 쿼리
- select * from member where team_id = ?
해당 쿼리가 발생하는 이유로는 JPA가 Team 엔티티를 맵핑하는 과정을 상상해보면 이해가 쉬울 것 같습니다.
JPA를 사용할 때는 JPA의 추상화 계층을 통해 추상화된 메소드만 사용해서 내부 구현을 모르고 사용이 가능했습니다.
그렇다면, ORM 기술인 JPA는 어떻게 연관관계가 있는 엔티티와 테이블을 맵핑하는 것일까요?
이미 1번 쿼리와 2번 쿼리를 통해 맵핑을 수행한다는 결과가 나와있으므로 역추론을 해봅시다.
- 1. 먼저 'select * from team'을 통해 가져온 테이블의 컬럼과 Team 엔티티를 맵핑한다.
- 2. 'select * from member where team_id = ?'을 통해 조회한 Team 엔티티에 속한 Member 엔티티를 맵핑한다.
이러한 과정으로 먼저 Team 테이블에서 컬럼들을 Team 엔티티로 맵핑하고,
이후에 조회한 Team에 해당하는 FK를 가진 Member 테이블의 컬럼들을 조회하여 Member 엔티티를 맵핑하여
최종 Team 엔티티를 가져오는 것으로 추론할 수 있습니다.
이렇게 추론하고 나면, N+1 문제가 발생하는 원인은 다음과 같은 결론이 도출됩니다.
JPA에서 엔티티 조회 시에 테이블을 객체로 맵핑할 때,
조회하는 엔티티가 가진 대상 엔티티는 추가로 조회 쿼리를 날려서 조회 후에 완전한 Team 엔티티로 맵핑한다.
2. 그렇다면 JPA는 왜 그렇게 설계했을까? (N+1 문제 근본적인 원인 추측)
N+1의 정의와 위의 원인을 이해하고 나서 저는 자연스럽게 다음과 같은 생각을 떠올렸었습니다.
'조회하는 엔티티가 가진 대상 엔티티를 맵핑할 때 추가 조회 쿼리를 날리기 때문에 N+1이 발생한다고?'
'그럼 조회하는 엔티티를 가져올 때 대상 엔티티의 테이블을 Join해서 가져오면 추가 쿼리가 발생 안하는 거 아닌가?'
이러한 사고 과정을 통해서 JPA가 왜 대상 엔티티의 테이블을 Join해서 가져오지 않는지 궁금해지기 시작했습니다.
JPA가 최근에 나온 것도 아니고 오랜 시간이 지난 지금도
Join을 사용하지 않고 추가 쿼리가 발생하는 것에는 이유가 있다고 생각했습니다.
그래서 JPA에 대한 내용이 나와 있는 JSR 220(JPA 1.0), JSR 317(JPA 2.0), JSR 338(JPA 2.1)을 봤었는데,
제가 못 찾는 건지, 진짜 내용이 없는 건지 위와 관련한 내용을 찾기가 힘들었습니다.
그래서 결국 왜 JPA는 추가 쿼리를 발생하게 했는지 뇌피셜 및 여러 검색을 통해 추론할 수밖에 없어서 추론 과정을 기록하고자 합니다.
(결국 여기서 기나긴 추론이 시작됩니다... 😁)
추측 1. 테이블-엔티티 맵핑 시에 내부 구현에서 Join을 사용하기가 힘들다.
처음에는 JPA라는 거대한 산을 멀리서 바라봤을 때,
'테이블-객체 맵핑 시에 잘은 모르겠지만 Join을 사용하기가 힘들구나.' 라고 생각했습니다.
그러나 이 추측은 다음과 같은 상황으로 반론할 수 있었습니다.
- 필드로 가진 대상 엔티티의 FetchType이 EAGER(즉시로딩)이고, PK로 엔티티를 조회하는 경우
- (즉시로딩 EAGER, 지연로딩 LAZY는 아래에서 따로 다루도록 하겠습니다!)
@Entity
public class Team {
...
@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
private List<Member> members = new ArrayList<>();
...
}
---
teamRepository.findById(1L);
위와 같은 상황으로 findById의 테스트 코드를 작성하면 다음과 같습니다.
(초기 데이터 Team과 Member는 넣어 놓은 상태에서 진행했습니다.)
@Test
@DisplayName("즉시로딩으로 PK로 엔티티 조회 시 쿼리를 관찰한다.")
void inspect_query_eager_findById() {
// when & then
teamRepository.findById(1L);
}
위의 쿼리 실행 결과를 보면, Team 엔티티를 조회할 때 Member 테이블을 left join을 사용하여 한번에 가져오는 것을 알 수 있습니다.
이렇게 되고 나니, join을 제대로 사용할 수 있는데 왜 추가 쿼리로 N+1을 발생하게 설계한걸까? 더 궁금해지게 됐습니다.
✅ 추측 2. Join을 사용했을 때 비효율적인 상황이 더 많다.
JPA에서 테이블-엔티티 맵핑 시에 Join을 사용할 수 있는데 사용하지 않았다면,
효율적인 측면에서 Join 쿼리로 한 번에 가져오는 것보다 추가 쿼리를 날리는 것이 더 효율적인 상황이 많다고 판단했다고 생각합니다.
이러한 상황은 JPA에서 Fetch Type인 EAGER & LAZY 로딩과 관련이 있습니다.
따라서 먼저 EAGER & LAZY 로딩에 대해서 알아보고 다시 돌아와봅시다.
※ EAGER & LAZY 로딩 (LAZY 로딩은 N+1을 해결할 수 있을까?)
먼저, EAGER & LAZY 로딩에 대해서 알아봅시다.
JPA에서 Fetch Type인 EAGER, LAZY 로딩은 '데이터를 가져오는 시점'에 관한 설정입니다.
JPA의 Fetch Type의 공식문서 주석을 살펴보면 다음과 같이 서술되어 있습니다.
Defines strategies for fetching data from the database. The EAGER strategy is a requirement on the persistence provider runtime that data must be eagerly fetched. The LAZY strategy is a hint to the persistence provider runtime that data should be fetched lazily when it is first accessed.
이를 요약(의역)하면 다음과 같습니다.
- Fetch Type은 '데이터베이스에서 데이터를 가져오는 전략'이다.
- EAGER 전략 : 데이터베이스에서 데이터를 즉시 가져와야한다는 '요구사항'이다.
- LAZY 전략 : 데이터를 처음 사용할 때 느리게(게으르게) 가져오라는 '힌트'이다.
간단하게 보면,
EAGER는 DB에서 데이터를 즉시 가져오는 전략이고, LAZY는 데이터를 사용하는 시점에 데이터를 가져오는 전략입니다.
또, 뉘앙스가 다른 부분은 'requirement'와 'hint'입니다.
EAGER는 'requirement'로, JPA를 구현하는 구현체들이 강제로 구현해야 하는 어떤 요구사항의 의미를 가집니다.
반면 LAZY는 'hint'로, JPA는 '데이터를 사용하는 시점에 데이터를 가져와라!' 하는 힌트를 제공할 뿐 내부 구현은 제공하지 않습니다.
따라서, JPA를 구현하는 구현체들은 LAZY 전략을 사용할 때 JPA가 제공한 hint를 바탕으로
'데이터를 사용하는 시점에 데이터를 가져오는' 코드를 구현하게 되는 것입니다.
그렇다면, JPA의 구현체로 많이 사용하는 Hibernate에서는 EAGER와 LAZY 전략을 어떻게 구현하고 있을까요?
앞서 살펴본 Team-Member 엔티티에서 findAll() 메소드를 EAGER, LAZY로 변경하면서 테스트해봅시다.
(Spring Data JPA도 Hibernate를 사용하므로 앞서 그랬듯 편의상 Spring Data JPA로 진행하겠습니다.)
Hibernate - EAGER Loading(즉시 로딩)
@Entity
public class Team {
...
@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
private List<Member> members = new ArrayList<>();
...
}
@Test
@DisplayName("즉시 로딩으로 모든 팀 조회 시 쿼리를 관찰한다.")
void inspect_query_eager_findAll() {
// when & then
teamRepository.findAll();
}
Team 엔티티를 가져올 때, EAGER 로딩이기 때문에 Team의 데이터를 즉시 가져오기 위해
속한 대상 엔티티인 Member 엔티티를 완성 시키기 위해서
앞서 봤던 것처럼 Join을 수행하지 않고 추가 쿼리를 날려서 N+1 문제가 똑같이 발생하는 것을 볼 수 있습니다.
여기서 주목해야할 점은, Team 엔티티를 가져오는 시점에 즉시 Member 엔티티도 가져온다는 점입니다.
Hibernate - LAZY Loading(지연 로딩)
@Entity
public class Team {
...
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members = new ArrayList<>();
...
}
@Test
@DisplayName("지연 로딩으로 모든 팀 조회 시 쿼리를 관찰한다.")
void inspect_query_lazy_findAll() {
// when & then
teamRepository.findAll();
}
Team 엔티티를 가져올 때, LAZY 로딩이기 때문에 Member 엔티티는 즉시 불러오지 않게 됩니다.
따라서, Team 엔티티를 가져올 때 EAGER 로딩의 쿼리와는 달리 Member 엔티티를 가져오는 쿼리가 발생하지 않아서
LAZY 로딩을 사용하면 N+1 문제가 발생하지 않는 것처럼 보입니다.
앞서 설명한 LAZY 로딩을 다시 한번 살펴봅시다.
데이터를 처음 사용할 때 느리게(게으르게) 가져오라는 '힌트'
위에 따르면, Member 엔티티를 처음 사용하는 시점에 데이터를 가져올 것입니다.
그렇다면 테스트 코드 상황을 Member 엔티티를 사용하도록 다음과 같이 변경 후 쿼리를 관찰해봅시다.
@Test
@DisplayName("지연 로딩으로 모든 팀 조회 후 속한 멤버들을 사용하는 쿼리를 관찰한다.")
void inspect_query_lazy_findAll_use_members() {
// when & then
final List<Team> teams = teamRepository.findAll();
for (Team team : teams) {
final List<Member> members = team.getMembers();
for (Member member : members) {
System.out.println("member.getNickname() = " + member.getNickname());
}
}
}
간단하게 모든 팀을 조회 후에 해당 팀에 속한 멤버들의 닉네임을 출력하는 테스트 코드입니다.
실행된 쿼리를 살펴봅시다.
결과적으로는 EAGER 로딩과 마찬가지로 Member 엔티티를 가져오기 위한 추가 쿼리가 발생해서 N+1 문제가 발생합니다.
하지만 로그를 잘 살펴보면 재밌는 점이 있습니다.
EAGER 로딩처럼 한번에 추가 쿼리가 3개가 나가는 것이 아니라,
for문을 돌면서 Member의 닉네임을 출력하는, 즉 Member 엔티티가 사용되는 시점에 추가 쿼리가 발생하여
추가 쿼리 발생 후 다음 추가 쿼리 발생 전에 출력이 이루어지는 것을 볼 수 있습니다.
이렇게 데이터를 사용하는 시점에 불러오는 것이 LAZY 로딩, 지연 로딩입니다.
추가적으로, 그렇다면 Team 엔티티만 가져왔을 때(TeamRepository.findAll 시) Member 엔티티의 상태는 어떨까요?
이는 Hibernate에서 JPA의 LAZY 힌트를 사용해서 구현하는 방식과 관련이 있습니다.
결론만 말하면 해당 Member 엔티티는 실제 객체가 아닌 프록시 객체를 저장하고 이후에 사용할 때 실제 객체를 넣는 방식입니다.
(자세한 구현은 여기서는 다루지 않겠습니다.)
이제 EAGER 로딩과 LAZY 로딩의 개념에 대해서 살펴봤으니, 다시 추측으로 돌아와봅시다.
앞서 JPA에서 Join 대신 추가 쿼리를 발생하는 방향으로 설계한 이유에 대한 제 추측은 다음과 같았습니다.
추가 쿼리를 사용했을 때보다 Join을 사용했을 때 비효율적인 상황이 더 많다.
제가 생각한 '추가 쿼리를 사용했을 때보다 Join을 사용했을 때 비효율적인 상황'은 다음과 같습니다.
- 1:N 관계에서 1이 가지는 N 엔티티를 즉시 사용하지 않는 상황
- EX : Team 엔티티가 가지는 List<Member>를 즉시 사용하지 않는 상황
저는 이러한 상황에서 모든 설계가 출발했다고 생각합니다.
만약, 극단적으로 하나의 팀에 속하는 멤버가 1000억명일 때 모든 Member를 즉시 사용하지 않는다고 해봅시다.
이때 한번에 join으로 모든 1000억명의 멤버를 불러온다면 어떻게 될까요?
사실 1000억명이든 1명이든 사용하지 않는 데이터를 불러온다는 것 자체가 상당히 비효율적인 행동입니다.
그렇다면, 이러한 상황에서는 '사용 시점에 데이터를 불러오면' 좋겠죠?
이러한 이유 때문에 앞서 설명한 LAZY 로딩이 탄생하고, 사용할 시점에 데이터를 불러와야 하므로
Join 대신에 추가 쿼리로 가져오는 방법으로 설계가 되었다고 생각합니다.
이렇게 설계됨으로써 N+1 문제는 발생하지만, 위의 비효율적인 상황이 더 치명적이라고 생각한 것이겠죠?
그래서 JPA의 Spec을 살펴보면, @OneToMany와 @ManyToOne의 default Fetch Type이 다릅니다.
- @OneToMany : 기본 LAZY 로딩
- @ManyToOne : 기본 EAGER 로딩
@OneToMany는 앞서 예시로 든 '즉시 데이터를 사용하지 않는 상황'이 발생할 확률이 비교적 크기 때문에
LAZY 로딩을 기본으로 하여 사용할 시점에 데이터를 불러와서 효율을 높이도록 했음을 알 수 있습니다.
반면 @ManyToOne은 EAGER 로딩이 기본입니다.
이는 @OneToMany의 컬렉션 객체를 가져오는 것과 비교해서 1개의 객체만 가져오면 되고,
@OneToMany의 컬렉션 객체는 모든 요소가 사용되지 않을 상황이 많은 반면
@ManyToOne의 1 엔티티는 N 엔티티를 사용할 때 같이 사용하는 상황이 많다고 판단을 했다고 추측됩니다.
결론
결론적으로 '왜 JPA는 Join 대신 추가 쿼리를 발생하게 했는지'에 대한 제가 내린 추측의 결론은 다음과 같습니다.
많은 데이터를 가져오는데 즉시 사용하지 않는 경우에 Join 쿼리 사용 시 비효율적인 상황이 발생하고,
이러한 상황을 방지하기 위해 데이터를 사용하는 시점에 불러와야 하는 구현(LAZY 로딩)을 추가해야했다.
그래서 LAZY 로딩을 추가하고 기본적으로는 Join 대신 추가 조회 쿼리를 사용하는 방향으로 설계했다.
3. N+1 문제의 해결 방법
앞서 원인 추측 및 분석의 호흡이 상당히 길었네요,, ㅎㅎ...
이제 N+1 문제의 해결 방법을 알아봅시다.
JPA의 구현체인 Hibernate에서는 N+1 문제의 해결책을 다음과 같이 제시합니다.
(2024년 5월 기준 lastest stable 버전인 Hibernate 6.4 version docs)
Hibernate provides several strategies for efficiently fetching associations and avoiding N+1 selects:
1. outer join fetching—where an association is fetched using a left outer join
2. batch fetching—where an association is fetched using a subsequent select with a batch of primary keys
3. subselect fetching—where an association is fetched using a subsequent select with keys re-queried in a subselect.
3-1. outer join fetching
Hibernate에서는 3가지 방법 중에서 이 방법을 사용하는 것을 가장 권장하고 있습니다.
Outer join fetching is usually the best way to fetch associations, and it’s what we use most of the time.
Outer join fetching을 사용하는 흔한 방법에는 크게 2가지 방법이 있습니다.
(문서에는 총 4가지 방법이 있지만, 흔하게 사용하는 2가지 방법만 설명하도록 하겠습니다.)
- 'fetch join' 문법으로 JPQL 작성
- EntityGraph 사용
1. fetch join
fetch join으로 불리는 이 방법을 사용해서 N+1 문제를 해결해봅시다.
JPA를 사용하면 JPQL을 사용하여 fetch join을 사용할 수 있습니다.
계속 예시로 들었던 Team-Member 예시에서 JPQL로 fetch join을 사용하면 다음과 같습니다.
outer join으로 명시되어 있지만, 기본 동작은 inner join으로 동작하고 outer join으로 동작하려면 left join을 명시해야합니다.
--INNER JOIN
select t from Team t join fetch t.members
--OUTER JOIN
select t from Team t left join fetch t.members
Spring Data JPA를 사용하면, JPQL을 사용하기 위해 @Query를 통해 다음과 같이 작성할 수 있습니다.
public interface TeamRepository extends JpaRepository<Team, Long> {
@Query("select t from Team t join fetch t.members")
List<Team> findAllWithInnerFetchJoin();
@Query("select t from Team t left join fetch t.members")
List<Team> findAllWithOuterFetchJoin();
}
이렇게 fetch join을 사용해서 테스트 코드를 작성하고, 쿼리 실행 결과를 살펴봅시다.
@Test
@DisplayName("페치 조인으로 모든 팀 조회 시 쿼리를 관찰한다.")
void inspect_query_fetch_join_findAll() {
teamRepository.findAllWithInnerFetchJoin();
teamRepository.findAllWithOuterFetchJoin();
}
모두 Team 엔티티를 가져올 때 Member를 join해서 가져오기 때문에 Member를 가져오는 추가 조회 쿼리가 발생하지 않습니다.
따라서 N+1 문제가 해결된 것을 볼 수 있습니다.
차이점은 사진처럼 inner join & outer left join에 차이가 있습니다.
※ Hibernate의 fetch join Tip
Hibernate에서는 fetch join에 대해 다음과 같이 설명하고 있습니다.
Unfortunately, by its very nature, join fetching simply can’t be lazy. So to make use of join fetching, we must plan ahead.
요약하면, 페치 조인은 근본적으로 Lazy하게 데이터를 가져오지 않으므로 페치 조인을 사용할 때는 계획적으로 사용해야한다는 뜻입니다.
LAZY 로딩 + 페치 조인을 사용한다고 하면,
결국 역설적으로 Team 엔티티를 조회할 때 EAGER하게 모든 Member를 join해서 가져오기 때문이죠.
이때, 그럼 'LAZY 로딩의 의미가 없지 않나?' 라고 생각할 수 있습니다.
Hibernate에서도 역설적으로 다음과 같이 언급하고 있습니다.
Our general advice is: Avoid the use of lazy fetching, which is often the source of N+1 selects.
Now, we’re not saying that associations should be mapped for eager fetching by default!
Most associations should be mapped for lazy fetching by default.
페치 조인을 사용할 때 일반적으로 'N+1이 발생할 수 있는 LAZY 로딩'을 피하라고 하면서
그렇다고 EAGER 로딩을 사용하라는 건 아니고, LAZY 로딩을 기본으로 사용하라고 언급하고 있습니다.
It sounds as if this tip is in contradiction to the previous one, but it’s not. It’s saying that you must explicitly specify eager fetching for associations precisely when and where they are needed.
또 위처럼 역설적인 설명의 해명도 하고 있는데, 이 말을 의역하면 다음과 같습니다.
LAZY 로딩을 기본으로 사용하되, 필요한 곳에 fetch join을 사용하여 EAGER하게 데이터를 가져와라!
위의 말이 결국 Hibernate에서 fetch join을 사용할 때 전하는 Tip인 것 같습니다.
2. EntityGraph 사용
다음으로 EntityGraph를 사용해서 N+1 문제를 해결해봅시다.
EntityGraph는 Hint와 비슷한 느낌으로, 가져올 엔티티를 지정한 EntityGraph를 생성하고
해당 EntityGraph를 조회하는 메소드에 파라미터로 넘겨서 조회 시에 함께 가져오도록 동작합니다.
JPA에서 EntityGraph를 사용하면 다음과 같이 사용할 수 있습니다.
EntityGraph<Team> entityGraph = em.createEntityGraph(Team.class);
entityGraph.addSubgraph("members");
em.find(Team.class, teamId, Map.of(SpecHints.HINT_SPEC_LOAD_GRAPH, entityGraph));
Spring Data JPA를 사용하면 @EntityGraph를 통해 다음과 같이 사용할 수 있습니다.
public interface TeamRepository extends JpaRepository<Team, Long> {
@Query("select t from Team t")
@EntityGraph(attributePaths = "members")
List<Team> findAllWithEntityGraph();
}
이렇게 @EntityGraph를 사용해서 테스트 코드를 작성하고, 쿼리 실행 결과를 살펴봅시다.
@Test
@DisplayName("EntityGraph로 모든 팀 조회 시 쿼리를 관찰한다.")
void inspect_query_EntityGraph_findAll() {
teamRepository.findAllWithEntityGraph();
}
모두 Team 엔티티를 가져올 때 Member를 left join해서 가져오기 때문에 Member를 가져오는 추가 조회 쿼리가 발생하지 않습니다.
따라서 마찬가지로 N+1 문제가 해결된 것을 볼 수 있습니다.
3-2. batch & subselect fetching
batch fetching과 subselect fetching은 모두 N+1 문제에서 발생하는 추가 조회 쿼리를 없애는 방향이 아니라
추가 조회 쿼리를 1개의 쿼리로 줄이는 방향으로 문제를 해결합니다.
따라서 추가 조회 쿼리를 없애는 것은 아니기 때문에 N+1 문제를 완벽하게 해결한다고 볼 수는 없을 것 같습니다.
단, 페치 조인 시에 거대한 카사디안 곱과 거대한 집합이 생성되는 경우에 최상의 솔루션이 될 수 있습니다.
1. batch fetching (@BatchSize)
batch fetching은 설정을 통해 배치 사이즈를 조절하여 추가 조회 쿼리를 줄이는 해결 방법입니다.
이전 예시에서 Member - Team에 Team 3개가 존재하여 3개의 추가 조회 쿼리가 발생했습니다.
이를 @BatchSize를 통해 1번의 쿼리로 줄일 수 있습니다.
@Entity
public class Team {
...
@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
@BatchSize(size = 3)
private List<Member> members = new ArrayList<>();
...
}
이렇게 조회되는 엔티티 위에 @BatchSize를 추가하고 Member 엔티티가 조회될 때 IN 절을 통해 한번에 조회할 수 있습니다.
findAll 시 실행되는 쿼리 결과는 다음과 같습니다.
두 번째의 추가 조회 쿼리가 BatchSize를 설정함으로써 IN절로 하나의 쿼리로 날아가는 것을 확인할 수 있습니다.
추가로, @BatchSize로 타겟을 지정하여 설정하지 않고 전역적으로 설정 파일에서 다음과 같이 지정할 수도 있습니다.
- application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 3
2. subselect fetching (@BatchSize)
subselect fetching은 설정을 통해 추가 조회 쿼리를 서브 쿼리로 줄이는 해결 방법입니다.
이전 예시에서 Member - Team에 Team 3개가 존재하여 3개의 추가 조회 쿼리가 발생했습니다.
이를 subselect fetching을 통해 1번의 쿼리로 줄일 수 있습니다.
@Entity
public class Team {
...
@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
@Fetch(value = FetchMode.SUBSELECT)
private List<Member> members = new ArrayList<>();
...
}
이렇게 가져올 Member 컬렉션 엔티티 위에 @Fetch와 FetchMode.SUBSELECT를 사용하면 됩니다.
이렇게 되면 Member 엔티티가 조회될 때 IN절과 서브 쿼리를 사용하여 한번에 조회할 수 있습니다.
findAll 시 실행되는 쿼리 결과는 다음과 같습니다.
※ batch & subselect fetching의 중요한 공통점
Hibernate에서는 batch & subselect fetching의 중요한 공통점으로 다음과 같이 언급하고 있습니다.
they can be performed lazily
바로 LAZY하게 동작한다는 점입니다.
join fetching(페치 조인)을 사용하게 되면 LAZY하게 동작하지 않기 때문에 즉시 Join하여 데이터를 가져왔습니다.
그래서 무조건적으로 사용하면 안되고, 상황에 따라 적용해야하는 유연성이 필요했습니다.
그러나 batch & subselect fetching는 LAZY하게 동작하기 때문에
LAZY 로딩에서 선언하기만 하면 상황을 고려할 필요가 없어서 편리하다는 장점이 있습니다.
하지만, Hibernate는 이에 대해서 다음과 같이 언급합니다.
It turns out that this is a convenience we’re going to have to surrender.
바로, 이러한 편리함은 우리가 포기해야한다는 의미를 담고 있습니다.
즉, batch & subselect fetching이 편리하더라도 join fetching(페치 조인)을 권장한다는 의미입니다.
이렇게 N+1 문제의 여러 해결방법까지 알아보고 글을 마치도록 하겠습니다.
4. 마치며
드디어 기나긴 JPA N+1 문제에 대한 저의 고찰 글이 끝나게 되었습니다.
N+1 문제에 대해서 단순하게 발생 상황, 해결 방법들만 봤었을 때는 깊게 생각을 안했었다가
최근에 다시 깊게 생각해볼 계기가 있어서 깊게 생각해보게 되었습니다.
생각의 시작은 위의 2번 목차에서 언급한 다음 생각에서 출발하게 되었습니다.
'조회하는 엔티티가 가진 대상 엔티티를 맵핑할 때 추가 조회 쿼리를 날리기 때문에 N+1이 발생한다고?'
'그럼 조회하는 엔티티를 가져올 때 대상 엔티티의 테이블을 Join해서 가져오면 추가 쿼리가 발생 안하는 거 아닌가?'
그래서 JPA에서 왜 그렇게 설계했는지 이유에 대해 추론하게 되었고
결론적으로는 다음과 같은 결론을 도출했습니다.
많은 데이터를 가져오는데 즉시 사용하지 않는 경우에 Join 쿼리 사용 시 비효율적인 상황이 발생하고,
이러한 상황을 방지하기 위해 데이터를 사용하는 시점에 불러와야 하는 구현(LAZY 로딩)을 추가해야했다.
그래서 LAZY 로딩을 추가하고 기본적으로는 Join 대신 추가 조회 쿼리를 사용하는 방향으로 설계했다.
글을 끝내는 지금도 사실 정확한 JPA 공식문서에 언급된 것이 아니기 때문에 찝찝한 감이 있지만,
결국 저의 개인적인 추론의 결론이니 나름 만족하게 됐습니다.
덕분에 JPA의 공식문서 및 JPA를 구현한 Hibernate의 공식문서에서 관련된 부분을 읽고 많이 공부하게 되었습니다.
Reference
'Spring > JPA' 카테고리의 다른 글
[JPA] JPA, Spring Data JPA의 내부 동작 원리 알아보기 (0) | 2024.08.05 |
---|---|
테스트 시 @ElementCollection 테이블 동적으로 가져오기(테스트 데이터 초기화) (0) | 2024.05.09 |
[JPA] JPA 1:N 관계에서 연관관계 주인을 1 대신 N에 두는 이유 (4) | 2023.08.06 |
[JPA] @JoinColumn 파헤치기 (feat. JPA 연관관계별 사용) (6) | 2023.07.09 |