Spring Data JPA 게시글은 대부분 인프런의 김영한님의 강의인 '실전! 스프링 데이터 JPA' 기반으로 내용을 정리했습니다.
@EntityGraph
연관된 엔티티들을SQL 한 번에 조회하는 방법이다. EntityGraph를 제대로 이해하려면 fetch join에 대해 자세히 알고 있어야 한다. 그래서 본론에 들어가기 앞서 fetch join에 대해 먼저 알아보자.
fetch join
- MemberRepositoryTest.class
// Member.class
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="team_id")
private Team team;
지금 Member 기준에서 Member와 Team의 연관관계는 다대일이다. 그리고 연관 관계 매핑 속성에 fetch = FetchType.LAZY가 걸려있다. 지연 로딩이라는 건데 무조건 모든 연관 관계에서는 지연 로딩 설정을 해주어야 한다. (연관 관계 종류에 따라 자동으로 LAZY가 걸려있는 것도 있지만 아닌 것도 있음)
@Test
public void findMemberLazy() {
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
teamRepository.save(teamA);
teamRepository.save(teamB);
Member member1 = new Member("member1", 10, teamA);
Member member2 = new Member("member2", 10, teamB);
memberRepository.save(member1);
memberRepository.save(member2);
em.flush();
em.clear();
List<Member> members = memberRepository.findAll();
for (Member member : members) {
System.out.println("member = " + member.getUsername());
}
}
Member와 Team을 적절하게 설정해주고 Member에 관해 findAll() 후 데이터를 출력해보면 신기한 점을 찾아볼 수 있다.
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_
member = member1
member = member2
쿼리를 보면 team에 대한 것은 싹 사라져있다. 원래대로라면 Member와 team이 join 해야 하지 않나?라고 생각할 수 있는데 지연 로딩을 걸면 team 객체의 값을 직접적으로 호출하기 전까지는 team은 가짜 객체(프록시)로 존재해 쓸데없는 성능 낭비를 줄일 수 있다. 그럼 team 데이터를 호출해보자.
프록시를 잘 모른다면 아래 포스팅을 참고하자.
for (Member member : members) {
System.out.println("member = " + member.getUsername());
System.out.println("member.getTeam().getName() = " + member.getTeam().getName()); // 추가
}
team까지 호출하면 select 쿼리가 총 몇 번 나갈까? 원래 생각대로라면 두 번이 나가야 정상인데 출력 로그를 보면 select 쿼리가 세 번이 나간 걸 확인할 수 있다.
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_
member = member1
select
team0_.team_id as team_id1_1_0_,
team0_.name as name2_1_0_
from
team team0_
where
team0_.team_id=?
member.getTeam().getName() = teamA
member = member2
select
team0_.team_id as team_id1_1_0_,
team0_.name as name2_1_0_
from
team team0_
where
team0_.team_id=?
member.getTeam().getName() = teamB
이런 문제를 N + 1 문제라고 한다. 쿼리를 잘 보면 첫 번째 쿼리는 memberRepository.findAll()에 관한 쿼리이고, 두 번째와 세 번째 쿼리는 member.getTeam().getName()에 대한 쿼리이다. member.getTeam()까지만 해도 Team 객체에 직접적인 접근이 아니기 때문에 두세 번째 쿼리가 나가지 않겠지만 getName()을 한 순간 Team에서 직접 접근하여 데이터를 가져와야 하기 때문에 프록시 객체로 해결할 수 없어 DB에 데이터를 가져오기 위해 추가적인 쿼리를 두 번 날리는 것이다. fetch join을 잘 모른다면 아래 포스팅을 참고하자.
위에 지연 로딩에 관한 문제점을 해결하기 위해 JPA에서는 fetch join이란 것을 지원한다.
- MemberRepository.interface
@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();
- MemberRepositoryTest.class
위에서 작성했던 test 코드에 아래 부분을 수정 후 돌려보자.
List<Member> members = memberRepository.findMemberFetchJoin(); // 수정
for (Member member : members) {
System.out.println("member = " + member.getUsername());
System.out.println("member.getTeam().getName() = " + member.getTeam().getName());
}
select
member0_.member_id as member_i1_0_0_,
team1_.team_id as team_id1_1_1_,
member0_.age as age2_0_0_,
member0_.team_id as team_id4_0_0_,
member0_.username as username3_0_0_,
team1_.name as name2_1_1_
from
member member0_
left outer join
team team1_
on member0_.team_id=team1_.team_id
member = member1
member.getTeam().getName() = teamA
member = member2
member.getTeam().getName() = teamB
join으로 team에 대한 데이터를 한 번에 가져와서 select가 한 번만 일어난다. 언뜻보면 즉시 로딩(EAGER)과 별 차이점이 없어 보이기도 하다. 과연 그럴까? 즉시 로딩으로 설정 후 쿼리를 구경해보자.
즉시 로딩과 fetch join 차이점
- Member.class
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name="team_id")
private Team team;
- MemberRepositoryTest.class
//List<Member> members = memberRepository.findMemberFetchJoin();
List<Member> members = memberRepository.findAll();
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_
select
team0_.team_id as team_id1_1_0_,
team0_.name as name2_1_0_
from
team team0_
where
team0_.team_id=?
select
team0_.team_id as team_id1_1_0_,
team0_.name as name2_1_0_
from
team team0_
where
team0_.team_id=?
member = member1
member.getTeam().getName() = teamA
member = member2
member.getTeam().getName() = teamB
즉시 로딩의 경우에도 N + 1 문제가 발생한다. 지연 로딩과 다른 점은 프록시를 만들지 않고 애초에 member를 조회할 때 team까지 싹 다 조회해버린다. 그렇기 때문에 지연 로딩처럼 member.getTeam().getName() 출력문 사이사이에 select 문이 있지 않고 member 다음에 주르륵 딸려오는 것이다. 간단하게 정리하자면 즉시 로딩은 N + 1 문제가 발생하고, fetch join은 N + 1 문제가 발생하지 않는다.
이제 본론으로 들어가보자면 스프링 데이터 JPA에서 fetch join을 사용하려면 매번 JPQL을 써줘야 하는 귀찮음이 발생한다. 그래서 스프링 데이터 JPA에서 @EntityGraph를 지원하며 이 기능을 사용하면 추가적인 JPQL 없이 fetch join을 사용할 수 있다.
@EntityGraph
- MemberRepository.interface
@EntityGraph를 사용하기 위해 일단 JpaRepository 인터페이스에 있는 findAll()을 오버라이딩해야 한다.
// JpaRepository.interface
/*
* (non-Javadoc)
* @see org.springframework.data.repository.CrudRepository#findAll()
*/
@Override
List<T> findAll();
// MemberRepository.interface
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll()
- MemberRepositoryTest.class
List<Member> members = memberRepository.findAll();
테스트 코드를 위와 같이 수정하고 실행해보자.
select
member0_.member_id as member_i1_0_0_,
team1_.team_id as team_id1_1_1_,
member0_.age as age2_0_0_,
member0_.team_id as team_id4_0_0_,
member0_.username as username3_0_0_,
team1_.name as name2_1_1_
from
member member0_
left outer join
team team1_
on member0_.team_id=team1_.team_id
member = member1
member.getTeam().getName() = teamA
member = member2
member.getTeam().getName() = teamB
그러면 위에서 JPQL을 이용해 fetch join을 한 쿼리와 똑같이 출력된다.
참고로 JPQL과 @EntityGraph는 같이 사용해도 된다.
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();
메서드 이름으로 쿼리 생성 기능에도 @EntityGraph를 사용할 수 있다.
메서드 이름으로 쿼리 생성 기능을 잘 모른다면 아래 포스팅을 참고하자.
@EntityGraph(attributePaths = {"team"})
List<Member> findEntityGraphByUsername(@Param("username") String username);
NamedEntityGraph
JPA 표준 스펙 2.2부터 들어간 기능이다.
- Member.class
@NamedEntityGraph(name = "Member.all" , attributeNodes = @NamedAttributeNode("team"))
public class Member {
- MemberRepository.interface
@EntityGraph("Member.all")
List<Member> findEntityGraphByUsername(@Param("username") String username);
Member class 상단에 @NamedEntityGraph() 애노테이션을 추가하고, 사용할 때는 name을 이용해서 호출하면 된다.
! 간단한 기능의 메서드의 경우 @EntityGraph를 사용해도 되지만 복잡한 기능의 경우 꼭 JPQL로 풀어야 할 때가 있기도 하다. 그럴 경우엔 그냥 JPQL을 사용하는 게 맞다.