JPA를 사용하다 보면 바로 N+1의 문제에 마주치고 바로 Fetch Join을 접하게 됩니다.
처음 Fetch Join을 접했을 때 왜 일반 Join으로 해결하면 안되는지에 대해 명확히 정리가 안된 채로 Fetch Join을 사용했습니다.
어떤 문제 때문에 일반 join으로 N+1을 해결하지 못하는지를 시작으로 해서 Fetch Join, Join의 차이점을 정리해보겠습니다.
사용한 예제는 github에 업로드해두었으니 참고 바랍니다.
https://github.com/cobiyu/join-fetchjoin-compare
cobiyu/join-fetchjoin-compare
Contribute to cobiyu/join-fetchjoin-compare development by creating an account on GitHub.
github.com
Join, Fetch Join 차이점 요약
- 일반 Join
- Fetch Join과 달리 연관 Entity에 Join을 걸어도 실제 쿼리에서 SELECT 하는 Entity는
오직 JPQL에서 조회하는 주체가 되는 Entity만 조회하여 영속화 - 조회의 주체가 되는 Entity만 SELECT 해서 영속화하기 때문에 데이터는 필요하지 않지만 연관 Entity가 검색조건에는 필요한 경우에 주로 사용됨
- Fetch Join과 달리 연관 Entity에 Join을 걸어도 실제 쿼리에서 SELECT 하는 Entity는
- Fetch Join
- 조회의 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 SELECT 하여 모두 영속화
- Fetch Join이 걸린 Entity 모두 영속화하기 때문에 FetchType이 Lazy인 Entity를 참조하더라도
이미 영속성 컨텍스트에 들어있기 때문에 따로 쿼리가 실행되지 않은 채로 N+1문제가 해결됨
Join, Fetch Join 차이점 검증 Test
테스트할 Entity
@Builder
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
// @ToString(exclude = "members")
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String name;
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
@Builder.Default
private List<Member> members = new ArrayList<>();
public void addMember(Member member){
member.setTeam(this);
members.add(member);
}
}
Member.java
(https://github.com/cobiyu/join-fetchjoin-compare/blob/master/src/main/java/com/cobi/fetchjoin/Member.java)
@Builder
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString(exclude = "team")
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
public String name;
public int age;
@ManyToOne(fetch = FetchType.LAZY)
public Team team;
public Member(String name, int age, Team team) {
this.name = name;
this.age = age;
this.team = team;
}
}
일반 Join을 이용한 N+1 해결?
Team과 Member를 조회할 때 일반 join을 이용하는 경우와 이에 따른 실행되는 쿼리입니다.
// TeamRepository.java
@Query("SELECT distinct t FROM Team t join t.members")
public List<Team> findAllWithMemberUsingJoin();
일반적으로 생각하는 Team과 Member가 join 된 형태의 쿼리가 실행되기는 합니다.
특이한 점은 가져오는 컬럼들을 보면 Team의 컬럼인 id와 name만을 가져오고 있습니다.
이 상태에서 join을 이용한 결과를 toString()으로 출력해보겠습니다.
// TeamService.java
@Transactional
public List<Team> findAllWithMemberUsingJoin(){
return teamRepository.findAllWithMemberUsingJoin();
}
// FetchJoinApplicationTests.java
@BeforeEach
public void init(){
teamService.initialize();
}
@Test
public void joinTest() {
List<Team> memberUsingJoin = teamService.findAllWithMemberUsingJoin();
System.out.println(memberUsingJoin);
}
쿼리는 join된 형태로 실행되었지만 갑자기 LazyInitializationException이 발생합니다.
(LazyInitializationException 의 일반적인 원인은 Session(Transaction)없이 Lazy Entity를 사용하는 경우가 주된 원인입니다.)
breakpoint를 찍어보면 왜 LazyInitializationException 이 발생했는지 알 수 있습니다.
쿼리를 보면 분명 join을 했는데 각 Team의 Lazy Entity인 memebers가 아직 초기화되지 않았다는 상태를 보여줍니다.
실제로 일반 join은 실제 쿼리에 join을 걸어주기는 하지만 join대상에 대한 영속성까지는 관여하지 않습니다.
오직 join만 걸고 실제 영속성 컨텍스트에는 SELECT 대상만을 담게 됩니다.
위 내용을 기반으로 해서 다시 생각해보면 아래와 같은 정황으로 LazyInitializationException이 발생하게 된 것입니다.
1. 일반 join으로 Team Entity 초기화 완료
2. 하지만 일반 join은 연관 Entity까지 초기화하지 않기 때문에 Member는 초기화되지 않음
3. toString()으로 아직 초기화되지 않은 members에 접근하면서 LazyInitializationException 발생
(실제로 Team에 @ToString(exclude="members")를 설정하게 되면 members에 접근하지 않게 되고 LazyInitializationException 또한 발생하지 않게 됩니다.)
Fetch Join을 이용한 N+1 해결!
Fetch Join을 사용하는 코드와 이에 따라 실행되는 쿼리는 아래와 같습니다.
// TeamRepository.java
@Query("SELECT distinct t FROM Team t join fetch t.members")
public List<Team> findAllWithMemberUsingFetchJoin();
일반 Join과 join의 형태는 똑같지만 SELECT하는 컬럼에서부터 차이가 보입니다.
- 일반 Join : join 조건을 제외하고 실제 질의하는 대상 Entity에 대한 컬럼만 SELECT
- Fetch Join : 실제 질의하는 대상 Entity와 Fetch join이 걸려있는 Entity를 포함한 컬럼 함께 SELECT
위처럼 쿼리에 사용되는 컬럼부터가 일반 Join과 Fetch Join에 차이가 있습니다.
Fetch Join 실행결과를 toString()으로 출력해보면 아래와 같이 모든 Team과 Member가 담긴 것을 확인할 수 있습니다.
// TeamService.java
@Transactional
public List<Team> findAllWithMemberUsingFetchJoin(){
return teamRepository.findAllWithMemberUsingFetchJoin();
}
//FetchJoinApplicationTests.java
@Test
public void fetchJoinTest() {
List<Team> memberUsingFetchJoin = teamService.findAllWithMemberUsingFetchJoin();
System.out.println(memberUsingFetchJoin);
}
실행 결과
[
Team(
id=1,
name=team1,
members=[
Member(
id=1,
name=team1member1,
age=1
),
Member(
id=2,
name=team2member2,
age=2
),
Member(
id=3,
name=team3member3,
age=3
)
]
),
Team(
id=2,
name=team2,
members=[
Member(
id=4,
name=team2member4,
age=4
),
Member(
id=5,
name=team2member5,
age=5
)
]
)
]
그렇다면 일반 Join은 언제 쓰지?
어떻게 보면 무조건 Fetch Join이 좋아 보이기도 합니다. 하지만 일반 Join이 쓰임새도 분명 있습니다.
JPA는 기본적으로 "DB ↔ 객체" 의 일관성을 잘 고려해서 사용해야 하기 때문에
로직에 꼭 필요한 Entity만을 영속성 컨텍스트에 담아놓고 사용해야 합니다.
그러니 무작정 Fetch Join을 사용해서 전부 영속성 컨텍스트에 올려서 쓰기보다는
일반 Join을 적절히 이용하여 필요한 Entity만 영속성 컨텍스트에 올려서 사용하는 것이 괜한 오작동을 미리 방지할 수 있는 방법이기도 합니다.
아래 예제와 같은 경우에는 Fetch Join보다는 일반 Join이 훨씬 더 효과적입니다.
예제
"team2member4" 라는 이름을 가지는 member가 속해있는 Team조회
(member의 정보는 필요하지 않음)
연관 관계가 있는 Entity가 쿼리 검색 조건에는 필요하지만 실제 데이터는 필요하지 않은 상황입니다.
이전 설명에서 일반 Join은 join대상에 대해서는 영속성 컨텍스트에 담지 않는다고 했습니다.
일반 Join의 이런 특성은 이번 예제에서 해결해야 할 상황에 적합해 보입니다.
아래 코드를 실행해 보겠습니다.
(아래 예제에서 사용된 JPQL은 예제를 위해 사용한 JPQL이며 아래처럼 컬렉션에 조건을 주는 형태의 JPQL은 지양되어야 합니다.
자세한 내용은 다음 포스팅에서 자세히 다루겠습니다.)
// TeamRespotory.java
@Query("SELECT distinct t FROM Team t join t.members m where m.name = :memberName")
public List<Team> findByMemberNameWithMemberUsingJoin(String memberName);
// TeamService.java
@Transactional
public List<Team> findByMemberNameWithMemberUsingJoin(String memberName){
return teamRepository.findByMemberNameWithMemberUsingJoin(memberName);
}
// FetchJoinApplicationTests.java
@Test
public void joinConditionTest() {
List<Team> memberUsingJoin = teamService.findByMemberNameWithMemberUsingJoin("team2member4");
System.out.println(memberUsingJoin);
}
실행된 쿼리
사실 위 예제는 이전 일반 Join예제와 동일한 이유로 LazyInitializationException이 발생합니다.
breakpoint를 걸어서 결과를 확인해보겠습니다.
"team2member4"라는 이름을 가진 member가 포함된 "team2"라는 Team이 조회되었습니다.
역시나 일반 join에 사용된 Member는 초기화되지 않았다는 상태를 보여주고 있습니다.
이처럼 검색조건에만 연관 Entity가 사용될 경우에는 일반 join을 적극적으로 사용하는 것이 효율적임을 알 수 있습니다.
'JPA' 카테고리의 다른 글
@Transactional에 관한 고찰 part 2 (2) | 2022.08.02 |
---|---|
@Transactional에 관한 고찰 (or 반성) (0) | 2022.05.08 |
Proxy형태로 동작하는 JPA @Transactional (8) | 2021.02.10 |
[JPA] OneToOne 성능 튜닝 사례 1 (1) | 2020.08.18 |
JPQL과 영속성 컨텍스트의 관계 (1) | 2020.07.17 |