3 분 소요

겁도 없이 프로젝트에 적용했던 Querydsl 도전기.

1. Querydsl, 왜 쓰나?

우리가 JPA를 사용하는 가장 큰 이유는 바로 Java언어의 특징인 ‘객체지향’ 을 잘 활용하기 위함임을 이미 공부했다. 그런데, 기존 JPA를 사용하더라도 결국 @Query 또는 JPQL을 작성해야 하는 상황이 있다.

테이블이 아닌 객체를 대상으로 쿼리를 작성하기 때문에 DB에 의존하지 않는다는 커다란 장점이 있다. 하지만, 내가 느끼는 가장 큰 단점은 결국 String 형식의 쿼리를 직접 작성해야 한다는 것이었다. (JPQL을 자바코드로 빌드할 수 있는 Criteria가 있다고 하는데, 너무 복잡하고 실용성이 없다고 한다). 까다로운 조건이 들어갈수록 쿼리의 복잡함과 떨어지는 가독성은 급격하게 늘어난다.

Querydsl은 이 두가지 부분을 개선해준다.

// JPQL 
select m from Member m where m.age > 18
    
// Querydsl
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
QMember m = QMember.member; // Q클래스

List<Member> list = queryFactory
    					.selectFrom(m)
    					.where(m.age.gt(18))
						.fetch();
  • 복잡한 다중조건 -> 동적쿼리로 인해 작성의 편리함과 체이닝형태(빌드형태)의 뛰어난 가독성
  • Java code -> 컴파일 단계에서 오류확인 가능 (개발공부를 하면 할수록 느끼는 것이, 컴파일 오류가 엄청나게 좋다라는 것이다…)

위 두가지가 Querydsl을 사용했을 때 오는 큰 장점이라고 많이 소개되어 있다. 대부분 공감하며, 특별히 개발 초보입장에서 느껴지는 강력한 장점은 ‘Java’ 코드라서 프로젝트 전체에서 이질감 없이 잘 녹아든다는 것이다. 접근하여 사용하는 것에 부담이나 거부감이 없는 것이 정말 좋았다.

2. 프로젝트 세팅

  • Querydsl은 세팅이 반 이상이다.

1) build.gradle

// buildscript 추가
buildscript {
    ext {
        queryDslVersion = "5.0.0"
    }
}
// plugins 추가
plugins {
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}
// dependencies 추가
dependencies {
    implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
    annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}"
}

// 기타 내용 추가
def querydslDir = "$buildDir/generated/querydsl"

querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}

sourceSets {
    main.java.srcDir querydslDir
}

configurations {
    querydsl.extendsFrom compileClasspath
}

compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}
  • 빌드 세팅이 많이 복잡하다. 그리고 무엇보다, springboot 버전과 jar 버전에 따라 세팅이 달라질 수 있다고 한다.
  • 작성자의 프로젝트 세팅
    • springboot: 2.6.12
    • jar: 1.8

2) Q클래스 생성

  • Querydsl 사용을 위해서는 이를 위한 인스턴스 Q클래스를 컴파일을 통해 생성한다.

    • Gradle - Tacks - other - compileQuerydsl 실행

    • Gradle - Tacks - other - compileJava 실행

  • 이렇게 하면 build - generated - querydsl 디렉토리에 entity마다 해당하는 Q클래스가 따로 생성된다.
  • 이 클래스는 코드 작성 시 테이블 -> 객체로 매핑된다.

3) JpaQueryFactory(EntityManager) 빈 등록

  • Config 클래스를 활용해서 쿼리팩토리를 bean으로 등록하여 사용한다.
@Configuration
public class QueryDslConfig {
  @PersistenceContext private EntityManager em;

  @Bean
  public JPAQueryFactory jpaQueryFactory() {
    return new JPAQueryFactory(em);
  }
}

3. JpaQueryFactory 사용

  • Querydsl의 글 대부분이 사진과 같이 Custom클래스를 만들어 JpaRepository를 간접적으로 상속받아 사용하는 구조를 갖는다. img

  • 이 부분이 상당히 귀찮고(?) 특히 큰 프로젝트가 아닌 상황에서 불필요한 구조라고 생각하여 다른 방법이 있는지 찾아보았는데, 우아한테크 우아콘 영상에서 JpaQueryFactory를 단독으로 사용하여 구현하는 방법을 소개해줬다.
  • 핵심은, Querydsl을 사용하기 위해서는 결국 JpaQueryFactory만 있으면 된다는 것이었다.
  • 우리는 이미 config로 빈 등록을 해두었기 때문에, 바로 사용해보자!
@Repository
@RequiredArgsConstructor
public class QueryRepository {

    private final JPAQueryFactory queryFactory;

    public List<Dto> getDtoList () {
        return queryFactory
            .select()
            .from()             
            .fetch();
    }
  • 프로젝트에 사용한 클래스를 간략화해놓았다. 핵심은 필드맴버에 있는 JPAQueryFactory!!

4. Querydsl 문법

  • 이제는 사용이 문제였다.

1) 기본 메서드

  • select()
  • from()
  • selectFrom() - select와 from을 합해놓은 것. select 대상이 동일한 엔티티면 selectFrom(member)와 같이 사용 가능함.
  • where()
  • update()
  • set()
  • delete()
  • join()
  • on()
  • leftJoin()
  • rightJoin()
  • and()
  • sum(), avg(), min(), max(), count(), divide(), multiply(), round() 등
  • groupBy(), having()
  • orderBy(), asc(), desc()

2) 결과조회 메서드

  • fetch() - 리스트 조회. 데이터가 없으면 빈 리트스 반환

  • fetchOne() - 단건 조회. 결과가 없으면 null, 2 이상이면 exception

  • fetchFirst() = limit(1).fetch()

  • fetchResults() - 페이징 정보 포함.

    QueryResults<Member> result = queryFactory
                    .selectFrom(member)
                    .orderBy(member.username.desc())
                    .offset(1)
                    .limit(2)
                    .fetchResults();
    
  • fetchCount() - 카운트 수 조회.

3) 기타 자세한 메서드 활용법

5. Querydsl 사용시 발생이슈

1) Tuple -> Dto

쿼리를 날려 받아오는 List는 당연(?)하게도 Tuple이라는 특정 클래스 형태이다. 뭐, 받아온 내용을 loop 돌려서 Dto로 다시 변환해도 상관은 없지만, 이게 Test할때 골때린다.

찾아보다가, query 안에서 바로 Dto형태로 받아오는 방법을 발견했다.

List<MemberDto> result = queryFactory
                            .select(Projections.constructor(MemberDto.class,
                                      member.username,
                                      member.age))
                            .from(member)
                            .fetch();

Projections라는 클래스를 사용하여 select 안에 Dto 변수를 바로 선언해줄 수 있다. 여러가지 다른 방법은 참고한 블로그 사이트를 공유한다.

다만, 이렇게 하는 경우 QueryRepository가 Dto에 의존성이 발생한다. 득실을 따져보고 적용해볼만 한 것 같다.

2) Mocking Test

Querydsl을 시도하고 가장 후회(?)했던 순간이었다. 필드와 어노테이션들은 기본적으로 Service단과 동일하게 가져가는 것이 가능하다.

  • 문제 1) query를 메서드별로 JPAQuery클래스에 담아서 넘겨줘야 한다는 것이다…

  • 문제 2) any()를 사용할 때도 query에 맞는 클래스를 명시해주어야 한다는 것이다…

  • 테스트 대상 코드

public List<ReportDto> getMonthlyIncomeReport (Long id, LocalDate startDt, LocalDate endDt) {

        return queryFactory
            .select(Projections.constructor(ReportDto.class,
                incomeCategory.incomeCategoryName,
                income.incomeAmount.sum(),
                income.incomeAmount.sum()
                    .multiply(100).doubleValue()
                )
            )
            .from(income)
                .leftJoin(income.detailIncomeCategory.incomeCategory, incomeCategory)
            .where(income.member.memberId.eq(id)
                .and(income.incomeDt.between(startDt, endDt))
            )
            .groupBy(incomeCategory.incomeCategoryName)
            .orderBy(income.incomeAmount.sum().desc())
            .fetch();

    }
  • 테스트 코드
JPAQuery step1 = mock(JPAQuery.class);
given(queryFactory
        .select(Projections.constructor(ReportDto.class,
            incomeCategory.incomeCategoryName,
            income.incomeAmount.sum(),
            income.incomeAmount.sum()
        .multiply(100)
        .doubleValue())))
	.willReturn(step1);

JPAQuery step2 = mock(JPAQuery.class);
given(step1.from(any(EntityPath.class)))
	.willReturn(step2);

JPAQuery step3 = mock(JPAQuery.class);
given(step2.leftJoin(any(EntityPath.class), any(EntityPath.class)))
	.willReturn(step3);

JPAQuery step4 = mock(JPAQuery.class);
given(step3.where(any(BooleanExpression.class)))
	.willReturn(step4);

JPAQuery step5 = mock(JPAQuery.class);
given(step4.groupBy(incomeCategory.incomeCategoryName))
	.willReturn(step5);

JPAQuery step6 = mock(JPAQuery.class);
given(step5.orderBy(income.incomeAmount.sum().desc()))
	.willReturn(step6);

given(step6.fetch())
	.willReturn(reportDtoList);

어렵지는 않지만, 매우 귀찮다,,,,


마지막 수정일시: 2022-10-29 11:28

카테고리:

업데이트:

댓글남기기