JPA를 사용하며, Fetch Join에 대한 이야기를 많이 들어봤습니다. 이번엔 FetchJoin에 대해서 알아보겠습니다. 

1. Fetch Join이란?

Fetch Join은 JPQL에서 성능 최적화를 위해서 사용하는 기능입니다. 이것은 SQL의 조인 종류가 아닙니다. 

(SQL 조인에는 Inner Join, Outer Join(left, right, full)이 있습니다.)

 

어떤 상황에 성능 최적화를 진행하는가?? -> JPA에는 엔티티에 관계를 맵핑할 때 지연 로딩과 즉시 로딩 설정을 할 수 있습니다.

 

즉시 로딩 -> 어떠한 엔티티가 조회되었을 때 연관된 엔티티도 모두 함께 조회

지연 로딩 -> 어떠한 엔티티가 조회되었을 때 연관된 엔티티는 Proxy로 들어가게 되고, 실제로 사용될 때 DB에서 조회해서 사용

 

즉시 로딩은 매우 매력적입니다. 그 이유는 연관된 엔티티를 추가 SQL로 조회할 필요 없이 모두 가져올 수 있기 때문입니다. 하지만 문제점이 있습니다.

 

1. 엔티티를 조회하지만, 연관된 엔티티는 필요하지 않음에도 조회를 하기 때문에 성능상 이슈가 있습니다.

2. 사용하지 않는 연관된 엔티티를 찾아오기 위해서 개발자가 의도하지 않은 Query가 발생하여 N+1문제가 발생할 수 있습니다. 

 

따라서 성능상 이슈와 N+1 문제로 인해서 지연 로딩을 사용합니다. 그렇다면 의문이 들 수 있습니다. 지연 로딩일 때 연관된 엔티티를 사용할 때도 어차피 한 번의 Query가 더 발생하는 부분입니다. 이때 Fetch Join이 진가를 발휘합니다.

 

엔티티를 조회할 때 Fetch Join을 사용한다면, 개발자의 의도 하에 한 번의 Query로 연관된 엔티티를 같이 조회할 수 있습니다. 따라서 연관된 엔티티가 필요하지 않을 땐 Proxy가 들어오며, 필요할 때는 실제 객체를 조회할 수 있게 되어 즉시 로딩의 문제점을 보완하게 됩니다. 

(단 N:1 관계에서 1쪽에서 Fetch Join을 사용한다면 페이징이 되지 않으므로 주의해야합니다.)


2. 예시에 사용될 Entity 설명

1. Member

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString(exclude = {"team"})
public class Member {


    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name",unique = true)
    private String name;

    @Column(name = "age")
    private int age;

    @Builder
    public Member(String name, int age){
        this.name=name;
        this.age=age;
    }

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

    public void changeAge(int age){
        this.age=age;
    }

    public void addTeam(Team team){
        team.getMembers().add(this);
        this.team=team;
    }
}

2. Team

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

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

    @Column(name = "team_name")
    private String name;


    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();


    @Builder
    public Team(String name, Member member){
        this.name=name;
        member.addTeam(this);
    }
}

Member Team관계 설명

Member는 이름과 나이를 가지고 있으며, 한 팀에 속할 수 있습니다. 

Team은 팀 이름을 가지고 있으며, 여러 명의 Member를 가지고 있습니다. 

 

Member와 팀은 N:1 관계를 가지게 됩니다. N을 연관관계의 주인으로 설정해줍니다. 연관관계의 주인은 외래 키 관리자라고 말할 수 있습니다. 

1. Member

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

@ManyToOne 관계를 맵핑해줍니다. 이때 @XToOne 맵핑들은 로딩 전략 Default가 즉시 로딩이기 때문에 fetch = FetchType.Lazy로 설정하여, 지연 로딩으로 변경해줍니다. 

2. Team

@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();

@OneToMany 관계 맵핑을 해줍니다. 이때 @XToMany 맵핑들은 로딩 전략 Default가 지연 로딩이기에 따로 설정하지 않아도 됩니다. 하지만 연관관계의 주인은 N 쪽인 Member가 외래 키를 관리하므로 자신은 주인이 아님을 설정하는 것이 mappedby 속성입니다. 사실상 Member.team이 주인이지만 Member를 생략하고 team이라고 명시해줍니다.

3. 간단한 테스트 

Member가 성공적으로 팀을 생성하는지

@Test
@DisplayName("멤버가 팀을 생성")
void memberCreateTeam(){

    /*
    given
     */
    Member member = Member.builder().age(10).name("m1").build();
    Member saveMember = memberRepository.save(member);

    /*
    when
     */
    Team team = Team.builder().name("team1").member(saveMember).build();
    Team saveTeam = teamRepository.save(team);

    /*
    then
     */
    assertThat(saveTeam.getName()).isEqualTo("team1");
    assertThat(saveTeam.getMembers().size()).isEqualTo(1);
    assertThat(saveTeam.getMembers().get(0).getName()).isEqualTo("m1");
}

팀에 새로운 멤버를 추가가 되는지

@Test
@DisplayName("팀에 새로운 멤버 추가")
void teamAddMember(){
    /*
    given
     */
    Member member = Member.builder().age(10).name("m1").build();
    Member member2 = Member.builder().age(10).name("m2").build();
    Member saveMember = memberRepository.save(member);
    Member saveMember2 = memberRepository.save(member2);
    Team team = Team.builder().name("team1").member(saveMember).build();
    Team saveTeam = teamRepository.save(team);

    /*
    when
     */
    saveTeam.addMember(member2);
    Team result = teamRepository.findByName("team1").get();

    /*
    then
     */
    assertThat(result.getMembers().size()).isEqualTo(2);

}

4. FetchJoin 사용 미사용 차이

간단히 팀을 생성하고 추가되는지 알아보는 테스트를 진행했습니다. 그렇다면 이제 Fetch Join을 사용했을 때와 사용하지 않았을 때의 sql의 차이를 알아보겠습니다. 

1차 캐시를 비우기 위해서 EntityManager도 주입받습니다.

4-1. Fetch Join 미사용

아래와 같은 테스트를 진행했을 때 생기는 쿼리를 보겠습니다. 

@Test
@DisplayName("페치조인 사용하지 않고, 팀의 이름을 조회할 때")
@Transactional
void notFetchJoinMemberFindTeamName(){

    Member member = Member.builder().age(10).name("m1").build();
    Member saveMember = memberRepository.save(member);

    Team team = Team.builder().name("team1").member(saveMember).build();
    Team saveTeam = teamRepository.save(team);

    em.flush();
    em.clear();

    Member findMember = memberRepository.findNotFetchJoinByName("m1").get();

    Team memberTeam = findMember.getTeam();

    assertThat(memberTeam.getName()).isEqualTo("team1");
}

테스트 코드의 주석 번호와 함께 봐주세요.

1번: memberRepository.save()를 통해서 insert query가 생성됩니다.

2번, 3번: teamRepository.save()를 통해서 team에 대한 insert query가 생성되고, 해당되는 멤버는 영속화되어 있기 때문에 dirty checking을 통해서 update query가 생성된 것을 알 수 있습니다. 

--------em.flust() -> 1차 캐시에 있는 데이터를 DB에 반영, em.clear()-> 1차 캐시 초기화--------

4번: 1차 캐시에 데이터가 없기에 DB에 쿼리를 날려서 데이터를 받습니다.

5번: team에 대한 설정은 지연 로딩이므로 join query를 날려서 찾아오지 않고, team 객체가 사용될 때 단독으로 query를 날려서 찾아오는 것을 볼 수 있습니다

4-2. Fetch Join 사용

MemberRepository의 query를 Fetch Join을 하도록 변경해줍니다.

@Query("select m from Member m join fetch m.team where m.name = :name")
Optional<Member> findByName(String name);
@Test
@DisplayName("페치조인 사용하고, 팀의 이름을 조회할 때")
@Transactional
void FetchJoinMemberFindTeamName(){
    Member member = Member.builder().age(10).name("m1").build();
    Member saveMember = memberRepository.save(member);

    Team team = Team.builder().name("team1").member(saveMember).build();
    Team saveTeam = teamRepository.save(team);

    em.flush();
    em.clear();

    Member findMember = memberRepository.findFetchJoinByName("m1").get();

    Team memberTeam = findMember.getTeam();

    assertThat(memberTeam.getName()).isEqualTo("team1");
}

1~3번 동일

4번: Fetch Join을 사용하기 전과 다르게 query가 한 개 줄어들고, inner join이 된 것을 볼 수 있습니다.

따라서 Fetch Join을 사용함으로써 query가 하나 줄어들어 지연 로딩의 성능 최적화한 것을 볼 수 있습니다.

5. EntityGraph Fetch Join vs @Query Fetch Join

EntityGraph의 Fetch Join과 @Query Fetch Join의 차이를 알아보겠습니다.

EntityGraph는 명명법으로 Fetch Join을 사용하는 방법입니다. 간단하게 예시를 보겠습니다.

@EntityGraph(attributePaths = "team")
Optional<Member> findEntityGraphFetchJoinByName(String name);

query는 위와 같이 @EntityGraph의 attributePaths에 페치 조인 대상을 넣어줍니다. 

@Test
@DisplayName("엔티티 그래프 페치 조인 사용")
@Transactional
void EntityGraphFetchJoin(){
    Member member = Member.builder().age(10).name("m1").build();
    Member saveMember = memberRepository.save(member);

    Team team = Team.builder().name("team1").member(saveMember).build();
    Team saveTeam = teamRepository.save(team);

    em.flush();
    em.clear();

    Member findMember = memberRepository.findEntityGraphFetchJoinByName("m1").get();

    Team memberTeam = findMember.getTeam();

    assertThat(memberTeam.getName()).isEqualTo("team1");
}

1~3번 동일

4번: @Query로 Fetch Join을 사용했을 때는 inner join이었습니다. 하지만 @EntityGraph로 페치 조인을 사용한다면, left outer join으로 조회됩니다. 목적에 따라서 @EntityGraph와 @Query Fetch Join을 사용해야 하는 것을 알 수 있습니다. 


정리하기

Fetch Join

- 지연 로딩을 사용한다면 선택이 아닌 필수

- N+1문제와 성능 최적화를 노릴 수 있다. 

@EntityGraph vs @Query Fetch Join

- 둘 다 페치 조인

- @EntityGraph = left outer join

- @Query Fetch Join = inner join 

- 따라서 목적에 따라서 구분해서 사용해야 한다. 

지금까지 페치 조인에 대해서 알아봤습니다. 감사합니다.

 

모든 코드는 아래 링크에서 확인하실 수 있습니다. 

https://github.com/rlaehdals/JpaQueryMethodName

 

GitHub - rlaehdals/JpaQueryMethodName

Contribute to rlaehdals/JpaQueryMethodName development by creating an account on GitHub.

github.com

 

'SpringBoot > JPA' 카테고리의 다른 글

Spring-Data-JPA [7] Querydsl 설정 (gradle 7.x)  (0) 2022.06.02
Spring-Data-JPA [6] Index 적용하기  (3) 2022.05.16
Spring-data-JPA [4] Update와 @Query  (0) 2022.03.23
Spring-data-JPA [3] 명명법  (0) 2022.03.22
Spring-data-JPA(2) 개념  (0) 2022.01.06