일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- JPA
- Hibernate
- batch insert
- spring
- Convention
- 성능테스트
- MySQLTransactionRollbackException
- @Transaction(readOnly=true)
- jdbc
- AWS
- N + 1
- 이펙티브 자바
- Cannotacquirelockexception
- 정적 팩터리 메서드
- mockito
- @controller
- awspring
- spring-cloud-starter-aws
- injellij
- fetch join
- Cache
- oauth2.0
- ngrinder
- Git
- 데드락
- 동시성
- assert
- OIDC
- @RequestMapping
- Batch
- Today
- Total
정리정리
JPA N + 1 문제 본문
이번 포스팅에서는 JPA와 같은 ORM에서 중요한 문제인 N + 1에 관해 알아보고, JPA에서 이를 해결하는 방법에 대해 포스팅을 해보겠습니다.
N + 1 이란?
우선 N + 1 문제란, 어떤 엔티티를 조회할 때, 이와 연관관계가 있는 다른 엔티티를 조회하는 쿼리가 추가로 발생하는 현상을 말합니다. 이때 만약 처음 엔티티를 조회하는 쿼리의 결과가 N개라면, 각 N개의 엔티티와 연관관계를 가진 엔티티를 가져오기 위한 N번의 쿼리가 발생하기 때문에 N + 1 문제라고 불리는 것 같습니다.
간단한 예시로 다음과 같이 집사와 강아지가 일대다 관계에 놓여있다고 가정을 해보겠습니다.
이런 식으로 데이터가 있다고 가정을 했을 때, 집사를 조회하는 쿼리를 작성하면 다음과 같은 결과가 생기게 됩니다.
#주인 3명 조회
SELECT * FROM 주인;
#주인 3명에 대한 추가 쿼리
SELECT * FROM 강아지 WHERE 주인=주인1;
SELECT * FROM 강아지 WHERE 주인=주인2;
...
SELECT * FROM 강아지 WHERE 주인=주인9;
SELECT * FROM 강아지 WHERE 주인=주인10;
이처럼 집사의 수가 100이면 총 쿼리 수는 101, 10000이면 10001, N이면 N + 1개가 생기는 문제를 N + 1 문제라고 합니다.
JPA N + 1 문제
N + 1에 관해 간단히 알아보았으니 실제로 JPA에서 확인을 해보겠습니다.
위의 예제와 동일하게 다음과 같이 코드를 작성했습니다.
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Owner {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "owner", cascade = CascadeType.PERSIST)
private final List<Dog> dogs = new ArrayList<>();
public Owner(String name) {
this.name = name;
}
public void addDog(Dog dog) {
this.dogs.add(dog);
dog.mappingOwner(this);
}
}
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Dog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Owner owner;
private String name;
public Dog(String name) {
this.name = name;
}
public void mappingOwner(Owner owner) {
this.owner = owner;
}
}
@Service
@RequiredArgsConstructor
public class OwnerService {
private final OwnerRepository ownerRepository;
@Transactional(readOnly = true)
public List<String> findAllOwnerDogs() {
List<Owner> owners = ownerRepository.findAll();
return owners.stream()
.map(Owner::getDogs)
.flatMap(Collection::stream)
.map(Dog::getName)
.collect(Collectors.toList());
}
}
@SpringBootTest
class OwnerServiceTest {
@Autowired
OwnerService ownerService;
@Autowired
OwnerRepository ownerRepository;
@BeforeEach
void init() {
for (int i = 1; i <= 20; i += 2) {
Owner owner = new Owner("주인" + (i / 2));
owner.addDog(new Dog("강아지" + i));
owner.addDog(new Dog("강아지" + (i + 1)));
ownerRepository.save(owner);
}
}
@Test
void findAllOwnerDogsTest() throws Exception {
List<String> dogNames = ownerService.findAllOwnerDogs();
}
}
코드는 앞선 예시와 같이 다음처럼 세팅을 해놨습니다.
- Owner - Dog 일대다 양방향
- 10개의 Owner 엔티티는 각각 2개의 Dog 엔티티와 연관관계를 맺고 있음
그리고 테스트를 실행해 본 결과는 다음과 같습니다.
위에서 예시로 들었던 것처럼 (Owner를 조회하는 쿼리 1) + (Dog를 조회하는 쿼리 10) = 11로 총 11번의 쿼리가 실행된 것을 볼 수 있습니다.
해결책
JPA에서는 이런 N + 1 문제를 해결하기 위해 fetch join, @entityGraph, batch 설정 등이 존재합니다. 각각의 경우를 한 번 알아보겠습니다.
fetch join
가장 대표적인 방법으로 JPQL에서 지원하는 fetch join이 있습니다. 사용 방법은 아래의 예제처럼 참조하려는 엔티티 앞에 join fetch를 붙인 JPQL 쿼리를 작성해 주시면 됩니다.
@Query("select o from Owner o join fetch o.dogs")
List<Owner> findAllJoinFetchDog();
format_sql 옵션을 켜고 테스트를 다시 돌려보면 이처럼 inner join을 통해 하나의 쿼리로 필요한 모든 정보를 다 가져오는 것을 볼 수 있습니다.
fetch join은 가장 많이 사용되는 방법 중 하나로 알려져 있지만, 이에도 당연히 몇가지 단점이 존재합니다.
우선, fetch join을 사용할 때마다 그에 따른 쿼리문을 추가해야 합니다. 그리고 이렇게 쿼리가 늘어남에 따라 연관관계에 있는 엔티티가 어떤 쿼리에서는 EAGER로, 다른 쿼리에서는 LAZY로 가져오게 되면 어떤 쿼리를 선택할지에 대한 복잡성만 더 높아질 수도 있다고 생각이 듭니다.
@EntityGraph
두 번째 방법으로는 Spring Data JPA에서 제공하는 EntityGraph라는 어노테이션이 있습니다.
@EntityGraph(attributePaths = {"dogs"})
@Query("select o from Owner o")
List<Owner> findAllEntityGraph();
기본 쿼리에 @EntityGraph 어노테이션을 붙이고, 추가로 가져올 연관관계에 있는 엔티티의 어트리뷰트 이름을 적어주면 됩니다.
이를 호출하면 다음과 같은 쿼리를 얻을 수 있습니다.
fetch join과의 차이점이라면 fetch join은 inner join, @EntityGraph는 left outer join을 사용한다는 차이점이 있습니다.
fetch join, @EntityGraph 주의점 1
두 해결 방식 모두 주의해야 할 점이 있습니다. 바로 쿼리가 카테시안 곱(Cartesian Product)으로 되어, 지금 같은 경우 N x M의 결과가 나온다는 점입니다. 그림을 통해 쉽게 설명을 드려보겠습니다.
다대일의 경우에는 해당 쿼리가 상관없지만, 지금처럼 일대다의 상황에서 일을 중심으로 한 쿼리를 작성하게 되면 그림의 오른쪽 결과와 같이 집사의 수가 강아지의 수만큼 복제가 됩니다.
JPA에서도 별반 다르지 않습니다.
앞선 fetch join 쿼리를 통해 조회한 결과입니다. 10명이었던 집사의 수가 강아지 수만큼 중복이 생겨 20명이 된 것을 볼 수 있습니다. 이렇게 되면 쿼리를 통한 페이징 처리는 불가능할 뿐만 아니라 어떤 작업을 하더라도 애플리케이션 쪽에서 재가공을 거쳐야 하는 문제가 발생합니다.
그래서 이를 해결하기 위해 JPQL에서는 distinct라는 기능을 제공합니다. 단어에서도 알 수 있듯이 엔티티의 중복을 제거해 주는 역할을 합니다. 아래처럼 select 뒤에 붙여 사용할 수 있습니다.
@Query("select distinct o from Owner o join fetch o.dogs")
List<Owner> findAllDistinctJoinFetchDog();
이런 식으로 원하는 결과를 얻을 수 있습니다.
다만 여기서도 주의해야 할 점이 있습니다. distinct를 통해 쿼리의 결과로 10개의 데이터를 가져온 것 같지만 실제로는 distinct가 없는 쿼리처럼 해당 쿼리의 결과도 DB에서 20개의 데이터를 가져온다는 것입니다.
이는 SQL의 distinct 문법을 생각하면 되는데, distinct는 뒤에 붙은 하나의 칼럼에 대해서만 중복을 제거하는 것이 아닌, select 절에 있는 모든 칼럼이 같은 경우에만 동일한 데이터로 취급을 해서 제거를 하기 때문에 실제 쿼리 결과는 이전과 같게 되는 것입니다. 다만 distinct를 붙임으로써, JPA에서 중복이 되는 엔티티를 제거해 준다는 차이가 존재합니다.
해당 문제는 Hibernate5까지만 발생하는 문제이며, Hibernate6을 사용하는 Spring Boot 3 이후로는 자동으로 중복제거를 하는 기능이 추가되었습니다.
하지만 distinct의 특징 때문에 JPA의 페이징 기능을 제대로 사용할 수 없다는 문제점이 존재합니다. 이에 관해서는 다른 포스팅에서 더 자세하게 얘기를 해보겠습니다.
fetch join, @EntityGraph 주의점 2
두 번째 문제로는 두 개 이상의 연관된 엔티티를 조회할 수 없다는 점입니다. 문제 상황을 알아보기 위해 Cat 엔티티를 추가해 보도록 하겠습니다.
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Cat {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Owner owner;
private String name;
public Cat(String name) {
this.name = name;
}
public void mappingOwner(Owner owner) {
this.owner = owner;
}
}
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Owner {
...
@OneToMany(mappedBy = "owner", cascade = CascadeType.PERSIST)
private final List<Cat> cats = new ArrayList<>();
...
public void addCat(Cat cat) {
this.cats.add(cat);
cat.mappingOwner(this);
}
}
그리고 강아지와 고양이 모두를 한 번에 가져오는 쿼리를 작성합니다.
@Query("select o from Owner o join fetch o.dogs join fetch o.cats")
List<Owner> findAllJoinFetchDogAndCat();
@EntityGraph(attributePaths = {"dogs", "cats"})
@Query("select o from Owner o")
List<Owner> findAllEntityGraphDogAndCat();
만약 fetch join을 이용한 쿼리를 작성했다면 프로그램 실행 시점에, @EntityGraph를 사용했다면 쿼리 호출 시점에 다음과 같이 MultipleBagFetchException이 발생하게 됩니다.
해당 예외는 Hibernate에서 사용하는 Bag이라는 자료구조와 카테시안 곱의 결과 때문에 발생하는데, 좀 더 자세한 내용에 대해서는 다른 포스트에서 다뤄보겠습니다.
이처럼 다른 엔티티와 join을 하게 되면 페이징 문제나 MultipleBagFetchException이 생기게 됩니다. 이를 해결하기 위해 또 다른 방법인 batch 설정에 대해 알아보겠습니다.
Batch 설정
Batch 설정은 N + 1문제를 해결하면서, 위의 문제들이 발생하지 않는 또 하나의 방법입니다. Batch 설정을 하게 되면 처음 조회하는 엔티티들의 key를 in 절로 받아 연관관계에 있는 엔티티들을 조회하게 됩니다.
설정은 단순하게 @BatchSize 어노테이션을 이용하거나, 설정 파일에 default_batch_fetch_size 값을 추가하면 됩니다.
- @BatchSize
@BatchSize(size = 1000)
@OneToMany(mappedBy = "owner", cascade = CascadeType.PERSIST)
private final List<Dog> dogs = new ArrayList<>();
- default_batch_fetch_size
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
보통 batch size는 100~1000개 사이로 하는 게 좋다고 합니다. 너무 작으면 N + 1처럼 쿼리가 너무 많이 나가게 되고, 1000보다 크면 DB에 부하가 심할뿐더러 DB에 따라서 최대 batch size가 1000으로 설정되었기 때문입니다.
Batch를 설정했으니 쿼리 결과를 테스트해 보겠습니다.
@Transactional(readOnly = true)
public List<Owner> findAllOwnerWithBatch() {
List<Owner> owners = ownerRepository.findAll();
//Lazy Loading
owners.stream()
.map(Owner::getDogs)
.flatMap(Collection::stream)
.map(Dog::getName)
.collect(Collectors.toList());
owners.stream()
.map(Owner::getCats)
.flatMap(Collection::stream)
.map(Cat::getName)
.collect(Collectors.toList());
return owners;
}
이후 두 엔티티를 가져오는 코드를 작성하고 테스트를 하면 다음과 같은 결과를 얻을 수 있습니다.
기존 N + 1의 경우였다면 (Owner를 조회하는 쿼리 1) + (Dog를 조회하는 쿼리 10) + (Cat을 조회하는 쿼리 10) = 21으로 21번의 쿼리가 생겨야 하는 반면, Batch를 설정하게 되면 (Owner를 조회하는 쿼리 1) + (Dog를 조회하는 쿼리 1) + (Cat을 조회하는 쿼리 1) = 3으로 총 3번의 쿼리만 생성됩니다.
이렇게 Batch 설정을 하게 되면 앞선 해결책들과 달리 Lazy 전략의 이점을 취하는 동시에 여러 연관관계에 있는 엔티티들을 가져올 수 있게 됩니다.
하지만 당연히 Batch 설정도 만능이 될 수는 없습니다. 엔티티에 따라 최적의 batch size를 알기 힘들뿐더러 batch 쿼리로 데이터를 대량으로 가져온다는 것 자체가 DB에 부하를 줄 수 있기 때문입니다.
따라서 앞선 fetch join과 batch를 적절하게 잘 사용하는 게 중요하다고 합니다.
참고
https://jojoldu.tistory.com/165
'JPA' 카테고리의 다른 글
@Transactional(readOnly = true) 성능 향상되는 이유 (0) | 2023.05.01 |
---|---|
[JPA] CascadeType.DELETE, orphanRemoval = true 차이 (0) | 2023.03.27 |
Spring JPA 데이터베이스 초기화 (0) | 2023.03.27 |
JPA, Hibernate, Spring Data JPA, JDBC (0) | 2023.03.27 |