🌻 JAVA/자바 ORM 표준 JPA 프로그래밍

JPA - Fetch Join이 과연 만능인가? (N+1, Pagination)

iseunghan 2024. 4. 18. 23:28
반응형

들어가기 전

이전 시간에 알아봤던 N+1 해결법에 이어서 FetchJoin을 이용해서 해결할 수 있었습니다. 하지만 Fetch Join이라고 다 해결할 수 있는 것은 아닙니다. 이번 시간에는 Fetch Join을 사용했을 때 어떠한 사이드 이펙트가 있는지 알아보고 그 해결책에 대해서 알아봅니다.

1. FetchJoin, EntityGraph 사용 시 Pagination을 사용할 수 없다.

FetchJoin과 EntityGraph 둘 다 동일한 증상이 발생합니다. Fetch Join만 테스트를 해보겠습니다.

@Query(
        value = "select t from Team t join fetch t.members",
        countQuery = "select count(t) from Team t"
)
List<Team> findTeamsFetchJoin(Pageable pageable);
@DisplayName("모든 팀을 할 때, 페이지네이션이 안된다.")
@Test
void team_findAll_Pagination_test() {
    clearPersistenceContext();

    System.out.println("----------team_findAll_test start-----------");
    List<Team> teamList = teamRepository.findTeamsFetchJoin(PageRequest.of(0, 1));
    assertThat(teamList).hasSize(1);
    System.out.println("----------team_findAll_test mid-----------");
    teamList.stream()
            .map(Team::getMembers)
            .map(List::stream)
            .forEach(memberStream -> memberStream
                    .map(Member::getName)
                    .forEach(System.out::println)
            );
    System.out.println("----------team_findAll_test end-----------");
}

로그를 살펴보면 LIMIT 쿼리가 발생하지 않고, firstResult/maxResults specified with collection fetch; applying in memory 라는 경고가 발생했습니다.

이 말은 즉슨, Full Scan해서 전부 다 들고와서 애플리케이션 단에서 메모리 상에 올려두고 페이지네이션 처리를 했다는 뜻입니다. 만약 몇천만건이라면 어떻게 될까요? OOM(OutOfMemory)이 발생해서 장애가 발생할 가능성이 매우 큽니다!

그렇다면 어떻게 해결해야 할까요? 메모리 상에 올려두고 Pagination을 한다는점은 너무나 치명적이기 때문에 N+1의 이점을 포기하는게 맞다고 생각합니다. 하지만 이건 상황에 따라 달라질 수 있을 것 같습니다.

  1. 만약 Pagination을 사용하지 않는다면, FetchJoin or EntityGraph로 N+1 해결
  2. 만약 Pagination을 중요시 한다면, N+1 포기 (하지만 Batch Size를 이용한다면 N+1에 대해 최소한의 성능을 보장시킬 수 있습니다)

아래에서는 후자의 방법을 살펴봅니다.

해결방법 - Fetch Join 제거

일단 Fetch Join을 포기해야하는 것은 불가피합니다. 그렇기 때문에 findAll(Pageable) 메소드를 이용하겠습니다.

/**
 * Returns a {@link Page} of entities meeting the paging restriction provided in the {@link Pageable} object.
 *
 * @param pageable the pageable to request a paged result, can be {@link Pageable#unpaged()}, must not be
 *          {@literal null}.
 * @return a page of entities
 */
Page<T> findAll(Pageable pageable);
@DisplayName("모든 팀을 조회할 때, 기본제공 findAll + Pageable은 Limit 쿼리가 발생한다.")
@Test
void team_default_findAll_Pagination_test() {
    clearPersistenceContext();

    System.out.println("----------team_findAll_test start-----------");
    Page<Team> result = teamRepository.findAll(PageRequest.of(0, 1));
    List<Team> teamList = result.getContent();
    assertThat(teamList).hasSize(1);
    System.out.println("----------team_findAll_test mid-----------");
    teamList.stream()
            .map(Team::getMembers)
            .map(List::stream)
            .forEach(memberStream -> memberStream
                    .map(Member::getName)
                    .forEach(System.out::println)
            );
    System.out.println("----------team_findAll_test end-----------");
}

네 정상적으로 offset 쿼리를 통해 pagination을 처리하고 있습니다.

그렇다면 fetch join을 제거한 @Query를 사용하면 Pagination이 잘 동작하는지 볼까요?

@Query(
        value = "select t from Team t",
        countQuery = "select count(t) from Team t"
)
List<Team> findTeamsWithoutFetchJoin(Pageable pageable);
@DisplayName("모든 팀을 조회할 때, Fetch Join 제거 + Pageable은 Limit 쿼리가 발생한다.")
@Test
void team_findAll_Exclusive_FetchJoin_with_Pagination_test() {
    clearPersistenceContext();

    System.out.println("----------team_findAll_test start-----------");
    Page<Team> result = teamRepository.findTeamsWithoutFetchJoin(PageRequest.of(0, 1));
    List<Team> teamList = result.getContent();
    assertThat(teamList).hasSize(1);
    System.out.println("----------team_findAll_test mid-----------");
    teamList.stream()
            .map(Team::getMembers)
            .map(List::stream)
            .forEach(memberStream -> memberStream
                    .map(Member::getName)
                    .forEach(System.out::println)
            );
    System.out.println("----------team_findAll_test end-----------");
}

직접 만든 쿼리도 정상적으로 Pagination이 동작하는 것을 확인할 수 있습니다. (추가 팁: Batch Size를 이용한다면 N+1에 대해 최소한의 성능을 보장시킬 수 있습니다)

Limit 결정되는 시점

SimpleJpaRepository - 692 line

SimpleJpaRepository에서 페이지 요청이 있으면 offSet과 MaxResults를 지정해주고 있습니다. 그 정보를 바탕으로 실제 쿼리를 날릴 때 Offset과 First or Top을 이용해서 Limit를 구현합니다. (자세한 글은 다음을 참조)

번외 - ~ToOne 관계에서는 페이징 처리가 가능하다.

위에서 Fetch Join + Pagination을 사용하면 안되는 케이스들은 모두 ~ToMany 관계일 때 입니다. 아래에서 소개드리는 것은 근본적인 해결방법은 아니지만, ~ToOne 관계에서는 페이징 처리가 가능하다는 것을 테스트하는 것입니다.

@Query(value = "select m from Member m join fetch m.team")
List<Member> findMembersFetchJoin(Pageable pageable);
@DisplayName("~ToOne관계에서는 페이징처리가 가능하다")
@Test
void member_findAll_Pagination_test() {
    clearPersistenceContext();

    System.out.println("----------member_findAll_test start-----------");
    List<Member> memberList = memberRepository.findMembersFetchJoin(PageRequest.of(0, 2));
    assertThat(memberList).hasSize(2);
    System.out.println("----------member_findAll_test mid-----------");
    memberList.stream()
            .map(Member::getTeam)
            .map(Team::getName)
            .forEach(System.out::println);
    System.out.println("----------member_findAll_test end-----------");
}

보시는 것과 같이 ~ToOne 관계에서는 문제 없이 조회되는 것을 확인할 수 있습니다.

2. MultipleBagFetchException - 하나의 엔티티에서 2개 이상의 컬렉션을 조회할 때 (~ToMany 관계)

위 ERD 처럼 Team이 members, sponsors를 갖는 구조입니다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Team {
        ...

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<Member> members = new ArrayList<>();

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<Sponsor> sponsors = new ArrayList<>();
}
public interface TeamRepository extends JpaRepository<Team, Long> {
    ...

    @Query(value = "select t from Team t join fetch t.members join fetch t.sponsors")
    Page<Team> findTeamsFetchJoinTwoCollection(Pageable pageable);
}
@DisplayName("둘 이상의 컬렉션을 페치조인할 수 없다.")
@Test
void Over_Two_collection_fetchJoin_Not_Allowed() throws Exception {
    System.out.println("--------Over_Two_collection_fetchJoin_Not_Allowed START-------------");
    // when
    List<Team> result = teamRepository.findTeamsFetchJoinTwoCollection(PageRequest.of(0, 3)).getContent();
    assertThat(teams).hasSize(3);

    // then
    System.out.println("--------Over_Two_collection_fetchJoin_Not_Allowed MID-------------");
    teams.forEach(team -> {
        System.out.println(team.getName());
        team.getMembers().stream().map(Member::getName).forEach(System.out::println);
        team.getSponsors().stream().map(Sponsor::getName).forEach(System.out::println);
    });

    System.out.println("--------Over_Two_collection_fetchJoin_Not_Allowed END-------------");
}

둘 이상의 컬렉션을 Fetch Join 하려니 cannot simultaneously fetch multiple bags 에러가 발생합니다. 어떻게 하면 MultipleBagFetchException을 피할 수 있을까요?

해결방법 1. Set 자료형으로 변경

둘 이상의 컬렉션을 조회하면 컬렉션X컬렉션 즉 카테시안곱이 발생하기 때문에 JPA에서 예외를 던지게 했습니다. 그래서 중복이 발생하지 않기 위해 Set 자료형으로 변경하면 해결할 수 있습니다.

@OneToMany(mappedBy = "team", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private Set<Member> members = new LinkedHashSet<>();

@OneToMany(mappedBy = "team", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private Set<Sponsor> sponsors = new LinkedHashSet<>();

순서를 보장해주기 위해 LinkedHashSet을 사용합니다.

@Query(value = "select t from Team t join fetch t.members join fetch t.sponsors")
List<Team> findTeamsFetchJoinTwoCollection();

동일한 테스트를 돌려보면 정상적으로 Fetch Join이 수행하여 N+1 문제를 해결할 수 있습니다.

주의할점이 있습니다. Fetch Join은 기본적으로 Inner Join을 사용합니다. 그렇기 때문에 만약 sponsor나 member를 가지고 있지 않는 team이 있다면? 아마 테스트를 깨지게 될 것입니다. 한번 확인해볼까요?

@BeforeEach
void setup() {
        ...

    Sponsor sponsor21 = Sponsor.builder().name("sponsor21").build();
    Sponsor sponsor22 = Sponsor.builder().name("sponsor22").build();
    Team team2 = Team.builder().name("team2").build();
    team2.addMember(Member.builder().name("member2-1").build());
    team2.addMember(Member.builder().name("member2-2").build());
//    team2.addSponsor(sponsor21);
//    team2.addSponsor(sponsor22);

    ...
}

테스트용 데이터를 삽입할 때, 일부러 team2의 sponsor를 추가하는 코드를 주석처리하고 테스트를 돌려보겠습니다.

테스트가 깨지게 됩니다. 이는 어찌보면 당연한 결과입니다. Inner Join을 이용해서 Team을 조회해오기 때문에 해당되지 않는다면 조회가 되지 않을 것 입니다. 만약 member, sponsor가 없는 Team도 가져와야 한다면 inner join이 아닌 left join을 사용하면 될 것입니다.

@Query(value = "select t from Team t left join fetch t.members left join fetch t.sponsors")
Page<Team> findTeamsFetchJoinTwoCollection(Pageable pageable);

left join을 통해서 모든 팀을 다 가져오는 것을 확인할 수 있습니다.

과연 이 방법이 최선일까요? 위 방법에는 Pagination을 In Memory에서 한다는 점이 아직 해결되지 않았습니다.

해결방법 2. BatchSize

특정 에러를 해결하기 위해 도메인 레이어의 자료형을 변경하는 것은 좋은 해결책이 아니라고 생각합니다. 그렇기 때문에 List 자료형을 사용해야하고 Pagination, 둘 이상의 컬렉션을 Fetch Join을 한다는 가정하에 Batch Size를 이용해 해결할 수 있습니다.

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = "spring.jpa.properties.hibernate.default_batch_fetch_size=10")
public class Team_MultipleBagEx_Solution2_Test {
        ...

        @DisplayName("둘 이상의 컬렉션을 페치조인할 수 없다 -> Fetch Join(x) + BatchSize를 이용해 해결")
        @Test
        void Over_Two_collection_Not_Used_fetchJoin_and_Use_BatchSize() throws Exception {
            System.out.println("--------Over_Two_collection_Not_Used_fetchJoin_and_Use_BatchSize START-------------");
            // when
            List<Team> teams = teamRepository.findAll(PageRequest.of(0, 3)).getContent();
            assertThat(teams).hasSize(3);

            // then
            System.out.println("--------Over_Two_collection_Not_Used_fetchJoin_and_Use_BatchSize MID-------------");
            teams.forEach(team -> {
                System.out.println(team.getName());
                team.getMembers().stream().map(Member::getName).forEach(System.out::println);
                team.getSponsors().stream().map(Sponsor::getName).forEach(System.out::println);
            });

            System.out.println("--------Over_Two_collection_Not_Used_fetchJoin_and_Use_BatchSize END-------------");
        }
}

@TestPropertySource(properties = "spring.jpa.properties.hibernate.default_batch_fetch_size=10") 를 이용해서 해당 테스트에 BatchSize를 10으로 설정하였습니다.

로그를 살펴보면, Paging이 잘 처리되었습니다. 그리고 Team에 속한 member, sponsor를 사용할 때 쿼리가 발생하였고, where in 절을 통해 Eager Loading을 하는 것을 볼 수 있습니다.

만약 BatchSize를 적용하지 않고 테스트를 돌렸다면 몇번의 쿼리가 발생했을까요? 전체 팀의 개수가 3이라면 4의 쿼리가 발생했을 것입니다.(N+1 문제)

4개의 쿼리가 3개로 줄어들었는데 1개밖에 차이가 안나네? 라고 생각하실 수 있습니다. 하지만 아래 표와 같이 팀의 개수가 많을수록 쿼리 수는 눈에 띄게 줄어듭니다.

전체 팀의 개수 발생 쿼리 수
(BatchSize 미적용)
발생 쿼리 수
(BatchSize=1000 적용)
쿼리 성능
3 4 3 25% 감소
1,000 1,001 3 99.7% 감소
10,000 10,001 21 99.8% 감소
100,000 100,001 201 99.8% 감소

최대 1000 분의 1로 쿼리수를 줄일 수 있습니다. Fetch Join을 사용할 수 있다면 Fetch Join을 사용하고, 만약 둘 이상의 컬렉션을 조회해야한다면 가장 많은 컬렉션에 Fetch Join을 적용하고 나머지 컬렉션에 대해서는 Batch Size를 사용하는 방법이 최선일 것 같습니다.

3. FetchJoin 대상은 별칭을 사용하지 말자.

JPA 표준 스펙에는 Fetch Join 대상에 별칭이 없습니다. 하지만 JPA 구현체인 하이버네이트는 별칭을 허용합니다. 즉 별칭을 사용을 할 수 있지만 여러 문제점들이 있기 때문에 조심해서 사용해야합니다.

Fetch Join 대상을 ON절에서 사용하면 에러가 발생한다.

@DisplayName("join fetch는 on절 사용시 org.hibernate.query.SemanticException: Fetch join has a 'with' clause (use a filter instead) 발생")
@Test
void fetchJoin_NotAllow_On_condition() throws Exception {
    System.out.println("--------fetchJoin_NotAllow_On_condition START-------------");
    // when
    List<Team> teams = em.createQuery("select t from Team t join fetch t.members m on m.name = 'member11'", Team.class).getResultList();

    // then
    System.out.println("--------fetchJoin_NotAllow_On_condition MID-------------");
    teams.forEach(team -> {
        team.getMembers().stream().map(Member::getName).forEach(System.out::println);
        team.getSponsors().stream().map(Sponsor::getName).forEach(System.out::println);
    });
    System.out.println("--------fetchJoin_NotAllow_On_condition END-------------");
}

위 테스트를 실행하면 on절 대신에 filter를 사용하라고 에러를 발생시킵니다.

그럼 where 절에 조건을 걸면 안되나?

예를 들어 다음과 같은 요구사항이 있다고 가정해보겠습니다.

  • Team을 조회하는데 해당 Team에 속한 모든 Member도 필요한 상황
  • 쿼리 최적화를 위해 Team.members에 대해서 Fetch Join 적용
  • 이름이 “member11”로 시작하는 member를 가져와야함
  • 위 요구사항으로 만들어진 쿼리는 다음과 같음
  • select t from Team t join fetch t.members m where m.name like 'member1%'

만약 team에 속한 member가 member1, member12, member2라고 가정했을 때 위 결과는 member1, member12가 나올 것입니다. 이렇게 되면 JPA 입장에서는 DB와 데이터 일관성이 깨지게 되고, 최악의 경우 member2가 DB에서 삭제될 수도 있습니다.

정리하자면 JPA의 엔티티는 DB의 데이터와 일관성을 유지해야 하기 때문에 임의로 데이터를 빼고 조회한다면 DB에 해당 데이터가 없다고 판단하는 것과 동일합니다.

그럼 하이버네이트는 왜 허용할까?

왜 하이버네이트에서는 Fetch Join 대상의 별칭을 허용할까요? 만약 조회하는 데이터가 DB와의 일관성 문제가 없다면 사용해도 됩니다!

예를 들어 다음과 같은 쿼리는 안전하다고 할 수 있습니다.

select m 
from Member m 
    join fetch m.team t
where t.name = 'team1'

join fetch 대상인 Team의 이름을 필터링해서 가져오는 쿼리입니다. 결론적으로 ~ToOne 관계인 컬렉션에 대해서 필터링하는 것은 안전하다고 할 수 있는거죠. 하지만 조회 용도로만 사용한다는 가정하에 말씀드리는 것입니다. 실무에서는 DB와 일관성이 깨지더라도, 조회 용도로만 사용한다면 Fetch Join 대상의 별칭을 사용하여도 됩니다. (대신 2차 캐시 등등 조심..)

정리하자면..

  • Fetch Join
    • 페이징 API를 사용할 수 없다
      • 해결방법: 페이징 필요 시: Fetch Join 제거 + Batch Size, 불 필요 시: Fetch Join 사용
    • 둘 이상의 컬렉션을 Fetch Join 할 수 없다.
      • 해결방법: 데이터가 많은 쪽에 Fetch Join 걸고, 나머지는 Batch Size를 이용해 최소한의 성능 보장
    • Fetch Join 대상 ON, Where절 사용 조심
      • 해결방법: Fetch Join 시 조회 용도로만 사용한다면 OK, 아니라면 절대 금지

정리하다보니 내용이 너무 길어졌네요.. JPA는 정말 알아도 끝이 없는 것 같습니다..

부족한 글 읽어주셔서 감사합니다.

REFERENCES

MultipleBagFetchException 발생시 해결 방법

JPA N+1 발생원인과 해결방법 - Yun Blog | 기술 블로그

JPA 모든 N+1 발생 케이스과 해결책

fetch join 과 limit 을 같이 쓸 때 주의하자. (firstResult/maxResults specified with collection fetch)

fetch join 시 별칭관련 질문입니다 - 인프런 | 질문 & 답변

fetch join 관련 질문 드립니다!! - 인프런

반응형