제 블로그의 모든 글은 IMHO로 쓴 것입니다. 잘못된 부분이 있으면 덧글을 통해서 소통을 하면 더 좋은 글로 발전이 될 수 있을 것 같습니다. 그렇지만 소통을 할 때 서로의 감정을 존중하는 선에서 해주셨으면 좋겠습니다. 감사합니다:) —
1. 글을 쓴 의도
이번에 친구가 N+1 문제를 물어봤는데 그 친구의 기술 stack은 루비온레일스였다.
난 orm을 장고에서도 써봤고 spring에서도 쓰니 일어나는 이유는 대답할 수 있찌만
해결책을 어떻게 대답해야 하는지 살짝 멘붕이 오더라 ^^*
그래서 spring버전 django 버전 모두 일단 정리해보기로 했다.
2. N+1
N+1이 일어나는 이유
제대로 된예제
위 테이블을 보시면 3명의 user들은 1개의 회사들과 OneToOne 관계로 연결되어 있습니다.
이때, 모든 user들의 쿼리셋을 가져온다면 일반적으로 MyUser.objects.all() 이렇게 사용할 수 있습니다.
이때 쿼리는 총 몇 개가 실행될까요??
정답은 총 4번의 쿼리입니다.
MyUser 모델의 data를 가져오는 쿼리 1개
MyUser 모델에서 가져온 3개의 data에 OneToOne으로 연결된 Company 모델을 가져오는 쿼리 3개
자! 이제 그럼 MyUser.objects.all().select_related('company')를 사용하여 Company 모델들까지 모두 가져와 보겠습니다.
prefetch_related는 myuser, company 에 대해 각각의 쿼리로 쿼리셋을 가져옵니다.
ORM에서 성능 이슈가 발생하면 가장 흔한 원인으로 N+1 Problem이 언급된다.
N+1 Problem은 쿼리 1번으로 N건의 데이터를 가져왔는데 원하는 데이터를 얻기 위해 이 N건의 데이터를 데이터 수 만큼 반복해서 2차적으로 쿼리를 수행하는 문제입니다.
예시를 통해 좀 더 쉽게 문제에 접근해 봅시다.
public void Academy여러개를_조회시_Subject가_N1_쿼리가발생한다() throws Exception {
//given
List<Paper> papers = PaperRepository.findAll();
for (Paper paper : papers){
LOGGER.info("Post entity class: {}", paper.get(0).getName());
}
}
- 전체 논문 N건의 목록을 얻기 위한 쿼리 1회
- 각 논문을 쓴 교수의 이름을 얻기 위한 쿼리 N회
위와 같은 로직이 N+1 문제를 야기합니다.(fetch lazy를 할 경우) 성능 측면에서 지극히 비효율적이지만 로직을 이해하기 쉽다는 장점이 있습니다.
그렇지만 이렇게 하위 엔티티들을 첫 쿼리 실행시 한번에 가져오지 않고, Lazy Loading으로 불러오기에 필요한 곳에서 사용되어야 할 때 쿼리가 실행되는 문제가 생긴다. 그렇다고 null이 들어있는 것은 아니고 proxy 형태로 생성되어 있다.
todo 쿼리 n+1된 것 넣는다.
Spring 해결법
1. Join Fetch
조회시 바로 가져오고 싶은 Entity 필드를 지정 (join fetch a.subjects)하는 것입니다. 추가로 만약 Subject의 하위 Entity까지 한번에 가져와야 할때도 아주 쉽게 해결할 수 있습니다.
단, 이 방법은 불필요한 쿼리문이 추가되는 단점이 있습니다. 이 필드는 Eager 조회, 저 필드는 Lazy 조회를 해야한다까지 쿼리에서 표현하는 것은 불필요하다라고 생각하실 분들이 계실것 같습니다.
2. @EntityGraph
@EntityGraph 의 attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면 Lazy가 아닌 Eager 조회로 가져오게 됩니다. 원본 쿼리의 손상 없이 (select a from Academy a) Eager/Lazy 필드를 정의하고 사용할 수 있게 되었습니다. 추가로 Teacher까지 한번에 가져오는 쿼리도 아래와 같이 표현할 수 있습니다.
@EntityGraph(attributePaths = {"subjects", "subjects.teacher"})
@Query("select a from Academy a")
List<Academy> findAllEntityGraphWithTeacher();
주의사항 Join Fetch
SELECT academy0_.id AS id1_0_0_,
subjects1_.id AS id1_1_1_,
academy0_.name AS name2_0_0_,
subjects1_.academy_id AS academy_3_1_1_,
subjects1_.name AS name2_1_1_,
subjects1_.teacher_id AS teacher_4_1_1_,
subjects1_.academy_id AS academy_3_1_0__,
subjects1_.id AS id1_1_0__
FROM academy academy0_
INNER JOIN subject subjects1_
ON academy0_.id = subjects1_.academy_id
@Entity Graph
SELECT academy0_.id AS id1_0_0_,
subjects1_.id AS id1_1_1_,
academy0_.name AS name2_0_0_,
subjects1_.academy_id AS academy_3_1_1_,
subjects1_.name AS name2_1_1_,
subjects1_.teacher_id AS teacher_4_1_1_,
subjects1_.academy_id AS academy_3_1_0__,
subjects1_.id AS id1_1_0__
FROM academy academy0_
LEFT OUTER JOIN subject subjects1_
ON academy0_.id = subjects1_.academy_id
JoinFetch는 Inner Join, Entity Graph는 Outer Join이라는 차이점이 있음을 주의해주세요. 공통적으로 카테시안 곱(Cartesian Product)이 발생하여 Subject의 수만큼 Academy가 중복 발생하게 됩니다.
해결법 2가지 방법이 있습니다. 하나는 일대다 필드의 타입을 Set으로 선언하는 것입니다. (Set은 중복을 허용하지 않는 자료구조이기 때문에 중복등록이 되지 않습니다.)
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name="academy_id")
private Set<Subject> subjects = new LinkedHashSet<>();
(Set이 순서가 보장되지 않기에 LinkedHashSet을 사용하여 순서를 보장합니다.)
2번째 방법은 distinct를 사용하여 중복을 제거하는 것입니다. (Set보다는 List가 더 적합하다고 판단되신다면)
이 부분은 @Query에서 적용하는 것이니 join fetch, @EntityGraph 모두 동일합니다.
@Query("select DISTINCT a from Academy a join fetch a.subjects s join fetch s.teacher")
List<Academy> findAllWithTeacher();
@EntityGraph(attributePaths = {"subjects", "subjects.teacher"})
@Query("select DISTINCT a from Academy a")
List<Academy> findAllEntityGraphWithTeacher();
@NamedEntityGraphs? 보통 N+1 문제 해결을 얘기할때 @NamedEntityGraphs가 예시로 많이 등장합니다.
@NamedEntityGraphs의 경우 Entity에 관련해서 모든 설정 코드를 추가해야하는데, 개인적으론 Entity가 해야하는 책임에 포함되지 않는다고 생각합니다.
3. the second-level cache사용하기
이번에는 더 이상 두 번째 수준의 캐시 영역을 벗어나지 않으며 READ_WRITE 캐시 동시성 전략을 사용하기 때문에 엔티티는 유지 된 직후에 캐시되므로 테스트를 실행할 때 SQL 쿼리를 실행할 필요가 없습니다 위의 경우 :
따라서, 2 차 레벨 캐시를 사용한다면 Hibernate.initiaize를 사용하여 비즈니스 유스 케이스를 수행하는 데 필요한 추가 연결을 가져 오는 것이 좋습니다. 이 경우 N + 1 캐시 호출이 있더라도 두 번째 수준 캐시가 올바르게 구성되고 메모리에서 데이터가 반환되기 때문에 각 호출이 매우 빠르게 실행되어야합니다. Hibernate.initialize는 콜렉션에도 사용될 수있다. 이제 2 차 수준의 캐시 모음은 다음과 같은 테스트 사례를 실행할 때 처음로드 될 때 캐시에 저장되는 것을 의미하는 read-through입니다. https://vladmihalcea.com/initialize-lazy-proxies-collections-jpa-hibernate/ Hibernate.initialize 메소드는 2 차 레벨 캐시에 저장된 프록시 엔티티 또는 콜렉션을로드 할 때 유용하다. 기본 엔터티 또는 컬렉션이 캐시되지 않은 경우 보조 SQL 쿼리로 프록시로드를 사용하면 JOIN FETCH 지시문을 사용하여 처음부터 지연 연결을로드하는 것보다 효율적이지 않습니다.
A 로직에서는 Fetch전략을 어떻게 가져가야 한다는 것은 해당 로직의 책임이지, Entity의 책임이 아니라고 생각합니다. Entity에선 실제 도메인에 관련 된 코드만 작성하고, 상황에 따라 유동적인 Fetch 전략을 가져가는 것은 전적으로 서비스/레파지토리에서 결정해야하는 일이라고 생각됩니다.
참고: https://show-me-the-money.tistory.com/48