N+1 문제와 해결 방법
JPA를 이용해 별도의 옵션 없이 엔티티를 조회할 경우, 엔티티에 연결되어 있는 연관관계들이 함께 조회된다.
함께 조회되는 시점에 부모 엔티티를 조회 하는 쿼리(1) + 연결되어 있는 연관관계 엔티티들에 대한 조회 쿼리(N)가 DB 요청으로 날아가게 된다.
예를들어 4개의 연관관계를 가지고 있는 엔티티 100개를 조회할 경우 100 + 400, 총 500회의 조회 쿼리가 날아가게 됨으로 성능에 큰 영향을 미치게된다.
이런 문제는 완벽하지는 않으나 Fetch Join, Entity Graph, Batch_Size를 통해 해결 할 수 있다.
보통 연관관계 엔티티들에 대해 FetchType을 통해 연관관계 엔티티를 즉시 조회(Eager)할지, 지연 조회(Lazy)할 지 정할 수 있다.
여기서 한가지 의문점이 생겼다.
Fetch Join은 부모 엔티티 조회 시점에 연관관계의 엔티티들을 함께 조회 하는 방식이며, 2개 이상의 1:N관계에 대해서는 Fetch Join을 사용할 수 없기에 일반적으로 Fetch Join + Batch Size 설정을 통해 사용하게 된다.
과연 Lazy Loading을 통해 부모 엔티티 조회 후 연관관계를 조회하는 것과
Fetch Join + Batch Size를 통한 조회의 성능 차이가 얼마나 클지 의문이 들어 직접 테스트 해보게 되었다.
서버와 DB는 모두 로컬환경에서 진행
[서버, DB가 로컬이 아닌 인터넷상 라우팅을 통해 통신해야 하는 원격지에 위치해 있다면 결과의 차이가 더 클 것으로 예상]
싱글 스레드 환경에서 진행 되었으며, 서버-DB통신 간에 데이터의 크기는 굉장히 작기 때문에 다중 요청의 멀티스레드 환경, 규모가 큰 데이터 환경에서는 테스트의 결과가 달라질 수 있다고 생각 된다.
테스트 환경
Language - Java 11
FrameWork - Spring Boot 2.7.9
ORM - Spring Data JPA + QueryDsl
DB - MySql
public void test() {
var testCount = 1000;
long start = System.nanoTime();
for(int i=0 ; i<testCount; i++) {
testService.test();
}
long end = System.nanoTime();
var result = ((double)end-(double)start)/1000000000;
System.out.println();
}
private final OwnerRepository ownerRepository;
public void test(){
var result = ownerRepository.findAllt();
for(Owner owner: result) {
var test1 = owner.getDogList();
System.out.println(test1.get(0));
var test2 = owner.getDuckList();
System.out.println(test2.get(0));
var test3 = owner.getCatList();
System.out.println(test3.get(0));
}
System.out.println();
}
Lazy Loading 테스트 조건
@Entity
@Getter
public class Owner {
@Id
long id;
@OneToMany(fetch = FetchType.LAZY, mappedBy = "owner")
private List<Cat> catList;
@OneToMany(fetch = FetchType.LAZY, mappedBy = "owner")
private List<Dog> dogList;
@OneToMany(fetch = FetchType.LAZY, mappedBy = "owner")
private List<Duck> duckList;
}
Fetch Join + Batch Size 테스트 조건
@Entity
@Getter
public class Owner {
@Id
long id;
@OneToMany(fetch = FetchType.LAZY, mappedBy = "owner")
private List<Cat> catList;
@OneToMany(fetch = FetchType.LAZY, mappedBy = "owner")
@BatchSize(size=100)
private List<Dog> dogList;
@OneToMany(fetch = FetchType.LAZY, mappedBy = "owner")
@BatchSize(size=100)
private List<Duck> duckList;
}
연관관계 엔티티
@Entity
public class Cat {
@Id
private int id;
@ManyToOne
@JoinColumn(name = "owner_id")
private Owner owner;
}
@Entity
public class Dog {
@Id
private int id;
@ManyToOne
@JoinColumn(name = "owner_id")
private Owner owner;
}
@Entity
public class Duck {
@Id
private int id;
@ManyToOne
@JoinColumn(name = "owner_id")
private Owner owner;
}
// Lazy 조건
@Override
public List<Owner> findAllt() {
return memberJpaQueryFactory.selectFrom(owner)
.from(owner)
.leftJoin(owner.catList, cat)
.leftJoin(owner.dogList, dog)
.leftJoin(owner.duckList, duck)
.fetch();
}
// Fetch, Batch_Size 조건
@Override
public List<Owner> findAllt() {
return memberJpaQueryFactory.selectFrom(owner)
.from(owner)
.leftJoin(owner.catList, cat).fetchJoin()
.leftJoin(owner.dogList, dog)
.leftJoin(owner.duckList, duck)
.fetch();
}
insert into owner(id) values(1);
insert into owner(id) values(2);
insert into owner(id) values(3);
insert into owner(id) values(4);
insert into owner(id) values(5);
insert into dog(id, owner_id) values (1, 1);
insert into dog(id, owner_id) values (2, 2);
insert into dog(id, owner_id) values (3, 3);
insert into dog(id, owner_id) values (4, 4);
insert into dog(id, owner_id) values (5, 5);
insert into dog(id, owner_id) values (6, 1);
insert into dog(id, owner_id) values (7, 2);
insert into dog(id, owner_id) values (8, 3);
insert into cat(id, owner_id) values (1, 1);
insert into cat(id, owner_id) values (2, 2);
insert into cat(id, owner_id) values (3, 3);
insert into cat(id, owner_id) values (4, 4);
insert into cat(id, owner_id) values (5, 5);
insert into cat(id, owner_id) values (6, 1);
insert into cat(id, owner_id) values (7, 2);
insert into cat(id, owner_id) values (8, 3);
insert into duck(id, owner_id) values (1, 1);
insert into duck(id, owner_id) values (2, 2);
insert into duck(id, owner_id) values (3, 3);
insert into duck(id, owner_id) values (4, 4);
insert into duck(id, owner_id) values (5, 5);
insert into duck(id, owner_id) values (6, 1);
insert into duck(id, owner_id) values (7, 2);
insert into duck(id, owner_id) values (8, 3);
단위(sec)
Fetch Join + Batch Size
1 - 36.95
2 - 39.22
3- 37.7
Lazy 이후 조회
1 - 66.56
2 - 63.13
3 - 68.3
4 - 64.19
원격지의 DB와의 통신이 아닌 로컬에서만 진행했음에도 굉장히 큰 속도 차이를 확인할 수 있다.