JPA N+1 문제를 @EntityGraph로 해결해보자
N+1 문제란
N+1 문제는 JPA 쓸 때 자주 마주치는 성능 이슈다. 연관 관계가 있는 엔티티를 조회할 때 추가 쿼리가 잘데없이 더 나가는 현상이다. 특히 컬렉션 조회할 때 더 자주 발생한다.
예를 들어보자. 게시글 10개를 조회하는데 각 게시글마다 작성자 정보를 가져와야 한다고 하면
- 처음에 게시글 목록 가져오는 쿼리 1번
- 각 게시글의 작성자 정보 가져오는 쿼리 10번 이렇게 총 11번의 쿼리가 실행되는 현상을 N+1 문제라고 한다.
Fetch Join으로 페이징처리 할 때의 한계
Fetch Join
보통은 Fetch Join으로 다음과 같이 해결하는 방법을 쓴다.
@Query("SELECT p FROM Post p JOIN FETCH p.author")
List<Post> findAllWithAuthor();
근데 Fetch Join에는 다음과 같은 한계가 있다.
- 페이징 처리할 때 메모리에서 처리하기 때문에 메모리 과부하가 일어날 수 있다.
- 여러 컬렉션을 한번에 Fetch Join 못 한다.
@EntityGraph
@EntityGraph는 JPA가 제공하는 페이징 처리할때에 더 유연한 해결책이다. 연관된 엔티티를 어떻게 가져올지 설정할 수 있게 해준다.
@EntityGraph는 페치 전략을 동적으로 EAGER 로 변경하는 방식이기 때문에, 페이징을 수행하면서도 추가적인 N+1 문제를 방지하는 데 유리하다.
@EntityGraph의 주요 특징
- 동적 페치 전략 적용
- 기본적으로 FetchType.LAZY로 설정된 연관 엔티티를 특정 쿼리에서만 FetchType.EAGER로 가져오도록 할 수 있다. 즉, @EntityGraph를 사용하면 기본 매핑 설정과 달리 특정 쿼리에서 필요한 연관 엔티티만 즉시 로딩할 수 있게 된다.
- 반대로, 필요에 따라 기본적으로 즉시 로딩이 설정된 엔티티를 지연 로딩으로 변경할 수도 있다.
- N+1 문제 해결
- @EntityGraph를 활용해 JPA가 여러 엔티티와 연관된 데이터를 함께 조회하도록 지정할 수 있어, 연관 데이터 접근 시 추가적인 쿼리를 줄여준다.
- 예를 들어, @OneToMany 관계로 매핑된 엔티티를 지연 로딩 설정으로 조회할 때 N+1 문제가 발생할 수 있지만, @EntityGraph를 사용하면 연관된 엔티티를 한 번에 가져오게 되어 성능이 개선된다.
- 복잡한 JPQL 대신 직관적인 데이터 조회:
- 코드 가독성을 높이며, 복잡한 fetch join JPQL 대신 훨씬 직관적으로 연관 엔티티를 조회할 수 있게 한다.
@EntityGraph vs Fetch Join 비교해보기
Fetch Join으로 할 때
@Query("SELECT a FROM Answer a JOIN FETCH a.picked WHERE a.picked = :user")
Page<Answer> findAllByPickedWithFetchJoin(Pageable pageable, @Param("user") Users user);
- 장점
- 쿼리 한방에 다 가져온다.
- 단점
- 페이징할 때 메모리에서 문제가 생길 수 있다.
@EntityGraph로 할 때
@EntityGraph(attributePaths = {"picked"})
@Query("SELECT p FROM Answer p WHERE p.picked = :user")
Page<Answer> findAllByPicked(Pageable pageable, @Param("user") Users user);
- 장점
- 페이징할 때 걱정이 없다
- 필요한 연관 엔티티만 골라서 가져올 수 있다
- 단점
- 쿼리가 추가로 나갈 수 있다.
- Fetch Join보다는 속도면에서 살짝 느릴 수 있다.
실제로 어떻게 쓰는지 보자
현재 프로젝트에 실제 적용한 코드를 보자
@EntityGraph(attributePaths = {"picked"})
@Query("SELECT p FROM Answer p WHERE p.picked = :user AND p.createdAt BETWEEN :startDate AND :endDate")
Page<Answer> findAllByPickedAndCreatedAtBetween( Pageable pageable, @Param("user") Users user, @Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate );
위 코드는 특정 Users 객체가 선택된 (picked) 모든 Answer를 페이징 조회하면서 picked 연관 엔티티를 함께 로드하도록 설계되었다.
@EntityGraph(attributePaths = {"picked"})
이 어노테이션은 picked 필드를 즉시 가져오도록 설정해줌. 이로 인해 JPA는 Answer를 조회할 때 picked 필드와 관련된 Users 엔티티도 함께 조회하여 추가 쿼리가 발생하지 않게 한다.
@EntityGraph를 사용할 때 조심할 점
1. N+1 해결하려다 성능 저하 발생하는 경우
@EntityGraph(attributePaths = {"comments", "likes", "tags"})
@Query("SELECT p FROM Post p")
List<Post> findAllWithDetails();
위 코드처럼 너무 많은 연관관계를 한번에 로딩하면 아래와 같은 문제가 발생한다.
- 불필요한 데이터까지 전부 조회돼서 메모리 사용량이 증가한다.
- 여러 테이블을 조인하면서 데이터 크기가 기하급수적으로 커질 수 있다.
- 실제로 사용하지 않는 연관 엔티티까지 로딩해서 성능이 나빠진다.
2. 중복 발생 가능
@EntityGraph는 OUTER JOIN 전략을 사용하기 때문에 중복이 발생할 수 있다.
중복 발생에 대한 대비로 다음과 같은 전략을 사용할 수 있다.
1. 컬렉션 연관관계에서 List -> Set 으로 바꾸기
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private Set<Comment> comments = new HashSet<>();
Set은 중복을 허용하지 않는 자료구조이므로 List에서 발생할 수 있는 중복 문제를 방지할 수 있다.
2. 쿼리에 DISTINCT 적용
@EntityGraph(attributePaths = {"customer", "orderItems", "orderItems.product"})
@Query("SELECT DISTINCT o FROM Order o WHERE o.customer.id = :customerId")
List<Order> findOrdersWithItemsByCustomerId(@Param("customerId") Long customerId);
DISTINCT로 인해 동일한 Order 엔티티가 여러 개 조회되는 것을 방지할 수 있다.
마무리
@EntityGraph는 JPA의 N+1 문제를 해결하는 유용한 전략으로, 특히 페이징 처리를 해야 하는 상황에서 Fetch Join보다 좋은 대안이 될 수 있다. 하지만 실제로 사용할 때는 성능 요구사항과 상황을 고려해 선택하는 것이 좋다.
'KakaoTechCampus' 카테고리의 다른 글
[Spring] 카카오페이 api 이용해서 테스트 결제 기능 구현하기 (1) | 2024.11.09 |
---|---|
[카카오테크캠퍼스] STEP2 3주차 회고 (1) | 2024.07.21 |
[카카오테크캠퍼스] STEP2 2주차 회고 (0) | 2024.07.08 |