N+1 문제 해결을 위해 적용하신 @EntityGraph의 경우, @EntityGraph를 설정할 때 필요 이상의 연관 엔티티들을 함께 로딩하게 되면 불필요한 데이터가 메모리에 적재되어 성능 저하를 유발할 수 있습니다.
아래 글에서 N + 1 해결 방법으로 @EntityGraph를 사용하는 것을 선택했는데, 위와 같은 피드백을 받았습니다.
[Diary] Spring ORM에서는 N + 1을 항상 신경쓰자. (tistory.com)
이 글에서는 @EntityGraph을 사용하면 발생하는 문제점과, 더 나은 해결법을 다루고자 합니다.
엔티티 그래프(Entity Graph)란?
엔티티 그래프(Entity Graph)는 JPA(Java Persistence API)에서 엔티티와 그 연관 관계를 로드(fetch)할 때, 특정 연관 필드에 대한 로딩 전략(fetch 전략)을 동적으로 정의할 수 있는 기능이다. 이를 통해 JPA가 데이터베이스에서 데이터를 조회할 때 불필요한 데이터 로드를 방지하고 성능을 최적화할 수 있다.
@EntityGraph가 수행하는 기능
@EntityGraph가 적용된 메서드는 JPA가 엔티티 그래프를 참고하여 SQL 쿼리를 생성한다.
예를 들어, 아래 코드의 경우 Cart라는 엔티티를 fetch 하면서, user, product, store라는 엔티티를 함께 fetch 하는 동작을 한다.
@EntityGraph(attributePaths = {"user", "product", "store"})
@Query("SELECT c FROM Cart c WHERE c.user.userId = :userId AND c.user.isDeleted = false")
List<Cart> findCartsByUserId(@Param("userId") Long userId);
문제점 1: 불필요한 연관 엔티티 로딩
@EntityGraph를 남용하면 필요하지 않은 연관 엔티티까지 불필요하게 메모리에 로딩될 수 있다. 예를 들어, 실제로 필요하지 않은 데이터까지 즉시 로딩하면 데이터의 양이 늘어나 성능 저하가 발생할 수 있다. 따라서 @EntityGraph를 사용할 때는 정확히 어떤 필드를 로딩할 것인지 신중하게 결정해야 한다.
또한 @EntityGraph는 JPA 구현체가 자동으로 최적화된 SQL을 생성하므로, 어떤 방식으로 JOIN 또는 SELECT가 실행되는지 명확히 알기 어렵다. 따라서 복잡한 쿼리가 필요할 경우, 명시적으로 Fetch Join을 사용하는 것이 더 적합할 수 있다.
문제점 2: JPQL 쿼리와의 충돌
@EntityGraph는 기본적으로 LEFT JOIN FETCH 또는 INNER JOIN FETCH와 같이 동작하지만, 복잡한 JPQL 쿼리와 함께 사용될 때는 그 동작이 예상과 다를 수 있다. 특히, 여러 개의 JOIN이나 FETCH를 사용하는 쿼리에서 @EntityGraph가 적용되지 않거나, 엔티티 간의 중복 데이터가 로딩되는 문제가 발생할 수 있다.
따라서 위 코드처럼 @Query와 함께 사용하는 것은 지양해야 한다.
대안 1: Fetch Join
@Query("SELECT c FROM Cart c " +
"JOIN FETCH c.user u " +
"JOIN FETCH c.product p " +
"JOIN FETCH c.store s " +
"WHERE u.userId = :userId AND u.isDeleted = false")
List<Cart> findCartsByUserId(@Param("userId") Long userId);
복잡한 조건이 있는 쿼리에서는 @EntityGraph 대신 JPQL의 FETCH JOIN을 명시적으로 사용하는 것이 좋다. 이렇게 하면 JPA가 쿼리 최적화를 보다 명확하게 처리할 수 있다.
대안 2: @BatchSize
@BatchSize를 사용하면 일정 개수의 연관 엔티티를 한 번에 로딩한다.
@Entity
public class Cart {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
@BatchSize(size = 10) // N+1 문제를 해결하기 위한 @BatchSize 설정
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
@BatchSize(size = 10)
private Product product;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "store_id")
@BatchSize(size = 10)
private Store store;
// other fields and methods
}
@BatchSize(size = 10) 이 어노테이션은 Lazy 로딩 시 최대 10개의 연관 엔티티를 한 번에 로딩하도록 설정한다. 이를 통해 하나의 쿼리로 최대 10개의 user, product, 또는 store 엔티티를 가져올 수 있어 N+1 문제를 완화할 수 있다.
하지만 이 방법도 적절한 BatchSize를 정하는 것이 중요하다.
대안 3: DTO 프로젝션
@Query("SELECT new com.example.dto.CartDTO(c.id, u.name, p.name, s.name) " +
"FROM Cart c " +
"JOIN c.user u " +
"JOIN c.product p " +
"JOIN c.store s " +
"WHERE u.userId = :userId AND u.isDeleted = false")
List<CartDTO> findCartDTOsByUserId(@Param("userId") Long userId);
DTO를 사용하는 방식은 필요한 필드만 선택적으로 로딩하여 성능을 최적화하는 방법이다. 엔티티 전체를 로딩하지 않고 필요한 데이터만 추출해서 DTO에 매핑할 수 있다.
위 코드는 JPQL에서 new 키워드를 사용해 CartDTO 객체로 직접 매핑한다. 필요한 필드들만 조회하여 메모리에 불필요한 데이터를 적재하지 않는다.
느낀 점: 필요한 데이터만 Fetch 하는 습관을 들이기
아래 글에서도 느꼈지만, 이 피드백을 받은 시간은 아래 글을 작성하기 이전이었기 때문에..
[Reflection] [우아콘2020] 수십억건에서 QUERYDSL 사용하기 - Select 컬럼에 Entity 자제 (tistory.com)
아무튼 최소한의 데이터만 fetch 하도록 하자.
그런데 그러면 필요에 따라 너무 DTO 수나 repository method 수가 늘어나지 않을까? 생각도 든다.
'Development > Diary' 카테고리의 다른 글
[Diary][Spring] Spring Cloud Gateway에 인가의 책임을 부여해볼까? (0) | 2024.09.24 |
---|---|
[Diary][Spring] Spring Webflux의 WebFilter는 자동으로 등록됩니다. (Security Filter Chain에 등록하면 발생할 수 있는 문제) (0) | 2024.09.17 |
[Diary][Spring Security] UserDetails를 어떻게 캐싱할까? (0) | 2024.09.01 |
[Diary] Spring ORM에서는 N + 1을 항상 신경쓰자. (0) | 2024.08.30 |
[Diary][Spring] Spring Security에서 Role과 Authority의 차이가 뭘까? (0) | 2024.08.29 |