-
Notifications
You must be signed in to change notification settings - Fork 6
ChannelSubscription에서 N+1 문제 해결하기
Channel(1) : ChannelSubscription(n) 관계이다.
ChannelSubscription
내부에 Channel
이 @ManyToOne 관계 매핑되어있다.
@Getter
@Table(name = "channel_subscription")
@Entity
public class ChannelSubscription {
private static final int MIN_ORDER = 1;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "channel_id", nullable = false)
private Channel channel;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;
@Column(name = "view_order", nullable = false)
private int viewOrder;
// 이하 생략
}
GET /api/channel-subscription에서 문제가 발생했다
아래의 테스트코드를 실행했을 때 문제가 발생했는데,
@Test
void 채널_구독_조회() {
// given
long noticeId = 1L;
채널_구독_요청(token, noticeId);
long freeChatId = 2L;
채널_구독_요청(token, freeChatId);
long questionId = 3L;
채널_구독_요청(token, questionId);
// when
ExtractableResponse<Response> response = 유저가_구독한_채널_목록_조회_요청(token);
// then
상태코드_200_확인(response);
구독이_올바른_순서로_조회됨(response, noticeId, freeChatId);
}
세 개의 채널(notice, freeChat, question)이 존재한다. 그리고 유저가 세 개의 채널을 모두 구독했다.
즉 현재는 아래와 같은 모습이다
(글씨가 엉망 😫)
Channel과 ChannelSubscription은 일대다 관계이지만, n+1 문제가 발생한 상황은 channel 하나에 하나의 channelsubscription이 매핑된 one-to-one 상황인 것 같다.
문제가 발생하는 코드로 가보자
// ChannelSubscriptionService.java
public ChannelSubscriptionResponses findByMemberId(final Long memberId) {
List<ChannelSubscriptionResponse> channelSubscriptionResponses = channelSubscriptions
.findAllByMemberIdOrderByViewOrder(memberId)
.stream()
.map(ChannelSubscriptionResponse::from)
.collect(Collectors.toList());
return new ChannelSubscriptionResponses(channelSubscriptionResponses);
}
서비스에서 호출하는 findAllByMemberIdOrderByViewOrder()
에서 문제가 발생한다.
// ChannelSubscriptionRepository.java
@Query("select cs from ChannelSubscription cs where cs.member.id = :memberId order by cs.viewOrder")
List<ChannelSubscription> findAllByMemberIdOrderByViewOrder(Long memberId);
레포지토리 클래스로 가보니 @Query
로 JPQL 이 직접 선언되어 있는 것을 확인할 수 있었다.
다시 발생한 n+1 쿼리문을 살펴보겠다.
select channelsub0_.id as id1_2_, channelsub0_.channel_id as channel_3_2_, channelsub0_.member_id as member_i4_2_, channelsub0_.view_order as view_ord2_2_ from channel_subscription channelsub0_ where channelsub0_.member_id=? order by channelsub0_.view_order
select channel0_.id as id1_1_0_, channel0_.name as name2_1_0_, channel0_.slack_id as slack_id3_1_0_, channel0_.workspace_id as workspac4_1_0_ from channel channel0_ where channel0_.id=?
select channel0_.id as id1_1_0_, channel0_.name as name2_1_0_, channel0_.slack_id as slack_id3_1_0_, channel0_.workspace_id as workspac4_1_0_ from channel channel0_ where channel0_.id=?
select channel0_.id as id1_1_0_, channel0_.name as name2_1_0_, channel0_.slack_id as slack_id3_1_0_, channel0_.workspace_id as workspac4_1_0_ from channel channel0_ where channel0_.id=?
memberId 조건에 맞는 ChannelSubscription을 모두 조회하면, 유저의 총 ChannelSubscription이 3개 존재하므로 Channel에 대한 쿼리가 세 개 더 나간다.
@Query("select cs from ChannelSubscription cs join fetch cs.channel where cs.member.id = :memberId order by cs.viewOrder")
List<ChannelSubscription> findAllByMemberIdOrderByViewOrderJoinFetch(Long memberId);
fetch join을 적용하는 방법은 매우 간단하다. @Query
내부에서 join fetch cs.channel
을 추가해주면 된다.
결과를 확인해보니 inner join
을 통해 한 번에 모든 값을 가져와서 쿼리가 한 번만 발생한 것을 확인할 수 있다.
---
url: GET /api/channel-subscription
time: 18
count : 1
select channelsub0_.id as id1_2_0_, channel1_.id as id1_1_1_, channelsub0_.channel_id as channel_3_2_0_, channelsub0_.member_id as member_i4_2_0_, channelsub0_.view_order as view_ord2_2_0_, channel1_.name as name2_1_1_, channel1_.slack_id as slack_id3_1_1_, channel1_.workspace_id as workspac4_1_1_ from channel_subscription channelsub0_ inner join channel channel1_ on channelsub0_.channel_id=channel1_.id where channelsub0_.member_id=? order by channelsub0_.view_order
---
fetch join의 문제??
fetch join에는 크게 두 가지 문제가 발생한다.
- 페이지네이션이 불가능하다.
- Collection이 두 개 존재할 경우 예외가 발생한다.
하지만 둘 다 @~ToMany
형태일 경우 발생하는 문제인 것 같다.
우리는 현재 @ManyToOne
(개념적으로는 @OneToOne
인 것 같기도?? 실제로 어노테이션을 @ManyToOne에서 @OneToOne으로 변경한 후 테스트를 실행해봤을 때 아무 문제가 발생하지 않고 똑같이 N+1 쿼리가 발생했다.) 관계이니 문제가 발생하지 않아서 fetch join으로 해결이 가능할 것 같다.
*import* org.hibernate.annotations.BatchSize;
를 사용해보려했다.
하지만 batch size는 @~ToMany
관계에서 사용 가능하다. BatchSize 내부로 들어가보자
Defines size for batch loading of collections or lazy entities
Batch Size는 컬렉션이나 lazy entity들 에 대해서 적용이 가능하다. 우리 케이스에는 맞지 않다!!
@EntityGraph(attributePaths = {"channel"})
@Query("select cs from ChannelSubscription cs where cs.member.id = :memberId order by cs.viewOrder")
List<ChannelSubscription> findAllByMemberIdOrderByViewOrderEntityGraph(Long memberId);
EntityGraph 어노테이션을 활용해서도 해결할 수 있다. @EntityGraph의 attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면 된다.
결과를 확인해보면
---
url: GET /api/channel-subscription
time: 20
count : 1
select channelsub0_.id as id1_2_0_, channel1_.id as id1_1_1_, channelsub0_.channel_id as channel_3_2_0_, channelsub0_.member_id as member_i4_2_0_, channelsub0_.view_order as view_ord2_2_0_, channel1_.name as name2_1_1_, channel1_.slack_id as slack_id3_1_1_, channel1_.workspace_id as workspac4_1_1_ from channel_subscription channelsub0_ left outer join channel channel1_ on channelsub0_.channel_id=channel1_.id where channelsub0_.member_id=? order by channelsub0_.view_order
---
내부에서 left outer join
이 발생한 것을 확인할 수 있다.
fetch join과 다르게 @EntityGraph를 사용하는 방법은 outer join이 발생한다. 그래서 distinct 키워드를 써주는게 좋다고 한다
distinct 키워드 추가 코드
@EntityGraph(attributePaths = {"channel"})
@Query("select **distinct** cs from ChannelSubscription cs where cs.member.id = :memberId order by cs.viewOrder")
List<ChannelSubscription> findAllByMemberIdOrderByViewOrderJoinFetch(Long memberId);
쿼리 결과
---
url: GET /api/channel-subscription
time: 20
count : 1
select **distinct** channelsub0_.id as id1_2_0_, channel1_.id as id1_1_1_, channelsub0_.channel_id as channel_3_2_0_, channelsub0_.member_id as member_i4_2_0_, channelsub0_.view_order as view_ord2_2_0_, channel1_.name as name2_1_1_, channel1_.slack_id as slack_id3_1_1_, channel1_.workspace_id as workspac4_1_1_ from channel_subscription channelsub0_ left outer join channel channel1_ on channelsub0_.channel_id=channel1_.id where channelsub0_.member_id=? order by channelsub0_.view_order
---
아무튼 결론은, @EntityGraph를 사용하는 방법도 가능하다!!
ChannelSubscriptionService.findByMemberId()
에서 발생하는 n+1 문제는 fetch join을 사용하거나, @EntityGraph를 통해서 해결할 수 있다.
내 의견 내보자면, fetch join에서 발생할 수 있는 문제들(두 개 이상의 컬렉션, 페이징 관련 문제)들이 발생하지 않는 상황이니 outer join이 발생하는 @EntityGraph 말고 fetch join을 사용하면 어떨까??!?!?!?
[https://velog.io/@jinyoungchoi95/JPA-모든-N1-발생-케이스과-해결책](https://velog.io/@jinyoungchoi95/JPA-%EB%AA%A8%EB%93%A0-N1-%EB%B0%9C%EC%83%9D-%EC%BC%80%EC%9D%B4%EC%8A%A4%EA%B3%BC-%ED%95%B4%EA%B2%B0%EC%B1%85) (오리의 예전 벨로그글ㅋㅅㅋ)
https://jojoldu.tistory.com/165 (조졸두님 글)
https://incheol-jung.gitbook.io/docs/q-and-a/spring/n+1 (이건 그냥 참고)