본문 바로가기
개발/JPA, Querydsl

[JPA/Querydsl] 깊은 연관관계에서 query 개수 줄이기

by hamcheeseburger 2022. 10. 26.

학습 동기

문제의 연관관계

Member가 @OneToMany 관계로 InventoryProducts를 가지고 있는 상황이고, InventoryProduct @ManyToOne으로 Product를 갖고 있는 상황이다.

Member의 목록을 페이징하면서 InventoryProducts와 Product까지 불어와야 하는 상황이었는데, 모두 fetch join 하여 가져오기에는 paging 처리에 성능상 문제가 있었다. @OneToMany 관계에서 fetch join을 사용하게 되면 DB 레코드 상에서 카티션 프로덕트가 발생하기 때문에 limit offset 키워드가 쿼리에 포함되지 않는다. 즉, 모든 데이터를 애플리케이션단에 불러온 후 pagination 처리를 하게 된다.

그리하여 Member와 InventoryProducts의 연관관계를 BatchSize로 풀어내려 했다. 그런데 InventoryProduct 내의 Product 가 Fetch.LAZY 로 지정되어 있어, 똑같이 n+1 문제가 터졌다. 그래서 임시방편으로 Product를 FetchType.EAGER 로 지정하여 해결했었다.

해결은 얼추 되었으나.. 모든 상황에서 Product 가 불러와 진다는 점에서 거리낌을 느꼈다. Product를 FetchType.LAZY 로 두고 페이징에서 성능상의 이점을 갖출 순 없는 것일까?

학습 내용

결론적으로 말하면 이 상황에서는 JPA의 기능을 사용하지 않기로 했다. JdbcTemplate이나 다른 기술을 사용했다는 이야기는 아니다. JPA가 제공해주는 편리한 부분들을 사용하지 않고 직접구현 했다는 뜻이다.

이유는 JPA의 기능만으로는 우리가 원하는 방식으로 쿼리가 발생하지 않았기 때문이다.

일단 내가 원하는 목표

BatchSIze를 사용하면서, FetchType.EAGER 없이 아래와 같은 쿼리가 나오는 것.

1. List<Member> 를 불러오는 쿼리 1개

2. 멤버 별 List<LnventoryProduct> 와 그와 관련된 Product를 함께 fetch 조인하여 불러오는 쿼리 1개

이렇게 총 2개가 나가길 목표로 하고 있다.

2번에 대한 쿼리를 이미지를 첨부하자면 아래와 같다. (한번에 캡쳐가 안돼서 나누어서 캡쳐했다.)

우리가 마주한 문제점은 BatchSize로 이 연관관계를 해결하면 Product를 FetchType.EAGER로 지정해야 하고, 이러한 설정은InventoryProduct 를 불러올 때 어느 상황이든지 Product 를 불러온다는 것이었다.

이를 동적으로 제어할 수만 있다면 문제를 해결할 수 있을 것이다. 보통 아래의 두가지 방법으로 해결한다.

  1. Jpql로 특정 상황에만 fetch join 해온다.
  2. EntityGraph를 사용한다.

결론적으로 우리의 상황에서는 위 두가지 방법을 사용하지 못한다.

첫째로 jpql로 fetch join을 할 수 없는 이유다. 우리가 query를 직접적으로 작성하는 부분은 Member 를 페이징 처리하여 가져오는 부분이다. 그 외에 InventoryProduct 와 Product 를 불러오는 부분은 JPA에 의존하는 부분이다. 그래서 InventoryProduct와 Product 를 연관짓는 부분을 따로 개발자가 호출해줄 수 없다.

 

둘째로 EntityGraph를 사용할 수 없는 이유를 이야기하기 전에, EntityGraph가 무엇인지 부터 언급해보겠다.

EntityGraph는 Entity의 조회 시점에 연관된 Entity를 불러오는 기능이다. 사실 fetch join 이랑 똑같은 기능이다. 다만 fetch join 을 사용하게 되면 연관할 Entity가 늘어날 때 마다 쿼리가 하나씩 늘어가게 된다는 단점이 있지만 EntityGraph는 하나의 쿼리로 원하는 형태의 EntityGraph만 적용해주면 여러 가지의 Entity를 연관지을 수 있다는 장점이 있다.

EntityGraph를 사용하면, JPA가 Entity를 조회할 때 동적으로 연관된 Entity를 불러올 수 있도록 할 수 있을 줄 알았다.. 두 가지의 시도를 했는데 모두 실패했다. 하나씩 살펴보자..

 

EntityGraph로의 해결 실패 기록

InventoryProduct와 Product의 Graph를 그리기

정적 엔티티 그래프와 동적 엔티티 그래프로 나뉘어지는데, 정적 엔티티 그래프는 FetchType.EAGER를 쓰는 것과 별반 다를 것이 없다. 그래서 동적 엔티티 그래프로 적용해보겠다.

public Slice<Member> findBySearchConditions(final String keyword, final CareerLevel careerLevel,
                                                final JobType jobType,
                                              final Pageable pageable) {

    final EntityGraph<InventoryProduct> entityGraph = em.createEntityGraph(InventoryProduct.class);
    entityGraph.addAttributeNodes("product");

    final JPAQuery<Member> jpaQuery = jpaQueryFactory.select(member)
            .from(member)
            // ..
            .setHint("javax.persistence.fetchgraph", entityGraph);

    return toSlice(pageable, jpaQuery.fetch());
}

Member가 갖고있는 List<InventoryProduct>에 @BatchSIze가 적용되어있기 때문에 위와 같이 in절을 통해 InventoryProduct를 조회하게 된다. entityGraph를 통해 Product를 같이 fetch join해서 불러오기를 기대했으나 효과가 없었다.

사실 생각해보면 당연히 안된다. 작성하려는 Query의 from절에는 Member 밖에 없는데, InventoryProductProduct 의 graph를 그려 적용하니 그냥

N+1 문제가 똑같이 발생했다.

Member와 InventoryProduct와 Product의 Graph를 그리기

Member와 Graph가 제대로 연결이 안된 것 같으니, Graph에도 Member를 넣어주면 되지 않을까? 하는 생각이 들었다. 그래서 Member에서 부터 Product까지 Graph를 그려보기로 했다.

 

public Slice<Member> findBySearchConditions(final String keyword, final CareerLevel careerLevel,
                                                final JobType jobType,
                                              final Pageable pageable) {

    final EntityGraph<Member> entityGraph = em.createEntityGraph(Member.class);
    entityGraph.addAttributeNodes("inventoryProducts");
    final Subgraph<Object> subGraph = entityGraph.addSubgraph("inventoryProducts");
    subGraph.addAttributeNodes("product");

    final JPAQuery<Member> jpaQuery = jpaQueryFactory.select(member)
            .from(member)
						// ...
            .setHint("javax.persistence.fetchgraph", entityGraph);

    return toSlice(pageable, jpaQuery.fetch());
}

적용했던 BatchSize를 무시하고 InventoryProduct 부터 Product 까지 fetch join 하여 불러왔다. 당연히 db query에서 limit offset 키워드도 포함이 되지 않았다.

너무 JPA에 의존적인 것 아닌가?

EntityGraph로 해볼 수 있는 건 다 해본 것 같은데, 문제 해결은 안돼서 눈물 좔좔 흘리고 있을 때였다. 때 마침 구구가 옆에 계시길래 냅다 질문을 던졌다.

돌아오는 대답은 결국… JPA에 너무 의존적이면 우리가 원하는대로 되지 않을 때가 많아요 였다.. 직접 쿼리를 짜는 것도 방법 중에 하나라고 말씀하시는 것을 듣고 아차 싶었다. 너무 JPA로만 구현을 하려다 보니 생각이 제한적이었던 것 같다. 조금은 귀찮아도, JPA에서 한계가 느껴진다면 직접 쿼리를 짜야 한다는 것을 이제 깨달았다.

그래서 어떻게 짰나?

  • BatchSIze를 걷어냈다.
  • 일단 Member 를 Pagination하여 불러온다.
  • 불러온 List<Member> 에서 id만 뽑아 내어 해당하는 List<InventoryProduct> 를 불러온다. 이 때, Product도 fetch join 하여 가져온다.
  • Member 마다 해당하는 List<InventoryProduct> 를 잘라와서 맵핑한다.

'개발 > JPA, Querydsl' 카테고리의 다른 글

Querydsl의 이론적인 부분  (0) 2022.10.31

이전 댓글