화분

@EntityGraph 관련 공부 정리 본문

Study/JPA

@EntityGraph 관련 공부 정리

ExcellEast 2024. 2. 1. 17:56

@EntityGraph 를 사용하게 된 이유

우선 다음과 같은 목적으로 쿼리를 짜는 과정에서 다양한 고민을 하였다.

Team 엔터티와 Team에 지원하는 다수의 Appicant 엔터티가 존재한다.
Team 엔터티와 Applicant 관련 연관관계 매핑시 Loading Strategy은 FetchType.LAZY로 설정하였다.
커서 페이징 방식으로 10개의 Team엔터티(게시글에 해당)를 거기에 속한 Applicant엔터티들과 함께 꺼내오려 한다.
최근 등록일자를 기준으로 내림차순으로 쿼리를 짜려한다.

 

위와 같은 문제를 해결하기 위해 쿼리를 짜기 전에 JpaRepository를 상속받아서 메서드명을 통해 데이터베이스에서 원하는 데이터를 꺼내오려 했다. 

@Repository
public interface MatePostRepository extends JpaRepository<MatePost, Long> {

 
    List<MatePost> findByCreatedAtLessThanAndIsDeletedNotOrderByCreatedAtDesc(LocalDateTime createdAt, boolean isDeleted, Pageable pageable);
}

 

간단히 메소드를 해석하자면,

findByCreatedAtLessThan (인자로 받은 등록일자(CreatedAt)을 기준으로 인자보다 더 낮은 값, 즉 더 이전 시간),

And

IsDeletedNot(인자로 받은 IsDeleted(soft Delete 여부)가 false인 조건을 만족하는, 즉 글이 삭제 상태가 아닌),

위의 두 조건을 모두 만족 시키는 데이터를

OrderByCreatedAtDesc(등록일자를 기준으로 내림차순으로 정렬) 하라는 의미이다. 이때 Pageable에 담긴 정보인 page number(페이지 번호 : 항상 0인 상태)와 limit(가져올 글의 개수 : 5 이면 5개의 글만 가져온다)란 조건을 더한다.

createdAt 인자는 커서 역할을 한다. 그래서 꺼내온 데이터 중 가장 마지막 데이터(여기선 가장 과거에 쓰인 글)의 등록일자를 다음 페이지 호출 시 커서 역할을 하도록 따로 저장한다.

결국 위의 메소드는 글의 등록일자를 기준으로 인자로 받은 지정된 등록일자보다 이전에 등록되었고 삭제 상태(soft delete == true)가 아닌 글 5개를 최신 등록일자 순으로 가져온다는 의미이다.

 

위와 같이 메소드명으로 쿼리를 지정하면 커서 페이징이 참 쉽다. 하지만 문제가 있다. ORM을 다루면서 발생하는 중요한 문제 중 하나인 'N + 1' 문제가 생길 수 있다는 점이다. 즉 하나의 글을 조회하는데 거기에 속한 댓글의 수 만큼 쿼리를 더 요청한다는 것이다.

 

이러한 문제를 해결하기 위해 다양한 방법을 알아보고 시도를 하였다. @Query 어노테이션을 통해 JPQL 쿼리를 직접 작성 하는 방식들을 고민해봤는데 처음엔 게시글에 해당하는 팀 엔터티와 거기에 속한 신청자 엔터티를 'left join fetch' 해서 가져오려 했는데 이럴 경우 조인으로 해당 팀 게시글 레코드가 1개가 아닌 여러개가 생겨서 결과적으로 10개의 팀 게시글을 가져오려 하는데 그 게시글에 속한 2개 이상의 신청자(댓글이라고 생각하자) 개수 때문에 실제로 가져오는 것은 그 이하가 될 수 있다는 점이다.

 

이러한 문제를 해결하기 위해 다양한 시도를 하였다. 그러나 jpql 만으론 이런 복잡한 쿼리를 짜는데 한계가 있다는 것을 알게 되었다. 위와 같은 문제도 있고 신청자(Applicant) 외래키로 참조한 팀 엔터티를 꺼내오는 등 순환참조로 인한 에러가 발생하였다. 

 

이러한 문제를 해결하기 위해 @EntityGraph를 설정하였다. 기존엔 지연로딩으로 지정되었던 관계가 @EntityGraph를 해당 쿼리 메소드에 붙여서 사용하면 즉시로딩으로 전환할 수 있고 반대로 연관관계가 즉시로딩이었다면 @EntityGraph를 사용하여 레포지토리 내 해당 쿼리 메소드에 한해 지연로딩으로 바꿔줄 수 있는 유용한 전략이다.

 

간단한 @EntityGraph를 사용하는 방법 설명

먼저 부모 엔터티인 팀 엔터티에 @NamedEntityGraph 어노테이션을 붙여준다.

name 속성은 이후에 @EntityGraph를 사용할때 지정하기 위해 사용될 이름이다. 나는 TeamMatching 엔터티의 @EntityGraph라는걸 표시하기 위해 TeamMatching. 을 썼다.

attributeNodes 속성은 이후에 @EntityGraph에서 즉시로딩이나 지연로딩 둘 중 하나로 정할 때 연관관계 매핑된 리스트 컬렉션의 이름이다.

@NamedEntityGraph(
        name = "TeamMatching.forEagerApplicants",
        attributeNodes = @NamedAttributeNode("teamApplicants")

)
public class TeamMatching extends BaseEntity {

...


@OneToMany(mappedBy = "teamMatching", cascade = CascadeType.ALL)
private List<TeamApplicant> teamApplicants = new ArrayList<>();

 

해당 쿼리 메소드명에 @EntityGraph를 붙여줘서 즉시로딩(EAGER)로 전환하였다. 이렇게 되면 지연로딩이 아닌 즉시로딩으로 전환되어 N + 1 문제를 해결할 수 있다.

@EntityGraph(value = "TeamMatching.forEagerApplicants", type= EntityGraph.EntityGraphType.LOAD)
@Query("select t from TeamMatching t where t.createdAt < :cursor order by t.createdAt desc")
List<TeamMatching> findAllByCreatedAtBefore(@Param("cursor")LocalDateTime cursor, PageRequest request);

 

위의 @EntityGraph는 GPT4의 답변을 바탕으로 내가 임의로 작성한 것이고 더 깊은 이해를 위해 GPT4에게 추가적인 질문을 하거나 stack overflow에 올라온 두 모드 간의 차이를 찾아보았다.

type 속성의 EntityGraphType.LOAD와 EntityGraphType.FETCH의 차이?

기본적으로 @EntityGraph에 지정된 속성과 연관된 엔티티는 즉시로딩을 한다. EntityGraphType.LOAD나 EntityGraphType.Fetch나 지정된 속성만 즉시로딩한다는 공통점이 있느나 둘 사이 간의 명확한 차이점은 다음과 같다.

EntityGraphType.LOAD

이 모드에서는 @EntityGraph로 명시하지 않은 다른 모든 연관 엔티티는 즉시로딩이든 지연로딩이든 엔티티에 설정된 기본 FetchType 설정을 따른다.

 

EntityGraphType.FETCH

이 모드도 마찬가지로 @EntityGraph로 명시하지 않은 다른 모든 연관 엔티티는 즉시로딩이든 지연로딩이든 엔티티의 기본 로딩 전략을 따른다.

 

위의 설명은 GPTs의 Spring JPA Teacher의 답변을 추린 것으로, 다른 사람들의 설명도 큰 차이가 없어 보인다. 그래서 이런 타입을 설정하지 않고 지연로딩 상태인 연관 엔티티를 필요에 따라 즉시 로딩을 해야할 때 쓰면 요긴할 거 같다.

 

stack overflow에 올라온 둘 사이의 차이에 대한 질문에 대한 1)답변에 의하면 FETCH는 EntityGraph의 속성에 명시되지 않은 연관 엔티티는 모두 FetchType.LAZY로 로딩 방법이 처리되는 것이고, LOAD는 EntityGraph의 속성에 등록된 연관 엔티티는 모두 즉시 로딩으로 처리된다는 점은 FETCH와 다르지 않으나 속성에 등록되지 않은 연관 엔티티들은 지정된 로딩 방법을 따르거나 로딩 방법이 지정되지 않았다면 default 값을 따른다는 점이다. 그럼 어떤 상황에서 어떤 모드를 사용해야 할까? 웹 어플리케이션의 성능을 고려한다면 즉시 로딩해야 할 연관 엔티티 외 나머지 연관 엔티티는 지연 로딩을 하는 것이 좋아보인다. 이런 점을 고려해봤을때 몇몇 특수한 경우를 제외하고 FETCH 모드를 사용하는 것이 좋다고 볼 수 있겠다.

 

 

 

다음은 해당 쿼리 메소드를 사용한 서비스 계층의 한 메서드이다. 팀매칭 게시글 목록을 조회하는 기능을 구현한 것이다.

public List<ToTeamFormDTO> getTeamMatchingList(int limit, String stringCursor){
        PageRequest request = PageRequest.of(0, limit); // limit의 크기만큼 페이징을 한다.
        LocalDateTime cursor = LocalDateTime.parse(stringCursor, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")); //문자열을 LocalDateTime 객체로 변환한다.

		// 레포지토리에서 데이터를 꺼내온다.
        List<TeamMatching> result = teamMatchingRepository.findAllByCreatedAtBefore(cursor, request);

		// 꺼내온 팀 게시글들을 dto로 변환하는 과정을 거친다.
        List<ToTeamFormDTO> dtoList = result.stream().map(teamMatchingEntity -> teamMatchingEntity.toTeamFormDto(new ToTeamFormDTO())).toList();
        return dtoList;
    }

 

아래와 같이 불러온 팀(게시글) 엔터티에 속한 신청자 엔터티를 담은 리스트 컬렉션을 dto에 팀 엔터티의 id를 포함한 신청자 엔터티의 데이터를 따로 담는 과정을 거쳐서 순환참조 문제를 해결하였다. 이렇게 하면 각 팀 게시글 마다 거기에 속한 신청자 엔티티를 불러오는 과정에서 팀 엔터티를 전부 읽지 않고 팀 엔터티의 id만 dto에 저장하게 되므로 순환참조를 막을 수 있다.

public List<ToApplicantDto> makeApplicantDto(){
        List<TeamApplicant> teamApplicants = getTeamApplicants();
        List<ToApplicantDto> dto = teamApplicants.stream().map(TeamApplicant::makeDto).collect(Collectors.toList());
        return dto;
    }

 

 

 

 

마치며...

공부한 내용과 경험을 정리하는 차원에서 글을 작성하였지만 앞으로 @EntityGraph와 JPA 를 더 공부해서 보완할 부분이 있으면 글을 보완하거나 새롭게 글을 작성할 계획이다. 

 

 

출처 : 1) Tim Biegeleisen, 2015년 8월 13일, java - What is the difference between FETCH and LOAD for Entity graph of JPA? - Stack Overflow