QueryDSL 조회 개선

iqpizza6349·2023년 5월 10일
0

Spring

목록 보기
4/6
post-thumbnail

산학 프로젝트를 진행 중 필터링을 통한 조회라는 기능이 필요하여 유연한 동적 쿼리를 사용할 수 있는 QueryDSL를 사용하였다.

필터링 조건은 총 7가지였고, 그 중 선택된 조건들을 사용하여서 데이터를 추출해야했다.
데이터베이스는 회사에서 이미 만들어져 샘플들까지 이미 입력되어있는 데이터베이스를 사용하여 결과값을 확인했다. 그렇기에 테이블의 제약조건들은 일체 건드리지 않고 QType 을 생성하였다.
(샘플 데이터의 갯수는 약 24만개이다.)

첫 번째 시도 (최초 구현)

최초 방식은 필터링된 결과를 paging하여 response했을 때 평균 3~5초 정도가 걸렸다.
하지만, pagination을 하지 않고 전체 결과값을 response하였을 때에는 평균 2, 3분 가량이 걸렸다. 쿼리에 뭔가 이상이 있음을 파악하고, 문제 파악에 나섰다.

기존 방식은 DTO를 사용하는 방식이 아닌 QType 자체를 조회하여, fetch하고 stream#map을 사용하여 DTO로 바꾸고 있었다.

List<Company> companines = query.select(company)
                                  .from(company)
                                  .where(predicate)
                                  .offset(offset)
                                  .limit(30)
                                  .fetch();
return companies.stream()
				.map(FilteredData::new)
                .collect(Collectors::toList());

하지만 이러한 방식은 불필요하게 많은 데이터들을 가지고 있을 뿐 아니라, Join 역시 Hibernate의 기본 조인 방식인 Cross-Join을 하게 되어 성능 저하를 불러왔다.

두 번째 시도 (cross-join 회피?)

첫 번째 시도에서 발생한 심각한 성능 저하와 cross-join를 회피하고자 Cross-Join이 발생하지 않도록 명시적으로 join을 적어주었다.
또, 전체 조회(*)를 해소하고자 조회를 FilteredData로 하기로 결정

return query.select(Projections.constructor(FilteredData.class, company))
			.from(company)
            .innerJoin(company.businessTypes, companyBusiness)
            		.on(company.id.eq(companyBusiness.company.id))
             .innerJoin(company.industryTypes, companyIndustry)
            		.on(company.id.eq(companyIndustry.company.id))
            .where(predicate)
            .offset(offset)
            .limit(30)
            .fetch();

원래는 Projections#constructor 를 사용하여, company의 특정 칼럼만 추출하려고 시도하였지만, API 명세서상 company.businessTypes(List)와 company.industryTypes(List)를 생성자로 안전하게 넘기는 방법을 몰라, 결국 실패하였다.
DTO 클래스의 일부분은 다음과 같다.

this.businessTypes = company.businessTypes.stream()
                            .map(bt -> bt.getBusinessType().getName())
                            .collect(Collectors.toList());
this.industryTypes = company.industryTypes.stream()
                            .map(it -> it.getIndustryType().getName())
                            .collect(Collectors.toList());

세 번째 시도 (cross-join 회피)

일어나면 안되는 최악의 상황을 마주하였다. 앞서 두 번째 시도에서 cross-join을 전부 회피하였다고 착각하였던 것이었다. company의 주소지 역시 조회를 해야했는 데, 이 부분 역시 cross-join이 칼럼 하나당 추가로 발생했던 것이었다. 덕분에 Out Of Memory Error를 정말 오랜만에 봤다. (VM을 추가로 늘렸을 때에는 OOME는 발생하지 않았지만, 성능이 드라마틱하게 개선되지는 않았다.)

네 번째 시도 (@QueryProjection 사용)

조인이 얼떨결에 4개나 되었다. (지역, 주소지, 회사 종류, 사업 종류)
두 번째 시도에서 DTO 내부에서 참조가 4개나 있기에 inner-join과 별개로 추가 쿼리가 필연적으로 4번 쿼리가 더 생길 수 밖에 없다. 이러한 문제를 해결하고자 해당 회사에 근무하시는 선배(학교 졸업생)께 여쭤보았다.

..라고 답변해주셔서 @QueryProjection를 사용하여 FilteredData 클래스를 QType로 만들었다. 하지만 @QueryProjections를 사용하여 어떻게 문제를 해결해야하는 지 이해를 하지 못해 결국 다른 방법으로 시도하기로 했다.

다섯 번째 시도(MySQLDialect 커스텀 & 서브쿼리)

굉장히 더티한 방법으로 개선을 했다. hibernate가 지원하는 MySQLDialect를 상속하여 커스텀 MySQLDialect 클래스를 생성하고, 서브쿼리를 사용하여 pagination 사용시 평균 200~300ms 가량 발생한다. (실제 쿼리만 전송되는 시간은 평균 약 60 ~ 80ms가 발생했다.) pagination을 사용하지 않은 경우에는 약 8 ~ 9초 가량 발생했다. (실제 쿼리는 약 2 ~ 3초가 발생했다.)
CustomMySQLDialect 클래스는 다음과 같이 구성하였다.

public class CustomMySQLDialect extends MySQLDialect {
	public CustomMySQLDialect() {
    	super();
        this.registerFunction("GROUP_CONCAT",
        		new SQLFunctionTemplate(StandardBasicTypes.STRING, "GROUP_CONCAT(?1)"));
    }
}

거듭 설명했듯이 내부 참조를 최소화하고, 회사 종류와 사업 종류는 각각 N, M개 들어갈 수 있기에 어떻게 처리하나 생각 중에 하는 수 없이 MYSQL의 GROUP_CONCAT를 사용하여, ,를 기준으로 split하고 List로 만들면 되지 않을까 하는 생각에 이르렀다.
사실 생각해보면 썩 좋은 방법은 아니지만, 당시 내 뇌는 카페인(☕)에 절어서 더 좋은 대안을 떠올리지 못했다.

그래서 최종 개선 코드는 다음과 같다.

return factory.select(Projections.constuctor(FilteredData.class,
							company.id, ...,
                            select(addressCounty.name).from(addressCounty)
                            		.where(addressCounty.code.eq(company.county.code)),
                            select(addressState.name).from(addressState)
                            		.where(addressState.code.eq(company.state.code)),
                            stringTemplate("GROUP_CONCAT({0})", businessType.name),
                            stringTemplate("GROUP_CONCAT({0})", industryType.name)
                ))
				.from(company)
                // ... inner join 생략
                .where(predicate)
                .offset(offset)
                .limit(limit)
                .fetch();

굉장히 복잡한 쿼리가 전송된다. 조회해야할 칼럼이 매우 많기에 가독성을 개선시키는 방법을 따로 연구해야할 것 같다.

일단은 약 10배 이상 개선시켰지만, 더 개선의 여지가 있어보인다.
이와 관련된 유용한 글이 있어 이를 참고하여 개선하고자 한다.

최종

중간에 포함된 서브쿼리를 제거하고 조인으로 해결함으로써 최초 시도보다 14.4배 가량 개선시켰다.

profile
coffee.drinkUntilEmpty();

0개의 댓글