📍N+1이 뭐지?
조회 시 1개의 쿼리를 생각하고 설계를 했으나 나오지 않아도 되는 조회의 쿼리가 N개가 더 발생하는 문제.
JPA의 경우에는 객체에 대해서 조회한다고 해도 다양한 연관관계들의 매핑에 의해서 관계가 맺어진 다른 객체가 함께 조회되는 경우에 N+1이 발생하게 된다.
예를 들어 jpql문으로 findById를 사용할 때에는 하나의 entity만 조회하기 때문에 문제가 없어 보인다.
하지만 findByAll을 요청하는 것이라면 가져오는 entity마다 연관관계로 정의된 객체를 검색하게 된다. (즉시로딩 상황)
즉, 모든 User에 대해서 검색하고 싶어서 select 쿼리를 하나 날렸지만(1), 즉시로딩이 걸려있기 때문에 각각의 User가 가진 entity을 모두 검색한다(N)라는 N+1 문제가 발생하는 것이다.
📍그럼 지연로딩하면 해결?
문제였던 즉시 로딩을 바꿨으니 N+1은 더이상 발생하지 않을까?🤔
아니다. . 지연 로딩은 해당 연결 entity에 대해서 프록시로 걸어두고, 사용할 때 쿼리문을 결국 날리기 때문에 처음 find할 때는 N+1이 발생하지 않지만 추가로 User 검색 후 User의 연관된 entity를 사용해야한다면 이미 캐싱된 User의 entity 프록시에 대한 쿼리가 또 발생하게 된다.
연관 데이터를 가져오는 시점을 미룰 뿐, 연관된 데이터에 접근하면 쿼리가 개별적으로 실행되는 것이다.
📍fetch join으로 지연로딩 해결
즉시로딩에서는 우리가 커스텀할 수 있는 부분이 존재하지 않기 때문에 지연로딩 과정에서 우리는 바로 사용을 할 객체에 대해서는 join을 걸 수 있도록 조정해주어야 한다. 그것이 fetch join.
@Query("select distinct u from User u left join fetch u.articles")
List<User> findAllJPQLFetch();
이런 식으로 join에 fetch를 걸게 되면 한번에 User에 연관된 entity를 가져오는 쿼리를 작성해준다.
📍fetch join으로 해결 못하는 케이스들
1️⃣ Pagination
Fetch join을 Paging처리해서 반환해보면
@EntityGraph(attributePaths = {"articles"}, type = EntityGraphType.FETCH)
@Query("select distinct u from User u left join u.articles")
Page<User> findAllPage(Pageable pageable);
- @EntityGraph으로 fetch join과 같은 기능을 수행할 수도 있다.
@Test
@DisplayName("fetch join을 paging처리에서 사용해도 N+1문제가 발생한다.")
void pagingFetchJoinTest() {
System.out.println("== start ==");
PageRequest pageRequest = PageRequest.of(0, 2);
Page<User> users = userRepository.findAllPage(pageRequest);
System.out.println("== find all ==");
for (User user : users) {
System.out.println(user.articles().size());
}
}
이렇게 0페이지에 총 2명의 유저를 반환해보면 정상적으로 쿼리가 하나만 나갈까?
하나만 나가기는 한다. 하지만 Mysql에서 페이징 처리를 할 때 사용을 하는 Limit, Offset이 없다. 분명 limit은 size 2로, offset은 page 0으로 지정해줬는데 말이다. 근데 또 반환 값은 2명의 유저 article size가 나왔는데 뭐가 어떻게 된걸까?
이유는 인메모리를 적용해서 조인을 했기 때문이다.
즉 실제 날아간 쿼리와 이 문구를 통합해서 이해를 해보면 일단 List의 모든 값을 select해서 인메모리에 저장하고, application 단에서 필요한 페이지만큼 반환을 알아서 해주었다는 이야기가 된다.
예를 들자면 2개만 반환하고 싶은데 100개를 먼저 가져온 후에 그제서야 2개를 반환해 준다는 뜻이다.
이 현상은 Fetch Join을 할 때 중복을 없애기 위해서 distinct를 쓰게 되는데, 페이징 처리는 중복 제거 후에야 제대로 작동하니까, JPA는 모든 데이터를 메모리에 가져온 후 처리하게 되서 발생한다. 쉽게 말해서 순서만 바꾼다면 해결 가능하다는 뜻.
✔️해결 방법
1. ~ToOne 관계에 있는 경우에는 fetch join을 걸어도 Pagination이 원하는대로 제공된다. ex) 유저와 게시물이 있는 경우에 게시물을 조회할 때
2. ~ToMany 관계에 있는 경우에는 @BatchSize 를 걸어 해결. -> 원하는 만큼만 설정해서 가져올 수 있음.
3 : @Fetch(FetchMode.SUBSELECT) 사용. -> @BatchSize(size = 무한대)와 같음.
2️⃣ 둘 이상의 Collection fetch join(~ToMany) 불가능
fetch join을 할 때 ToMany의 경우 한번에 fetch join을 가져오기 때문에 collection join이 2개 이상이 될 경우 너무 많은 값이 메모리로 들어와 exception이 추가로 걸린다. 그 exception이 MultipleBagFetchException이다.
✔️해결 방법
@BatchSize 를 걸어 해결.
@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Article> articles = new ArrayList<>();
@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Question> questions = new ArrayList<>();
// repository 딴
@Query("select distinct u from User u left join u.articles left join u.questions")
Page<User> findAllPage2(Pageable pageable);
‼️주의점
batch size에 fetch join을 걸면 안 된다.
조금만 생각해보면 서로 반대되는 개념인 것을 알 수 있다. batch size는 "나눠서 가져오자", fetch join은 "다 한 번에 가져오자" 이기 때문이다.
동시에 쓰게되면 fetch join이 실행되며 batch size는 무시된다.
📍결론
N+1 문제가 발생했다면,
페이징 처리가 필요한 경우에는 Batch Size를 설정하고 지연 로딩을 사용한다.
Fetch Join은 페이징 처리가 필요 없는 상황에서만 사용한다.
'springboot' 카테고리의 다른 글
[SpringBoot] JPA, Hibernate, 그리고 Spring Data JPA 톺아보기 (0) | 2025.02.19 |
---|---|
[SpringBoot] @Transactional을 정확하게 알아보자 (1) | 2025.01.15 |
[SpringBoot] 영속성 컨텍스트, 그리고 EntityManager (0) | 2025.01.09 |
[SpringBoot] 끄적끄적 프로젝트 쿼리 성능 개선을 해보자 (0) | 2025.01.06 |
[SpringBoot] QueryDSL 그게 뭔데 다들 쓰는거지? (0) | 2024.12.28 |