JPA를 사용한다면 한 번쯤은 반드시 접해봤을 내용입니다.
한 줄로 요약하자면 다음과 같습니다.
연관관계가 설정되어있는 엔티티를 조회할 때, 조회된 엔티티의 개수 만큼 연관관계에 있는 엔티티에 대한 조회 쿼리가 추가로 발생하는 것을 의미합니다.
예를 들어볼까요?
엔티티로 Diary와 이에 대한 Comment가 있다고 가정할게요.
반대로 Comment 입장에서는
이들을 코드로 보자면 다음과 같습니다.
public class Diary {
@OneToMany(mappedBy = "diary")
private List<Comment> comments;
}
Diary 입장에서는 @OneToMany
관계로, 기본적으로 Lazy 로딩입니다.
public class Comment {
@ManyToOne
@JoinColumn(name="diary_id")
private Diary diary;
}
Comment입장에서는 @ManyToOne
관계로, 기본적으로 Eager 로딩입니다.
단순히 연관관계 없이 Comment 자체만을 접근한다면 문제가 없습니다.
하지만 일반적인 로직에서는 Diary와 연관된 Comment를 조회하여 두 엔티티에 모두 접근하여 사용하게 될 것입니다.
그런데 이 때, hibernate는 Diary를 조회한 뒤, 관련된 Comment에 접근하는 순간 연관된 개수만큼 따로따로 select 쿼리를 날리게 됩니다.
select diary -> select comment1 -> select comment2 -> select comment3...
즉, 한 Diary에 Comment가 5개가 있다고하면, Diary 조회 쿼리 1개 + Comment 조회 쿼리 5개로 총 6개의 조회 쿼리가 날아가게 되죠.
단순히 생각해봤을때에도, 데이터가 많아질 수록 끔찍해지지 않을까요? Diary를 한 1000개쯤 가져와서 Comment에 접근한다면..?! DB: (죽여줘)
분명히 @OneToMany
는 기본적으로 Lazy 로딩인데?! 나중에 조회하면 해결해주는 거 아니었어?!
라고 생각할 수도 있지만, 결국 Lazy 로딩도 조회를 당장하는가 미루는가의 차이이고, join을 해주는 것이 아니기 때문에 같은 문제에 봉착하게 됩니다.
이를 어떻게 해결할 수 있을까요?
select * from diary, select * from comment, select * from comment...
등으로 쿼리를 분할해서 가져오는 것이 아니라, 한번에 가져오는 쿼리를 사용하면 해결될겁니다.
즉, 조인을 해야겠죠!
Fetch Join은 JPQL에서 조회하는 주체가 되는 엔티티 + 연관된 엔티티 모두 select하여 영속화합니다.
즉, 연관된 엔티티까지 모두 영속화하므로 계속 조회쿼리를 날리지 않음으로써 N+1문제가 해결됩니다.
일반 Join
JPQL에서 조회하는 주체가 되는 엔티티만 select하여 영속화합니다.
즉, 연관 엔티티에 대한 데이터는 필요하지 않지만 join이나 where 조건 등에 쓰일 때 사용합니다.public List<Comment> findAllDiaryComments(Long diaryId) { return queryFactory .selectFrom(comment) .join(diary) .fetch(); }
이런 Fetch Join 쿼리는 어떻게 사용할 수 있을까요?
단순히 말하면 쿼리를 String타입으로 직접 작성하는 것입니다.
간단한 쿼리라면 사용해도 괜찮은 방법일 수 있습니다.
하지만 String타입으로 직접 작성한 join문을 호출한다는 점,
데이터를 호출하는 시점에 FetchType과 상관 없이 조회하게 된다는 점,
그리고 쿼리가 길어지면 유지보수에 까다로울 수 있다는 점이 단점으로 작용합니다.
@Query("select d from Diary d join fetch d.comments")
List<Diary> findAllJoinFetch();
JPQL을 작성하여 inner join으로 호출합니다.
2) @EntityGraph
@EntityGraph(attributePaths = "comments")
@Query("select d from Diary d")
List<Diary> findAllEntityGraph();
fetch join과 다른 점은 inner join이 아니라 outer join으로 가져온다는 점입니다.
재사용이 쉽고 동적 쿼리를 작성할 수 있으며, 가독성도 좋게 보여주는 다양한 플러그인들이 있습니다.
현재 쉽게 접할 수 있는 사용되는 것은 QueryDSL정도가 되겠네요.
개인 프로젝트를 진행하면서 조금 복잡한 조인조건과 where 조건들이 필요한 경우가 있었는데요, 이를 단순 JPQL이나 Spring data JPA등으로 작성하기엔 가독성 등에 문제가 있었습니다.
이 때 사용한 것이 QueryDSL입니다.
private final JPAQueryFactory queryFactory;
@Override
public List<Comment> findByDiaryId(Long loginId, Long diaryId) {
return queryFactory
.selectFrom(comment)
.leftJoin(friend)
.on(friendStatusEq(FriendStatus.Y),
(respondentIdEq(loginId).or(requesterIdEq(loginId)))
)
.where(diaryIdEq(diaryId),
commentVisibilityEq(CommentVisibility.PUBLIC)
.or(commentVisibilityEq(CommentVisibility.FRIEND).and(friend.requester.id.isNotNull()))
)
.fetch();
}
private BooleanExpression friendStatusEq(FriendStatus friendStatus) {
return friendStatus != null ? friend.friendStatus.eq(friendStatus) : null;
}
...
QueryDSL은 dependecy를 build가 필요합니다.
@Entity로 선언되어 있는 테이블을 기준으로 qClass를 만들어, 주입받은 QueryFactory에서 쿼리가 작동할 수 있게끔 합니다.
QueryDSL은 동적쿼리에 중점이 맞춰져 있지만, 가독성 측면이나 재사용 측면에서 봤을 때에도 복잡한 쿼리의 경우 JPQL보다 유용해보입니다.
QueryBuilder
BooleanExpression으로 다양한 조건을 가독성있고 재사용 가능하게 넣어줄 수 있습니다.
이 때, null조건은 무시됩니다.
위의 로직은 아직 부족함이 많아보입니다.
여기서 공부할수록 고민이 되었던 것은 위의 로직이 동적 쿼리보단 그저 조건이 조금 복잡한 쿼리에 가까운데 queryDSL을 사용하는것이 바람직한것인지입니다.
또, 이렇게 조금만 복잡한 쿼리를 JPA로 사용하다보면 myBatis를 사용하는 것이 오히려 더 좋지않나 하는 의문이 듭니다. myBatis로 쉽게 작성할 것 같은 쿼리를 이렇게 복잡하게 사용할 필요가 있는지..싶은 ㅎㅎ
public List<Friend> findAllFriends(Long memberId) {
return queryFactory
.selectFrom(friend)
.where(findByMemberId(memberId), findByFriendStatus(FriendStatus.Y), findByUseStatus(UseStatus.IN_USE))
.fetch();
}
단순히 이런식으로 join없이 작성했더니, N+1문제가 발생했습니다.
Friend와 Member를 조회하면서 Member가 가진 Friend의 수만큼 Member를 조회하는 쿼리가 그대로 날아갔습니다.
다음과 같이 FetchJoin으로 수정하여 N+1문제를 해결하였습니다.
@Override
public List<Friend> findAllFriends(Long memberId) {
return queryFactory
.selectFrom(friend)
.join(friend.requester, requester).fetchJoin()
.join(friend.respondent, respondent).fetchJoin()
.where(findByMemberId(memberId), findByFriendStatus(FriendStatus.Y), findByUseStatus(UseStatus.IN_USE))
.fetch();
}