🙏내용에 대한 피드백은 언제나 환영입니다!!🙏
앞 글에서 즉시로딩(EAGER), 지연로딩(LAZY)에 대해서 알아보았다.
이제, 이와 관련된 N+1문제에 대해서 알아보겠다.
아래 실행 코드는 이 글에 적힌 코드이다.
지연로딩(LAZY)을 사용하더라도 해결이 되지 않는 것이 있다.
코드와 결과부터 먼저 보겠다.
for(int i = 0; i < 10; i++) {
// 10개를 DB에 저장. 1개의 팀당 1명의 멤버 저장.
Team team = new Team("팀" + i);
teamRepository.save(team);
Member member = new Member("멤버" + i);
member.setTeam(team);
memberRepository.save(member);
}
먼저 위와 같이 1개의 Team에 1명 Member를 저장. 총 10번을 하였다.
그리고,
List<Member> members= memberRepository.findAll();
for( Member member : members) {
System.out.println("<< " + member.getTeam().getName() + "에 접근 >>");
}
위와 같은 코드를 작성하여 쿼리를 날려보았다.
Hibernate: // memberRepository.findAll()에 대한 쿼리.
select
m1_0.id,
m1_0.name,
m1_0.team_id
from
member m1_0
Hibernate: // 여기부터는 Team 객체에 접근하는 쿼리.
select
t1_0.id,
t1_0.name
from
team t1_0
where
t1_0.id=?
<< 팀0에 접근 >>
.
. (총 10번)
.
Hibernate:
select
t1_0.id,
t1_0.name
from
team t1_0
where
t1_0.id=?
<< 팀9에 접근 >>
위와 같은 결과가 나왔다. 결과가 길어 중간은 삭제하였지만, 10번의 Team 객체를 불러오는 쿼리가 날린 것이다.
최초에 Member 목록을 가져오는 것 1번. member.getTeam().getName(); 코드로 인해 Team에 접근하는 쿼리로 인하여 10번. 총 11번의 쿼리가 발생하였다.
이것이 데이터의 수 N개인. N+1문제이다.
만약 데이터의 수가 100개라면 101 쿼리가 발생할 것이다.
fetch join은 Entity를 조회 할 때, 지연로딩(LAZY) 매핑되어 있는 것에 join쿼리를 발생시켜 한번에 조회할 수 있는 기능이다.
먼저, Paging에서 문제가 발생한다.
SQL LIMIT/OFFSET은 조인된 row 단위로 잘리기 때문에
→ 부모 엔티티(Post)가 완전히 로딩되지 않은 불완전한 상태가 될 수 있음.
Hibernate는 이 정합성 문제를 막기 위해
LIMIT/OFFSET을 DB에 적용하지 않고 전부 로딩한 뒤
메모리에서 잘라서 반환함.
→ 결과적으로 모든 데이터를 읽어온 뒤 메모리 페이징이 일어나고,
데이터가 많으면 OOM(Out Of Memory) 발생 위험이 큼.
예시
SQL에서 LIMIT/OFFSET이 적용되는 경우
fetch join 결과(총 16행):
| post_id | comment_id |
|---|---|
| 1 | c1 |
| 1 | c2 |
| 1 | c3 |
| 1 | c4 |
| 1 | c5 |
| 2 | c6 |
| 2 | c7 |
| 2 | c8 |
| 3 | c9 |
| 3 | c10 |
| ... | ... |
LIMIT 10 OFFSET 0을 적용하면 DB는 상위 10행만 반환 →
→ Post3은 댓글이 8개인데 2개만 로딩된 불완전한 객체가 되어버림.
Hibernate는 이 오류를 피하려고 LIMIT/OFFSET을 DB에 안 걸고, 전부 로딩 후 메모리에서 잘라냄 → 이 때문에 대량 데이터에서는 OOM 위험이 생기는 것.
다음으로, 두 개이상의 컬렉션을 fetch join을 문제가 발생한다
하나의 부모 엔티티에 일대다(@OneToMany) 컬렉션이 두 개 이상 있으면 Fetch Join 시 카테시안 곱(Cartesian Product)이 발생한다.
→ 데이터가 불필요하게 폭증하고, Hibernate는 안전하게 매핑할 수 없어 MultipleBagFetchException을 발생시킨다.
List(bag) 타입 컬렉션이 동시에 2개 이상 fetch join 되면 금지.
(Set으로 바꾸면 예외는 피할 수 있지만, 행 폭증 문제는 그대로 - 디비에서 들고오는건 그대로)
예시
fetch join 결과(총 6행):
| team_id | member_id | project_id |
|---|---|---|
| 1 | M1 | P1 |
| 1 | M1 | P2 |
| 1 | M1 | P3 |
| 1 | M2 | P1 |
| 1 | M2 | P2 |
| 1 | M2 | P3 |
→ 실제로는 Team1에 Member 2명, Project 3개가 있어야 하는데, 조인 결과만 보면 Members 6개, Projects 6개처럼 중복된 데이터가 생긴다.
Hibernate는 이런 경우 안전하게 컬렉션을 복원하기 어렵기 때문에 여러 bag(List) 컬렉션 동시 Fetch Join을 금지하고, 예외를 던진다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select m from Member m join fetch m.team")
List<Member> findAll();
}
fetch join을 사용하려면 위와 같이 Repository에 설정해줘야 한다.
이것을 실행한 후 결과는 아래와 같다.
Hibernate:
select
m1_0.id,
m1_0.name,
m1_0.team_id,
t1_0.id,
t1_0.name
from
member m1_0
join
team t1_0
on t1_0.id=m1_0.team_id
팀0에 접근
팀1에 접근
팀2에 접근
팀3에 접근
팀4에 접근
팀5에 접근
팀6에 접근
팀7에 접근
팀8에 접근
팀9에 접근
결과에서 볼 수 있듯이, 매핑되어 있던 Team Entity또한 한번에 실행되는 것을 볼 수 있다.
지연로딩(LAZY)시 프록시 객체를 조회할 때, 한 번에 조회할 수 있도록 where ~ in절로 묶어서 조회하는 옵션이다.
전역 구간에 적용시키고 싶다면,
application.yml은 아래와 같이,
spring:
jpa:
properties:
default_batch_fetch_size: 10
application.properties에는 아래와 같이 적용시키면 된다.
spring.jpa.properties.hibernate.default_batch_fetch_size=10
만약, 전역구간에 적용시키는 것이 아닌, 특정 구간에만 적용시키거나, Batch Size를 다르게 하고 싶다면 @BatchSize 어노테이션을 사용하면 된다.
위에 예제를 기준으로 보면
List<Member> members= memberRepository.findAll();
for( Member member : members) {
System.out.println("<< " + member.getTeam().getName() + "에 접근 >>");
}
Member을 통해 Team에 접근하므로,
@BatchSize(size = 10)
@Entity
public class Team{
...
}
만약, Team을 통해 Member을 조회한다면,
@BatchSize(size = 10)
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members = new ArrayList<>();
이렇게 설정하면 된다.
위 코드에서 보면 10이라고 적힌 것을 볼 수 있다. 이것은 Batch Size로, 얼마나 묶어서 조회를 할 것인지를 뜻한다. 숫자가 너무 크다면 DB에서 부담을 느끼기에 적절한 숫자를 택하는 것이 좋다. (보통 100 ~ 1000 으로 적용한다고 한다.)
결과를 쉽게 보기 위해 Batch Size를 10으로 하겠다.
Hibernate:
select
m1_0.id,
m1_0.name,
m1_0.team_id
from
member m1_0
Hibernate:
select
t1_0.id,
t1_0.name
from
team t1_0
where
t1_0.id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
팀0에 접근
팀1에 접근
팀2에 접근
팀3에 접근
팀4에 접근
팀5에 접근
팀6에 접근
팀7에 접근
팀8에 접근
팀9에 접근
위 결과에서
where
t1_0.id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
이것을 보면 알 수 있듯이 Batch Size를 10으로 했기 때문에 10개를 묶어서 한 번에 조회를 하였다. 만약에, Batch Size를 5로한다면 아래와 같은 결과가 나올 것이다.
Hibernate:
select
m1_0.id,
m1_0.name,
m1_0.team_id
from
member m1_0
Hibernate:
select
t1_0.id,
t1_0.name
from
team t1_0
where
t1_0.id in (?, ?, ?, ?, ?)
팀0에 접근
팀1에 접근
팀2에 접근
팀3에 접근
팀4에 접근
Hibernate:
select
t1_0.id,
t1_0.name
from
team t1_0
where
t1_0.id in (?, ?, ?, ?, ?)
팀5에 접근
팀6에 접근
팀7에 접근
팀8에 접근
팀9에 접근
fecth join의 장점
1. 쿼리를 날리는 갯수가 적다.
fecth join의 단점
1. 두 개이상의 컬렉션을 fetch join을 할 수 없다.
2. Paging문제 발생.
BatchSize의 장점
1. fetch의 단점들을 해결할 수 있다.
2. 데이터 전송량 관점에서 유리하다. (아래 참고)
BatchSize의 단점
1. BatchSize의 크기보다 큰 데이터가 들어있을 경우, fetch join보다 더 많은 쿼리를 날린다.
데이터 전송량 관점에서는 BatchSize가 유리하다. Fetch Join은 Join을 하고 나서 가져오기 때문에 중복 데이터를 많이 가져오기 때문이다.
프로그램을 만들 땐 성능이 중요하다.
처음에 공부를 할 땐, 성능을 생각하지 못하고 쿼리를 날리는 과정을 보지 않았다.
이 글을 작성하는 과정을 거치며 과정과 결과를 보며 성능에 대한 중요성을 다시 한 번 깨달은 것이다.
이 후에 개인 프로젝트를 통해서 N+1 문제가 발생하지 않도록 코드를 짜서 글을 작성해보겠다.