fetch join 을 사용할 때 주의점,예외 모음집

이진우·2024년 3월 16일
0

스프링 학습

목록 보기
27/41

프로젝트 세팅

먼저 주의점의 관찰을 위해 프로젝트를 세팅한다.

Member(다대일의 다쪽 연관관계)

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String loginId;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String nickName;

    @Builder
    public Member(String name,String loginId,String password,String nickName,Team team){
        this.name=name;
        this.loginId=loginId;
        this.password=password;
        this.nickName=nickName;
        this.team=team;
    }


    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}

Team(다대일의 일쪽 연관관계)

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Team {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

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

    @OneToMany(mappedBy = "team",cascade = CascadeType.ALL,orphanRemoval = true)
    private List<TeamImage> teamImages=new ArrayList<>();

    @Builder
    public Team(String name){
        this.name=name;
    }

}

TeamImage(다대일의 다쪽 연관관계)

Team 과 TeamImage 의 관계를 세팅한다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class TeamImage {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String accessUrl;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    @Builder
    public TeamImage(String accessUrl,Team team){
        this.accessUrl=accessUrl;
        this.team=team;
    }

}

주의점 1: distinct를 사용해야 하는 경우

Hibernate Version

실험 환경 기준은 하이버네이트 버전 5.6.14 버전이다.

사용 배경

@ManyToOne 에서 다(Member) 쪽에서 일(Team)쪽을 fetch Join 을 사용하여 일(Team) 의 필드 또한 함께 영속화 할 수 있다는 것을 알게된 A는

이제는 일(Team) 을 조회할 때 그 와 연관된 다(Member) 의 필드를 함께 조회하고 싶어 일(Team) 에서 다(Member)에 Fetch Join 을 적용했다.

아래와 같이 말이다.


public interface TeamRepository extends JpaRepository<Team,Long> {
    @Query("select t from Team t join fetch t.members")
    List<Team> findTeamWithFetchMembers();
}

테스트 코드 결과 예상 및 조회

A는 처음에 위 코드를 짤때 원하는 결과는 각각의 팀을 한번씩 조회하고 그와 연관된 Member 또한 같이 조회하는 결과를 원했다.

즉 아래와 같이 테스트 코드를 통해 결과를 확인하면

@Test
    @DisplayName("팀과 Member 의 1대다 Fetch Join 테스트 Pageable")
    public void SecondTest() {
        Team teamA=Team.builder().name("teamA").build();
        Team teamB=Team.builder().name("teamB").build();


        Member member1=Member.builder().loginId("afdf").password("adfdf").nickName("일진우").name("이진우1").team(teamA).build();
        Member member2=Member.builder().loginId("bfdf").password("bdfdf").nickName("이진우").name("이진우2").team(teamA).build();
        Member member3=Member.builder().loginId("cfdf").password("cdfdf").nickName("삼진우").name("이진우3").team(teamA).build();
        Member member4=Member.builder().loginId("dfdf").password("ddfdf").nickName("사진우").name("이진우4").team(teamB).build();
        Member member5=Member.builder().loginId("efdf").password("edfdf").nickName("오진우").name("이진우5").team(teamB).build();

        teamA.getMembers().add(member1);
        teamA.getMembers().add(member2);
        teamA.getMembers().add(member3);
        teamB.getMembers().add(member4);
        teamB.getMembers().add(member5);

        teamRepository.save(teamA);
        teamRepository.save(teamB);


        em.clear();

        List<Team> teams=teamRepository.findTeamWithFetchMembersWithPageable(PageRequest.of(0,2));

        for(Team t: teams){
            System.out.println("팀의 이름: "+t.getName());
            System.out.println(t.getMembers().size());
        }

    }

즉 예상된 출력결과는 팀의 이름 A: 와 팀의 Member 에 대한 각각의 이름 + 팀의 이름 :B 와 팀 B의 Member 에 대한 각각의 이름

이렇게만 나오는 것을 예상했지만 출력결과는 그렇지 않다.

팀의 이름: teamA
멤버의 이름:이진우1
멤버의 이름:이진우2
멤버의 이름:이진우3
팀의 이름: teamA
멤버의 이름:이진우1
멤버의 이름:이진우2
멤버의 이름:이진우3
팀의 이름: teamA
멤버의 이름:이진우1
멤버의 이름:이진우2
멤버의 이름:이진우3
팀의 이름: teamB
멤버의 이름:이진우4
멤버의 이름:이진우5
팀의 이름: teamB
멤버의 이름:이진우4
멤버의 이름:이진우5

그 이유는 쿼리 내용을 확인하면 짐작이 가능하다.

select
        team0_.id as id1_1_0_,
        members1_.member_id as member_i1_0_1_,
        team0_.name as name2_1_0_,
        members1_.created_at as created_2_0_1_,
        members1_.updated_at as updated_3_0_1_,
        members1_.login_id as login_id4_0_1_,
        members1_.name as name5_0_1_,
        members1_.nick_name as nick_nam6_0_1_,
        members1_.password as password7_0_1_,
        members1_.team_id as team_id8_0_1_,
        members1_.team_id as team_id8_0_0__,
        members1_.member_id as member_i1_0_0__ 
    from
        team team0_ 
    inner join
        member members1_ 
            on team0_.id=members1_.team_id

team의 아이디와 member의 team_id가 같은데에 join이 일어난다.

그럼 위 같은 쿼리의 결과로 나타나는 테이블의 형태는
팀A 와 멤버 1에 대한 레코드 +
팀A 와 멤버 2에 대한 레코드 +
팀A 와 멤버 3에 대한 레코드 +
팀B 와 멤버 4에 대한 레코드 +
팀 B와 멤버 5에 대한 레코드

이런 식으로 나타날 것이다.
따라서 위와 같이 문제의 출력결과가 발생한다.
이를 방지하려면 distinct 키워드를 통해 방지할 수 있다.
이는 객체의 중복을 방지하게 한다.

수정

public interface TeamRepository extends JpaRepository<Team,Long> {
   @Query("select distinct t from Team t join fetch t.members")
   List<Team> findTeamWithFetchMembers();
}

결과 확인

팀의 이름: teamA
멤버의 이름:이진우1
멤버의 이름:이진우2
멤버의 이름:이진우3
팀의 이름: teamB
멤버의 이름:이진우4
멤버의 이름:이진우5

위와 같이 Team 이 중복되어 조회되지 않는 것을 볼 수 있다.

스프링 부트 3이상에서는...

HiberName 버젼의 상승과 함께 초기처럼 코드를 작성해도 자동으로 중복은 제거해서 아래와 같은 결과가 나타난다.

팀의 이름: teamA
멤버의 이름:이진우1
멤버의 이름:이진우2
멤버의 이름:이진우3
팀의 이름: teamB
멤버의 이름:이진우4
멤버의 이름:이진우5

주의점 2: 일대다 Fetch Join 에서 페이징 처리

사용 배경

A는 주의점 1 과 같은 결과를 학습했으므로

이제 페이징 처리를 일대다 페치조인에 적용하여 제한된 양을 조회하고 싶다고 한다.

따라서 아래와 같이 코드를 작성하였다.

public interface TeamRepository extends JpaRepository<Team,Long> {

    @Query("select t from Team t join fetch t.members")
    List<Team> findTeamWithFetchMembersWithPageable(Pageable pageable);

}

테스트 코드 및 에러 확인

따라서 아래와 같이 테스트를 진행하여 결과를 확인한다.

@Test
    @DisplayName("팀과 Member 의 1대다 Fetch Join 테스트 Pageable")
    public void SecondTest() {
        Team teamA=Team.builder().name("teamA").build();
        Team teamB=Team.builder().name("teamB").build();


        Member member1=Member.builder().loginId("afdf").password("adfdf").nickName("일진우").name("이진우1").team(teamA).build();
        Member member2=Member.builder().loginId("bfdf").password("bdfdf").nickName("이진우").name("이진우2").team(teamA).build();
        Member member3=Member.builder().loginId("cfdf").password("cdfdf").nickName("삼진우").name("이진우3").team(teamA).build();
        Member member4=Member.builder().loginId("dfdf").password("ddfdf").nickName("사진우").name("이진우4").team(teamB).build();
        Member member5=Member.builder().loginId("efdf").password("edfdf").nickName("오진우").name("이진우5").team(teamB).build();

        teamA.getMembers().add(member1);
        teamA.getMembers().add(member2);
        teamA.getMembers().add(member3);
        teamB.getMembers().add(member4);
        teamB.getMembers().add(member5);

        teamRepository.save(teamA);
        teamRepository.save(teamB);


        em.clear();

        List<Team> teams=teamRepository.findTeamWithFetchMembersWithPageable(PageRequest.of(0,2));

        for(Team t: teams){
            System.out.println("팀의 이름: "+t.getName());
            for(int i=0;i<t.getMembers().size();i++){
                System.out.println("멤버의 이름:"+t.getMembers().get(i).getName());
            }
        }

    }

결과를 확인하던 중 아래와 같은 에러가 발생한다.

2024-03-16T15:10:47.651+09:00  WARN 14408 --- [    Test worker] org.hibernate.orm.query                  : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

이는 메모리에서 페이징 처리를 진행한다는 의미로
기존처럼 DB에서 limit 로 제한된 갯수만큼을 가져오는 것이 아니라 메모리에서 그 작업을 진행하기 때문에 그런 부분에서 위험하다.

실제로 일대다 페치조인+ 페이징 처리에서 발생하는 쿼리를 조회하면

select
        t1_0.id,
        m1_0.team_id,
        m1_0.member_id,
        m1_0.login_id,
        m1_0.name,
        m1_0.nick_name,
        m1_0.password,
        t1_0.name 
    from
        team t1_0 
    join
        member m1_0 
            on t1_0.id=m1_0.team_id

위와 같이 limit 이 보이지 않는다는 것을 알 수 있다.

반면 다대일 페치조인 + 페이징 처리를 진행하면

List<Member> members=memberRepository.findMemberWithTeamFetch(PageRequest.of(0,2));
select
        m1_0.member_id,
        m1_0.login_id,
        m1_0.name,
        m1_0.nick_name,
        m1_0.password,
        t1_0.id,
        t1_0.name 
    from
        member m1_0 
    join
        team t1_0 
            on t1_0.id=m1_0.team_id 
    limit
        ?,?

위와 같이 limit 이 존재한다.


주의점 3: DTO + Fetch JOIN

사용 배경

A는 Fetch Join 을 사용해서 Member 의 데이터의 일부와 Team 의 데이터 일부를 함께 DTO로 반환한다.

@Data
@NoArgsConstructor
public class MemberResponseDto {
    private String memberNickName;
    private String teamName;

    @Builder
    public MemberResponseDto(String memberNickName,String teamName){
        this.memberNickName=memberNickName;
        this.teamName=teamName;
    }
}

또한 위 DTO 를 설계하기 위해

 @Query("select m from Member m join fetch m.team t")
    List<Member> findMemberWithTeamFetch(Pageable pageable);

위 메서드를 사용하고 있는데 기본적으로 Member 의 내용 전부를 조회하다 보니 아래와 같이

select
        m1_0.member_id,
        m1_0.login_id,
        m1_0.name,
        m1_0.nick_name,
        m1_0.password,
        t1_0.id,
        t1_0.name 
    from
        member m1_0 
    join
        team t1_0 
            on t1_0.id=m1_0.team_id 
    limit
        ?,?

실제 사용하지 않는 password, id 등 필요없는 부분을 함께 조회하고 있다.

위 과정에서 성능이 걱정되었던 A 는 이를 DTO로 조회하여 해결하려 한다.

코드 작성

따라서 아래와 같이 코드를 작성한다.

@Query("select new com.ceos19.everyTime.member.domain.dto.MemberResponseDto(m.nickName,t.name) from Member m join fetch m.team t")
    List<MemberResponseDto> findMemberDTOWithTeamNameWithFetchJoin();

테스트 코드 및 에러 확인

위에 대해 테스트를 작성하기 위해 아래와 같은 코드를 작성한다.

@Test
    @DisplayName("Member 입장에서 테스트")
    public void MemberTest() {
        Team teamA=Team.builder().name("teamA").build();
        Team teamB=Team.builder().name("teamB").build();


        Member member1=Member.builder().loginId("afdf").password("adfdf").nickName("일진우").name("이진우1").team(teamA).build();
        Member member2=Member.builder().loginId("bfdf").password("bdfdf").nickName("이진우").name("이진우2").team(teamA).build();
        Member member3=Member.builder().loginId("cfdf").password("cdfdf").nickName("삼진우").name("이진우3").team(teamA).build();
        Member member4=Member.builder().loginId("dfdf").password("ddfdf").nickName("사진우").name("이진우4").team(teamB).build();
        Member member5=Member.builder().loginId("efdf").password("edfdf").nickName("오진우").name("이진우5").team(teamB).build();

        teamA.getMembers().add(member1);
        teamA.getMembers().add(member2);
        teamA.getMembers().add(member3);
        teamB.getMembers().add(member4);
        teamB.getMembers().add(member5);

        teamRepository.save(teamA);
        teamRepository.save(teamB);


        em.clear();

        List<MemberResponseDto> memberResponseDtos=memberRepository.findMemberDTOWithTeamNameWithFetchJoin();

        for(MemberResponseDto memberResponseDto : memberResponseDtos){
            System.out.println("팀의 이름: "+memberResponseDto.getTeamName());
            System.out.println("멤버의 이름: "+memberResponseDto.getMemberNickName());
        }

    }

하지만 아래와 같은 에러가 발생한다.

Caused by: org.hibernate.query.SemanticException: 
query specified join fetching, but the owner of the fetched 
association was not present in the select list [SqmSingularJoin
(com.ceos19.everyTime.member.domain.Member(m).team(t) : team)]

페치 연관관계가 select list에 없다고 한다.

해결방법

fetch join 을 없애고 join으로만 사용한다.

 select
        m1_0.nick_name,
        t1_0.name 
    from
        member m1_0 
    join
        team t1_0 
            on t1_0.id=m1_0.team_id

그럼 위와 같은 쿼리로 최적화를 진행할 수 있다.


주의점 4 : 1쪽에서 여러 연관관계를 fetch join 할 때

사용 배경

A는 Team 에서 Team 의 이미지와 Team 의 Member 에 대한 내용 을 함께 영속화해서 가져오고 싶다.

코드 작성

따라서 아래와 같은 코드를 작성한다.

@Query("select t from Team t join fetch t.members join fetch t.teamImages")
    List<Team> findTeamWithFetchMembersAndImages();

테스트 코드 + 에러 확인

   @Test
    @DisplayName("팀과 Member 의 1대다 Fetch Join 테스트 Pageablefadfa")
    public void ThirdTest() {
        Team teamA=Team.builder().name("teamA").build();
        Team teamB=Team.builder().name("teamB").build();


        Member member1=Member.builder().loginId("afdf").password("adfdf").nickName("일진우").name("이진우1").team(teamA).build();
        Member member2=Member.builder().loginId("bfdf").password("bdfdf").nickName("이진우").name("이진우2").team(teamA).build();
        Member member3=Member.builder().loginId("cfdf").password("cdfdf").nickName("삼진우").name("이진우3").team(teamA).build();
        Member member4=Member.builder().loginId("dfdf").password("ddfdf").nickName("사진우").name("이진우4").team(teamB).build();
        Member member5=Member.builder().loginId("efdf").password("edfdf").nickName("오진우").name("이진우5").team(teamB).build();

        TeamImage teamImage=TeamImage.builder().team(teamA).accessUrl("www").build();
        TeamImage teamImage1=TeamImage.builder().team(teamA).accessUrl("www").build();

        teamA.getMembers().add(member1);
        teamA.getMembers().add(member2);
        teamA.getMembers().add(member3);
        teamB.getMembers().add(member4);
        teamB.getMembers().add(member5);

        teamA.getTeamImages().add(teamImage);
        teamA.getTeamImages().add(teamImage1);

        teamRepository.save(teamA);
        teamRepository.save(teamB);


        em.clear();

        List<Team> teams=teamRepository.findTeamWithFetchMembersAndImages();

        for(Team t: teams){
            System.out.println("팀의 이름: "+t.getName());
            System.out.println(t.getMembers().size());
        }

    }

위와 같은 테스트 코드를 작성하고 돌리면

그럼 아래와 같은

Caused by: java.lang.IllegalArgumentException: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.ceos19.everyTime.member.domain.Team.members, com.ceos19.everyTime.member.domain.Team.teamImages]
	at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:141)
	at org.hibernate.query.spi.AbstractSelectionQuery.list(AbstractSelectionQuery.java:378)
	at org.hibernate.query.Query.getResultList(Query.java:119)

여러 객체에 대해 페치조인을 사용하지 말라는 오류를 발생시킨다.

profile
기록을 통해 실력을 쌓아가자

0개의 댓글