Skip to content

ChannelSubscription에서 N+1 문제 해결하기

봄 edited this page Nov 13, 2022 · 1 revision

상황 설명

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)이 존재한다. 그리고 유저가 세 개의 채널을 모두 구독했다.

즉 현재는 아래와 같은 모습이다

image

(글씨가 엉망 😫)

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에 대한 쿼리가 세 개 더 나간다.

해결해보기

1. fetch join으로 해결

@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에는 크게 두 가지 문제가 발생한다.

  1. 페이지네이션이 불가능하다.
  2. Collection이 두 개 존재할 경우 예외가 발생한다.

하지만 둘 다 @~ToMany 형태일 경우 발생하는 문제인 것 같다.

우리는 현재 @ManyToOne (개념적으로는 @OneToOne 인 것 같기도?? 실제로 어노테이션을 @ManyToOne에서 @OneToOne으로 변경한 후 테스트를 실행해봤을 때 아무 문제가 발생하지 않고 똑같이 N+1 쿼리가 발생했다.) 관계이니 문제가 발생하지 않아서 fetch join으로 해결이 가능할 것 같다.

2. batch size로 해결

*import* org.hibernate.annotations.BatchSize; 를 사용해보려했다.

하지만 batch size는 @~ToMany관계에서 사용 가능하다. BatchSize 내부로 들어가보자

image

Defines size for batch loading of collections or lazy entities

Batch Size는 컬렉션이나 lazy entity들 에 대해서 적용이 가능하다. 우리 케이스에는 맞지 않다!!

3. @EntityGraph로 해결

@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 (이건 그냥 참고)

Clone this wiki locally