woowacon 2020 수십억건데이터에서 QUERYDSL

Crow·2022년 12월 26일
0

해당 글은 https://www.youtube.com/watch?v=zMAX7g6rO_Y 해당 영상의 정리글

1. 워밍업

extends/ implements 사용하지 않기

보통 일반적으로 queryDSL을 사용하려면 OrderRepository를 기본 Repository로 생각했을때
JpaRepository를 상속받고 OrderRepositoryCustom이라는 별도의 interface를 상속받고 해당 interface의 구현체인 OrderRepositoryImpl가 필요함 해당 구조가 너무 과하다면

아래와 같이 QueryRepositorySupport를 상속받는 구조 사용할수있음

하지만 이것 역시 super생성자에 Entity를 매번 등록해야함(이걸 할때마다 불편하다고 느낌)

public class BookRepositorySupport extends QuerydslRepositorySupport{
	private final JPAQueryFactory queryFactory;
    
    public BookRepositorySupport(JPAQueryFactory queryFactory){
    super(Book.class);
    this.queryFactory = queryFactory;
    }
}

그러다가 꼭 무언가를 상속/구현 받지 않더라도,
꼭 특정 Entity를 지정하지 않더라도 Querydsl을 사용할 수 있는 방법을 찾게됨

그건 바로 JPAQueryFactory만 있다면 Querydsl을 사용하는대 문제가 없었다는것(extends/implements 다 제거 가능)

상속을 다 제거할시

본 JpaRepository의 코드를 확장해서 쓰는게 아니다보니 장점이 하나 사라지는데요.
이렇게 하게 된 이유가, (저희 프로젝트 특성일수도 있는데) 점점 프로젝트가 커지다 보니,
단일 Entity만을 위한 기능이 어드민/api/batch 등 기능을 구현할때는 거의 안쓰이게 되었었는데요.

어떤 기능을 구현하기 위해 A/B/C 엔티티를 함께 참조 (혹은 Join) 해야하는데, 이건 A엔티티 Repository의 역할로 봐야할지, B엔티티 Repository의 역할로 봐야할지 모호할때가 계속 발생했었습니다.

그래서 이럴 경우에 특정 Entity를 메인으로 하지 않는 기능이라 판단되는 경우에 위와 같이 JPAQueryFactory 로만 구현된 서비스 기능에 특화된 로직에 적극적으로 사용하고 있습니다.

package io.web.chewing.repository;

import com.querydsl.jpa.impl.JPAQueryFactory;
import io.web.chewing.Entity.Booking;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

import static io.web.chewing.Entity.QBooking.booking;

@RequiredArgsConstructor
@Repository
public class BookingQueryFactory{
    private final JPAQueryFactory queryFactory;

    public List<Booking> findByName(String name){
        return queryFactory.selectFrom(booking)
                .where(booking.real_name.eq(name))
                .fetch();
    }
}

해당 코드는 내가 가지고 있는 Entity를 사용함
해당 구조는 특정 Entity를 메인으로 하지 않는 기능을 사용할때 특화됨
단일 Entity만을 사용하는 기능을 만드는 구조는 아래에 방법이 더 좋아보임

custom repository interface를 implements하는 부분은 남겨둘시(해당 영상에 Dongmin Shin & 향로님 댓글)

custom repository 구현 클래스의 이름을 Spring Data Repository naming rule에 맞도록 기본 repository + Impl로 정해주면
Spring Data가 알아서 구현 클래스를 bean으로 등록해주니까 여기에 JPAQueryFactory를 생성자 주입받는다면 EntityRepository 기능을 확장할때 유리함
확실한 코드는 아니고 내가 생각했을때 이런 구조라고 생각하는 코드

package io.web.chewing.repository;

import com.querydsl.jpa.impl.JPAQueryFactory;
import io.web.chewing.Entity.Booking;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

import static io.web.chewing.Entity.QBooking.booking;

@RequiredArgsConstructor
@Repository
public class BookingRepositoryImpl implement {
    private final JPAQueryFactory queryFactory;

    public List<Booking> findByName(String name) {
        return queryFactory.selectFrom(booking)
                .where(booking.real_name.eq(name))
                .fetch();
    }
}

동적쿼리

BooleanBuilder 보단 BooleanExpression

BooleanBuilder

@Override
public List<Acadmy> findDynamicQuery(String name, String address, String phoneNumber){
	BooleanBuilder builder = new BooleanBuider();
    
    if(!StringUtils.isEmpty(name){
    	builder.and(academy.bane.eq(name));
    }
        if(!StringUtils.isEmpty(address){
    	builder.and(academy.bane.eq(address));
    }
        if(!StringUtils.isEmpty(phoneNumber){
    	builder.and(academy.bane.eq(phoneNumber));
    }
    
    return queryFactory
    	.selectFrom(academy)
        .where(builder)
        .fetch();
}

BooleanBuilder을 사용한 코드는 어떤 쿼리인지 예상하기 어려움

BooleanExpression

 public List<Academy> findDynamicQueryAdvance(String name, String address, String phoneNumber) {
        return queryFactory
                .selectFrom(academy)
                .where(eqName(name),
                        eqAddress(address),
                        eqPhoneNumber(phoneNumber))
                .fetch();
    }

    private BooleanExpression eqName(String name) {
        if (StringUtils.isEmpty(name)){
            return null;
        }
        return academy.name.eq(name);
    }

    private BooleanExpression eqName(String address) {
        if (StringUtils.isEmpty(address)){
            return null;
        }
        return academy.address.eq(address);
    }

BooleanExpression를 사용하면 null 반환시 자동으로 조건절에서 제거됨

따라서 모든 조건이 null이 발생하는 경우는 대장애가 발생하니까 주의해야함

2. 성능개선 - select

Querydsl의 exist 금지

SQL.exist

select exists(
	select 1
	from ad_item_sum
    where created_date '2020-01-01)

해당 쿼리는 2500만건의 기준 2s 57ms성능

SQL.count(1)>0

select count(1)
from ad_item_sum
where created_date > '2020-01-01'

해당 쿼리 2500만건 기준 5s 208ms 성능

이런 이유가 있는 이유는 exist는 첫번째 값이 발견된다면 쿼리를 종료하지만 count는 첫번째 쿼리를 발견해도 끝까지 모든 조건을 체크함

이건 체크해야할 첫번째 데이터의 위치가 앞에 있을수록 성능차이가 더 커짐

그래서 누가 봐도 exist가 데이터 유무를 체크할때 성능이 좋다고 느낄수있음

아쉽게도 Querydsl의 exists는 실제로는 성능상 이슈가 있는 count > 0로 실행됨

문제가 있으니 바꿔보려고 했지만 2020년 우아콘 기준으론 Querydsl JPA의 근간이 된 JPQL이 from 없이는 쿼리를 생성할수 없음

따라서 exists가 빠른 이유인 조건에 해당하는 row 1개만을 찾으면 바로 쿼리를 종료하는걸 직접 구현해야함

limit 1로 조회제한

public Boolean exist(Long bookId){
	Integer fetchOne = quertFactory
    		.selectOne()
            .from(book)
            .where(book.id.eq(bookId))
            .fetchFirst();
            
    return fetchOne != null;
}

위의 코드처럼 limit 1로 조회 제한 이때 조회 결과에 대해서 0보다 크냐가 아닌 null인지 아닌지로 판단해야함

이유는 조회결과가 없으면 0이 아닌 null이 반환되기 때문에 null이냐 아니냐만 판단할 수 있는 마지막 조건만 넣어주면됨

exists와 거의 동일한 성능을 나타냄

Cross Join 회피

묵시적조인 에서 명시적 조인으로

Before

public List<Customer> crossJoin(){
	return queryFactory
    		.select(customer)
      	.where(customer.sutomerNo
        .gt(customer.shop.shopNo))
        .fetch();
}

2020년 기준으로 일반적으로 Cross Join은 성능상 이슈가 있다는걸
다들 알고있음

그 이유는 해당 조인은 모든 경우의 수를 대상으로 하기 때문에 성능이 좋을수 없음

물론 일부 db에 관해선 최적화 됨 그렇더라도 최대한 피해야좋음

하지만 querydsl or jpa를 사용하면 실질적으로 join을 사용하지 않더라도 묵시적 조인으로 Cross Join이 일어남

where문에서 join에 대해서 직접적으로 선언한다면 기능을 사용하는대 문제가 없음

이건 Hibernate 이슈라서 Spring Data JPA도 Querydsl JPA도 동일하게 발생함

이걸 가장 간단히 피하는 방법은 명시적 조인을 선언하면됨
After

public  List<Customr> notCrossJoin(){
	return queryFactory
   		.selectFrom(customer)
           .innerJoin(customer.shop, shop)
           .where(customer.customerNo.gt(shop.shopNo))
           .fetch();
}

해당 쿼리는 명시적 Join으로
Inner Join이 발생함

Entity 보다는 Dto를 우선

많은 사람들이 JPA를 사용할때 Entity를 사용해야 한다고 생각함(이 부분은 나도 이 영상을 보기전까지 그런 생각이 있었음)
Entity 조회시엔
Hibernate 1차 2차 캐시같은 문제 발생,
불필요한 컬럼이 모두가 조회됨(이 경우는 나도 많이 느낌)
OneToOne에서 N+1 쿼리가 터지는 문제 발생(이후 자세한 설명을 들어봐야함)

이런문제만 봐도 단순한 조회 기능에서 성능 이슈가 날 요소가 너무 많음

아래 내용처럼 Entity를 조회할지 Dto를 조회할지 생각해봐야겠음

조회 컬럼 최소화

Before

public List<BookPageDto> getBooks(int bookNo, int pageNo){
	return queryFactory
    		.select(Projections.fields(BookPageDto.class,
            		book.name,
                    book.bookNo,
                    book.id
    ))
    .from(book)
    .where(book.bookNo.eq(bookNo))
    .offset(pageNo)
    .limit(10)
    .fetch();
}

위의 Before 쿼리문에선 이미 알고있는 bookNo값을 select절에 선언함 그러나 이걸 after에서 as표현식으로 대체해서 조회컬럼을 줄여보겠음

After

public List<BookPageDto> getBooks(int bookNo, int pageNo){
	return queryFactory
    		.select(Projections.fields(BookPageDto.class,
            		book.name,
                    Expressions.asNumber(bookNo).as("bookNo"),
                    book.id
    ))
    .from(book)
    .where(book.bookNo.eq(bookNo))
    .offset(pageNo)
    .limit(10)
    .fetch();
}

위 코드처럼 이미 알고있는 값들은 asNumber or asString으로 대체가능함

해당 Querydsl을 실행해보면 실제로 Hibernate가 사용한 select절이 줄어든것을 확인가능

Entity Dto 언제 조회해야할까?

Entity 조회

  • 실시간으로 Entity 변경이 필요한 경우

Dto 조회

  • 고강도 성능 개선 or 대량의 데이터 조회가 필요한 경우

내가 고민하던 문제

select 컬럼에 Entity 자제

  • select 컬럼에 entity를 선언하게 된다면 AdBond와 연결될 Customer가 조회가 필요할때 Customer같은 경우에는 Entity 조회하게됨

이 기능에 대해선 Querydsl로 조회된 결과를 신규 Entity로 생성

그러나 이 경우엔 Customer의 모든 컬럼이 조회됨
하지만 우리가 필요한건 새로 만들 AdBond의 Customer와 관계를 맺을 id값만 필요했음

하지만 필요없는 나머지 컬럼까지 조회됨
그리고 Customer에 OneToOne관계인 Shop이 매 건마다 조회가 됨

(내가 만든 chewing도 OneToOne 관계를 맺은 Entity가 없었을뿐이지 충분히 발생할 수 있던 문제인대 운이 좋았음)

즉 Customer 한건마다 무조건 적으로 조회가됨
이게 우리가 말하는 N+1 문제임

따라서 select 문제 한건에 성능상 문제가 너무 많이 발생함

만일 여기서 Shop Entity에도 @OneToOne관계의 Entity가 존재한다면 100배 1000배의 실행이 됨

그러나 여기서 Entity간 연관관계를 맺으려면 반대 Entity가 있어야하지 않나요?

이 문제는 insert문을 새로 만들어야 할 때 충분히 생각해 볼 수 있음

이 문제의 해결법은 연관된 Entity의 save를 위해서는
반대편 Entity의 ID만 있으면 됨(join column에 들어갈 ID만 필요)

queryFactory
	.select(Projections,fields(AdBondDto.class,
    adItem.amount.sum().as("amount),
    adItem.txDate,
    adItem.orderType,
    adItem.customer.id.as("customerId))
 )
 .from(adItem)
 .where(adItem.orderType.in(orderTypes)
 		.and(adItem.txDate.between(startDate, endDate)))
        .groupBy(adItem.orderType, aditem.txDate, adItem.customer)
        fetch();
        
  public AdBond toEntity(){
  	return AdBond.builder()
    		.amount(amount)
            .txDate(txDate)
            .orderType(orderType)
            .customer(New Customer(customerId))
            .build();
  }

해당 로직에서 Entity를 Select하는대신 id 컬럼만 dto를통해서 조회한 결과 2500만건의 데이터를 기준으로 5~6배의 속도가 차이나기 때문에
실제로 모든 컬럼이 필요한지 아닌지 충분히 고민하고 사용

select 컬럼에 Entity 자제 - distinct문제

distinct가 있을 경우 customer컬럼까지 모두 포함해서 대상이 됨
따라서 distinct를 위한 임시 테이블을 만들기 위해 필요한 공간과 시간들 전부가 필요함 따라서 기존대비 성능이 엄청 떨어짐

Group By 최적화

MySQL에선 Group By를 실행하면 filesort가 필수로 발생함
물론 해당 쿼리가 index를 타지 않은 경우에 한함
하지만 사용한 모든 Group By query가 index를 탄다는 보장이 없기 때문에 Filesort가 빈번하게 발생함

이 부분에 관한 해결책은 MySQL에선 order by null을 사용하면 Filesort가 제거된다.

하지만 Querydsl에서는 order by null 문법을 지원하지 않음.

따라서 orderByNull을 직접적으로 구현해서 우회하는 방식을 사용해야함
OrderByNull.java

public class OrderByNull extends OrderSpecifier {
    public static final OrderByNull DEFAULT = new OrderByNull();

    private OrderByNull(){
        super(Order.ASC, NullExpression.DEFAULT,NullHandling.Default);
    }
}

쿼리 사용

.groupBy(txAddition.type, txAddition.code)
.orderBy(OrderByNull.DEFAULT)

해당 형태로 위에서 만든 클래스를 적용함

해당 Query를 날려보면 2500만건 데이터 기준 성능차이가
5배~6배 차이남

추후 개선점

이후 조금 더 개선하자면
정렬이 필요하더라도 조회 결과가 100건 이하 라면,
애플리케이션에서 정렬을 해주는게 좋다
WAS자원이 DB자원보다 훨씬 널널한 경우가 많다
DB는 3~4대지만 WAS는 10대 이상을 유지하는 경우가 많음

단 페이징일 경우 order by null을 사용하지 못하기 때문에
페이징이 아닌경우에만 사용(이건 사용할 수 있나 확인해봐야함 그렇지만 페이징시 offset을 사용하던 cursor를 사용하던 내가 설정한 페이지 수를 가져올텐대 이거에 group by로 수를 세어야하나?)

커버링 인덱스

커버링 인덱스: 쿼리를 충족시키는데 필요한 모든 컬럼을 갖고 있는 인덱스

select/where/order by/group by등에서 사용되는 모든 컬럼이 인덱스에 포함된 상태이며

NoOffset방식과 더불어 페이징 조회 성능을 향상시키는 가장 보편적인 방법임

select *
from academy a
join (select id
		from academy
        order by id
        limit 10000, 10) as temp
on temp.id = a.id

하지만 아쉽게도 JPQL에선 from절의 서브쿼리를 지원하지 않음

우회법

커버링 인덱스 조회는 나눠서 진행
Cluster Key(PK)를 커버링 인덱스로 빠르게 조회하고,
조회된 Key로 SELECT컬럼들을 후속 조회한다.

    /**
     * 커버링 인덱스 by Querydsl
     * */
    public List<BookingPaginationDto> paginationCoveringIndex(String name, int pageNo,int pageSize){
        // 1) 커버링 인덱스로 대상 조회
        List<Long> ids = queryFactory.select(booking.id)
                .from(booking)
                .where(booking.real_name.like(name + "%"))
                .orderBy(booking.id.desc())
                .offset(pageSize)
                .offset((long) pageNo *  pageSize)
                .fetch();
        // 1-1) 대상이 없을 경우 추가 쿼리 수행 할 필요 없이 바로 반환
        if(CollectionUtils.isEmpty(ids)){
            return new ArrayList<>();
        }

        // 2)
        return queryFactory.select(Projections.fields(BookingPaginationDto.class,
                booking.id.as("id"),
                booking.real_name,
                booking.people,
                booking.store,
                booking.date,
                booking.time,
                booking.bookingState))
                .from(booking)
                .where(booking.id.in(ids))
                .orderBy(booking.id.desc())
                .fetch();
    }

해당 쿼리문은 향로님의 쿼리를 바탕으로 내가 테스트 하려고 내 Entity와 Repository를 사용해서 만듬

커버링 인덱스에 관한 향로님의 원래 코드는 해당 영상 14분부터 or 블로그에서 볼수있음

커버링 인덱스 사용시 페이징에 걸리는 시간은 1억건의 데이터 기준으로

방식시간
기존 페이징26s
jdbc 커버링0.27s
querydsl 커버링0.58s
profile
어제보다 개발 더 잘하기 / 많이 듣고 핵심만 정리해서 말하기 / 도망가지 말기 / 깃허브 위키 내용 가져오기

0개의 댓글