QueryDsl 을 사용한 쿼리 튜닝과 N+1 해결

recordsbeat·2020년 12월 4일
3
post-thumbnail

시작하기

Spring 진영의 ORM인 JPA(spring-data)를 사용하다보면 종종 쿼리에 대한 한계점에 부딪히게 된다. 이로 인해 해결책을 찾다보면 어렵지 않게 QueryDsl이라는 방안을 볼 수 있다.

현업간 QueryDsl를 사용하면서 겪은 시행착오와 나름의 해결책을 정리하고자 이 글을 적게 되었다.

앞서 결론부터 이야기 하자면 다음과 같다.

QueryDsl

  • 리스팅을 위한 조회 및 복잡한 쿼리 추출에만 사용
    domain model 이 아닌 dto 혹은 별도의 객체를 통해 추출

domain model 을 사용할 경우 query 비용(plan) 이나 select에 대한 n+1문제를 피해갈 수 없음
entity A(소유) - B(종속) 연관관계에서 from절을 B로 사용할 경우(query 비용문제로 인하여) B에 대한 n+1 문제가 야기된다. 그러므로 mybatis 혹은 native query 처럼 사용하고 싶은 경우에만 적용

어쩌다가?

상황은 다음과 같았다.
1. JPA를 사용하는 프로젝트가 있다.
2. 해당 프로젝트에서 대량의 데이터를 추출하는 쿼리를 사용했다.
3. 쿼리가 너무 느려 plan에 따라 수정을 했다.
4. QueryDsl과 JPA간 N+1 문제 및 영속성 에러가 발생하였다.

어디보자..


(넌 담에보자)

다음과 같은 QueryDsl select 문을 작성하였다.
RootEntity 와 RelatedEntity 는 1:1 관계이면서
Optional = True (RelatedEntity 테이블에 RootEntity존재 하지 않아도 되는) 관계다.

그리고 두 엔티티는 RootEntity -> RelatedEntity 인 단방향 참조로 이루어져있다.

public Page<RootEntity> findAllRootEntityByUseYn(Pageable pageable)
{
    QRootEntity RootEntity = QRootEntity.RootEntity;
    QRelatedEntity RelatedEntity = QRelatedEntity.RelatedEntity;

    JPAQuery<RootEntity> query = queryFactory
        .select(RootEntity)
        .from(RootEntity)
        .innerJoin(RootEntity.RelatedEntity, RelatedEntity)
        .fetchJoin()
        .where(
            RelatedEntity.useYn.eq(UseYn.Y)
        );

    QueryResults<RootEntity> result = query
        .groupBy(RootEntity.RootEntitySeq)
        .orderBy(RootEntity.RootEntitySeq.desc())
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetchResults();


    return new PageImpl<>(result.getResults(), pageable, result.getTotal());
}

*실제보다 많이 간략화 한 쿼리입니다.

그리고 해당 쿼리문에 대한 plan은 다음과 같았다.

rootEntity는 풀색인을 하고 있으며 temporary와 filesort에 대한 비용이 들고 있었다.
이로 인하여 쿼리 속도는 배 이상 차이나고 있었다.

ALL
이전 테이블과의 조인을 위해 풀스캔이 된다. 만약 (조인에 쓰인) 첫번째 테이블이 고정이 아니라면 비효율적이다, 그리고 대부분의 경우에 아주 느린 성능을 보인다. 보통 상수값이나 상수인 컬럼값으로 row를 추출하도록 인덱스를 추가함으로써 ALL 타입을 피할 수 있다.
//중략
쿼리를 가능한 한 빠르게 하려면, Extra 값의 Using filesort 나 Using temporary 에 주의해야 한다.
참조링크 - 쿼리 플랜 해석방법
https://database.sarang.net/?inc=read&aid=24199&criteria=mysql

시행착오

그래서 쿼리를 바꿨다.

public Page<RootEntity> findAllRootEntityByUseYn(Pageable pageable)
{
    QRootEntity RootEntity = QRootEntity.RootEntity;
    QRelatedEntity RelatedEntity = QRelatedEntity.RelatedEntity;

    JPAQuery<RootEntity> query = queryFactory
        .select(RootEntity)
       	// from 의 대상을 RelatedEntity로 변경
        .from(RelatedEntity)
        .innerJoin(RootEntity.RelatedEntity, RelatedEntity)
        .on(RelatedEntity.RootEntitySeq.eq(RootEntity.RootEntitySeq))
        .where(
            RelatedEntity.useYn.eq(UseYn.Y)
        );

    QueryResults<RootEntity> result = query
	    // groupBy와 orderBy 의 대상을 RelatedEntity로 변경
        .groupBy(RelatedEntity.RootEntitySeq)
        .orderBy(RelatedEntity.RootEntitySeq.desc())
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetchResults();


    return new PageImpl<>(result.getResults(), pageable, result.getTotal());
}

풀 색인을 피하기위하여 where 조건의 대상인 RelatedEntity를 from 절로 이동시켰고 groupBy와 OrderBy 또한 진행하였다.

쿼리 플랜을 살펴보니 제대로 실행되는 듯 하다.
(ㅇㅈㄸㄹ ㅇㅈㄷ~)

그리고 쿼리 로그를 살펴봤다.

...? 로그가 왤케 많아..?


(N+1 어서오고)

select 절에 RootEntity를 넣었지만 Entity Model을 불러오기위한 fetchJoin이 이뤄지지 않아서 RelatedEntity select 행위가 여러번 일어났다.

다시 말해 RootEntity와 RelatedEntity 의 관계에서 RelatedEntity 에 대한 join 관계로 RootEntity 를 조회하려면 N+1를 피할 수 없다.

또한 RootEntity와 RelatedEntity 를 양방향 참조로 바꾸어 쿼리를 작성하면 되지 않을까 할 수도 있지만 실제로는 RootEntity의 연관 객체들이 RelatedEntity 외에도 여러가지가 있어 더욱 복잡한 join을 야기시킬 수 있다.

*해당 부분에 대해 다른 견해나 틀린점이 있을 시에 댓글 달아주시면 감사하겠습니다!

해결방안(?)

위 시행착오가 글로는 간단히 적혀져있지만 약 2주간 겪으면서 차라리 mybatis를 쓰고 싶다! 라는 생각이 들었다. (적어도 n+1은 없으니까)

그러다 문득. QueryDsl을 mybatis처럼 쓰면 되지 않나.? 라는 생각에 도달하였고 이를 실행에 옮겼다.

public Page<QueryDto> findAllRootEntityByUseYn(Pageable pageable)
{
    QRootEntity RootEntity = QRootEntity.RootEntity;
    QRelatedEntity RelatedEntity = QRelatedEntity.RelatedEntity;

    JPAQuery<QueryDto> query = queryFactory
        .select(
                Projections.constructor(QueryDto.class,
                RootEntity.col1
                , RootEntity.col2
                , RelatedEntity.col1
                ....
            ))
       	// from 의 대상을 RelatedEntity로 변경
        .from(RelatedEntity)
        .innerJoin(RootEntity.RelatedEntity, RelatedEntity)
        .on(RelatedEntity.RootEntitySeq.eq(RootEntity.RootEntitySeq))
        .where(
            RelatedEntity.useYn.eq(UseYn.Y)
        );

    QueryResults<QueryDto> result = query
	    // groupBy와 orderBy 의 대상을 RelatedEntity로 변경
        .groupBy(RelatedEntity.RootEntitySeq)
        .orderBy(RelatedEntity.RootEntitySeq.desc())
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetchResults();


    return new PageImpl<>(result.getResults(), pageable, result.getTotal());
}

그냥 select 용 Dto 하나 더 만들었다.
애초에 Entity 객체라는 hibernate 영속 객체를 로드하는 행위 자체를 없애버리자는 것이다.
(사실 이 행위는 다른 select문에서 많이 쓰고 있었는데, 왜 유독 이 쿼리문에서만 Entity Model을 load하려고 안간힘 썼는지 모르겠다.)

해당 쿼리를 돌려보면

n+1 이나 별다른 에러 없이 잘 로드 된다.

위에서 결론을 미리 말하였지만 QueryDsl 라는 라이브러리의 사용처를 영속객체를 load 할 때 보다는 복잡한 select 문을 사용해야할 때 별도의 dto클래스를 사용하여 load 하는 것이 훨씬 효율적으로 보인다.

물론 제가 잘 못사용한 부분이 있을 수 있습니다.

하지만 이렇게 QueryDsl과 JPA repository 의 사용처를 나누어 놓으니 쿼리문 사용을 위해 entity의 참조 관계를 수정하는 일이 없어 메소드간의 영향을 끼치지 않는다.

마무리

현업간 다량의 데이터 추출을 하면서 처음 겪은 쿼리 성능 이슈와 튜닝.
그리고 해당 이슈를 QueryDsl 이라는 라이브러리를 통해 해결하다보니 만나게된 시행착오들이었다. 이번거 안적어두면 나중에 흑우짓 또 할 것 같아서 적어두게 되었다.

모두들 n+1로부터 자유로워지시길

profile
Beyond the same routine

1개의 댓글

comment-user-thumbnail
2020년 12월 10일

결국 갓동욱님이 이미 써두신 글과 일맥상통하다!
-Querydsl Select 필드로 Entity 사용시 주의 사항
https://jojoldu.tistory.com/518

답글 달기