[Diary][Spring] N + 1 문제 해결을 위한 @EntityGraph를 사용할 때 주의할 점
본문 바로가기

Development/Diary

[Diary][Spring] N + 1 문제 해결을 위한 @EntityGraph를 사용할 때 주의할 점

N+1 문제 해결을 위해 적용하신 @EntityGraph의 경우, @EntityGraph를 설정할 때 필요 이상의 연관 엔티티들을 함께 로딩하게 되면 불필요한 데이터가 메모리에 적재되어 성능 저하를 유발할 수 있습니다.

아래 글에서 N + 1 해결 방법으로 @EntityGraph를 사용하는 것을 선택했는데, 위와 같은 피드백을 받았습니다. 

[Diary] Spring ORM에서는 N + 1을 항상 신경쓰자. (tistory.com)

 

[Diary] Spring ORM에서는 N + 1을 항상 신경쓰자.

프로젝트를 진행하다가 드디어 N + 1을 마주치는 상황이 생겼습니다. 사실 이 문제가 정확히 언제 발생할 수 있는지 이해가 안 되었는데, 이렇게 쉽게 마주쳐서 기뻤습니다 후후문제 상황여기 장

bezzang2.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)

 

[Reflection] [우아콘2020] 수십억건에서 QUERYDSL 사용하기 - Select 컬럼에 Entity 자제

[2019] Spring JPA의 사실과 오해 (youtube.com) 이 글에서는 위 동영상에서, Entity 보다는 Dto를 우선이라는 챕터에 대해 느낀 점을 작성합니다. Entity 보다는 Dto를 우선우리가 ORM을 사용하면서 Entity를 조

bezzang2.tistory.com

아무튼 최소한의 데이터만 fetch 하도록 하자.

그런데 그러면 필요에 따라 너무 DTO 수나 repository method 수가 늘어나지 않을까? 생각도 든다.