[영상후기] [10분 테코톡] 바론, 블랙캣의 Querydsl with JPA

박철현·2024년 10월 15일
0

영상후기

목록 보기
153/160

movie

JPQL 문제

  • JPQL로 쿼리를 잘못 작성했을 때 실제 사용자가 해당 API를 요청할 때 런타임 에러를 통해 잘못된 커리임을 알 수 있는 문제
  • Querydsl로 어느정도 해소 가능

DSL(Domain-Specific-Languages)

  • 특정 도메인에서 발생하는 문제를 효과적으로 해결하기 위해 설계된 언어
    • 데이터베이스 내 정보 검색, 삽입, 수정, 삭제 하기위한 SQL
    • 웹의 디자인, 레이아웃, 시각적 스타일링을 위한 CSS
    • 문자열 내 특정 패턴을 검색, 추출, 교체하기 위한 Regex

Querydsl

  • SQL 형식의 쿼리를 Type-Safe 하게 생성할 수 있도록 하는 DSL을 제공하는 라이브러리
    • JPA, SQL, Mongodb 등 다양한 모듈에 대해 지원하지만 발표에서는 JPA를 기준으로 설명

설정 방법

발표자의 설정

  • Spring Boot 3.1.4
  • IntelliJ IDEA 2023.2.2
  • Gradle 8.2.1
  • Querydsl 5.0.0

JPA 의존성 추가

    implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
  • Boot 3.0 부터 jakarta 변경 -> QueryDSL 버전 명시 뒤 추가

QClass 생성을 위한 annotationProcessor 추가

    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"

QClass란?

  • 엔티티 클래스 속성과 구조를 설명해주는 메타데이터
    • Type-safe 하게 쿼리 조건 설정 가능
  • 자기 자신 속성을 QClass로 가짐
    • alias로 활용할 수 있음

QClass 생성 위치 IntelliJ 설정에 따라 달라지니 협업을 위해 명시적 Gradle 설정 권장

  • Gradle 설정
    • Gradle : build/generated directory에 QClass 생성
    • IntelliJ : src/main/generated에 생김
  • 매번 소스에 맞게 QClass를 옮겨줄 순 없으니 gradle script 추가 권장

def generated = 'src/main/generated'

// querydsl QClass 파일 생성 위치 지정
tasks.withType(JavaCompile).configureEach {
    options.getGeneratedSourceOutputDirectory().set(file(generated))
}

// gradle clean 시에 QClass 디렉토리 삭제
clean {
    delete file(generated)
}

.gitignore 등록

  • Querydsl 라이브러리 버전에 따라 생성되는 QClass 생김새가 달라질 수 있다.
    • /src/main/generated/ 를 QClass 생성 위치로 사용하고 싶다면 꼭 ignore 등록
    • 라이브러리 버전에 따라 생성되는 QClass 생김새가 달라질 수 있기 때문에 추가하는 것 추천
    ### Querydsl ###
    /src/main/generated/

JPARepository와 함께 사용하기

JPAQueryFactory Bean 등록

@Configuration
public class QueryDslConfig {
	@PersistenceContext
    private EntityManager entityManager;
    
    @Bean
    public JPAQueryFactory jpaQueryFactory() {
    	return new JPAQueryFactory(entityManager);
    }
}

CustomRepository 인터페이스 생성

public interface CustomStoreRepository extends JpaRepository<Story, Long>{
	List<PopularStoreDto> findPopularStoreThan(final long id);
    public List<Store> findStoreByStoreCategoryByPaging(final StoreCategory storeCategory, final Long offset, finet Long size);
}

CustomRepository 인터페이스 구현

  • 꼭 인터페이스 이름 뒤 Impl을 뒤에 작성
@RequiredArgsConstructor
public class CustomStoreRepositoryImpl implements CustomStoreRepository {
	private final JPAQueryFactory jpaQueryFactory;
    
    @Override
    List<PopularStoreDto> findPopularStoreThan(final long id) {
    	return /* querydsl 코드 */
    }
    
    @Override
    public List<Store> findStoreByStoreCategoryByPaging(final StoreCategory storeCategory, final Long offset, finet Long size) {
    	return /* querydsl 코드 */
    }
    
}

사용할 JPA 레포지토리 수정

public interface CustomStoreRepository extends JpaRepository<Story, Long>, CustomStoreRepository {
	List<PopularStoreDto> findPopularStoreThan(final long id);
    public List<Store> findStoreByStoreCategoryByPaging(final StoreCategory storeCategory, final Long offset, finet Long size);
}

사용

조회

@Override
public List<Store> findStoreByStoreCategory(final StoreCategory storeCategory) {
	return jpaQueryFactory
    	.select(store)
        .from(store)
        .where(store.storeCategory.eq(storeCategory))
        .fetch();
}
  • select절 : QClass 혹은 QClass에 들고 있는 필드를 넣어줌
    • 어디서 조회할지에 대한 QClass를 넣어주면 됨
    • 만약 select와 from이 동일하다면 selectFrom() 메서드를 사용할 수 있음
        	.select(store)
          .from(store)
          
          .selectFrom(store)
  • where절 : 다양한 조건 메서드 사용 가능
	store.storeName.eq("store1")
    store.storeName.ne("store1")
    store.storeName.eq("store1").not
    store.storeName.isNotNull()
    
    store.id.in(0, 10)
    store.id.notIn(0, 10)
    store.id.between(0, 10)
    store.id.goe(10)
    store.id.gt(10)
    store.id.loe(10)
    store.id.lt(10)
    
    store.storeName.like("store%")
    store.storeName.contains("store")
    store.storeName.startswith("store")
  • 결과 조회
.fetchOne() // return Store

.fetch() // return List<Store>

.fetchFirst() // limit(1).fetchOne()

정렬

@Override
public List<Store> findStoreByStoreCategory(final StoreCategory storeCategory) {
	return jpaQueryFactory
    	.select(store)
        .from(store)
        .leftJoin(store.orders, order)
        .leftJoin(order.review, review)
        .where(store.storeCategory.eq(storeCategory))
        .groupBy(store)
        .orderBy(review.rate.sum().desc())
        .fetch();
}

Querydsl 제공 join

  • innerJoin, join

    • innerJoin() 이나 join() 메서드 모두 innerJoin()으로 동작
    • join() 메서드 명시해주면 됨
    • innerJoin의 경우 on절로 조건을 명시하지 않고 where 절로 조건을 명시해도 똑같이 동작하기 때문에 where절이 좀 더 가독성 좋게 작성할 수 있음
  • outerJoin(), leftJoin(), rightJoin()도 제공함

.innerJoin(order.review, review)
.on(review.content.contains("맛있어요"))

.join(order.review, review)
.on(review.content.contains("맛있어요"))

.join(order.review, review)
.where(review.content.contains("맛있어요"))

.leftJoin(order.review, review)
.on(review.content.contains("맛있어요")

.rightJoin(order.review, review)
.on(review.content.contains("맛있어요"))
  • 만약 두 테이블 간 연관 관계가 없다면?
    • join 메서드 안에 연관관계가 없는 두 테이블을 넣기
    • where 조건으로 해당되는 조건을 걸어주면 됨
    • 연관관계가 없는 테이블간 조인은 카테시안 곱이 발생함
      .join(order, review)
      .where(order.review.eq(review.content))
    • outer join은?
      • 동일하게 fron절에 join 하고 싶은 테이블명을 leftJoin()에 걸어주면 됨
      • **연관관계가 없는 테이블간 외부 조인은 조인메서드의 하나의 QClass만 명시)
    .from(order)
    .leftJoin(review)
    .on(order.review.content.eq(review.content))
  • N + 1 문제 해결
    • fetchJoin()이란 메서드를 직관적으로 제공
.leftJoin(store.orders, order)
.fetchJoin()
  • 2개 이상의 OneToMany 관계는 연속된 fetchJoin 불가
    • Querydsl이 아닌 JPQL의 특징
.leftJoin(store.orders, order)
.fetchJoin()
.leftJoin(store.review, review)
.fetchJoin()
  • groupBy(), orderBy()
    • 집계함수는 Select, GroupBy 등 다양한 곳에서 활용 가능
      • sum(), avg(), count() 등의 다양한 메서드 제공
    • outer join null 발생 가능성
      • null값들을 맨 앞에 넣을건지, 맨 뒤에 넣을건지 nullFirst, nullLast 메서드 존재
.orderBy(review.rate.sum().desc())

.orderBy(review.rate.avg().asc())

.orderBy(review.rate.count().desc().nullsLast())
.orderBy(review.rate.count().desc().nullsFirst())
.groupBy(Store)
.orderBy(review.rate.sum().desc())
  • offset, limit
    • offset : 몇번째 페이지 조회할 것인지
    • limit : 몇 개 만큼의 사이즈를 가져올 것인지
    @Override
    public List<Store> findStoreByStoreCategory(final StoreCategory storeCategory, final Long offset, final Long size) {
      return jpaQueryFactory
      .select(store)
      .from(store)
      .leftJoin(store.orders, order)
      .leftJoin(order.review, review)
      .where(store.storeCategory.eq(storeCategory))
      .groupBy(store)
      .orderBy(review.rate.sum().desc())
      .offset(offset)
      .limit(size)
      .fetch();
    }

서브 쿼리

JPAQuery 사용

  • 일반적으로 사용하는 쿼리문 생성
  • 사용 범위가 넓음

JPAExpressions

  • 유틸성 클래스
  • 다양한 select 메서드 지원
  • 사용 범위가 서브 쿼리에 더 맞춰져 있음

요구사항 1. 가게 상관없이 전체 리뷰 평점 계산

final QReview notJoinedReview = new QReview("review2");

JPAExpressions
	.select(notJoinedReview.rate.avg())
    .from(notJoinedReview);

요구사항 2. 전체 리뷰 평점보다 가게의 평점이 크거나 같은 가게만 필터링

having(review.rate.avg().goe(
        JPAExpressions
        .select(notJoinedReview.rate.avg())
        .from(notJoinedReview)
    )
)

요구사항 Querydsl

가게에 상관없이 계산된 전체 리뷰 평점보다 특정 가게의 모든 주문의 리뷰 평점이 높은 가게만 골라서 평점 순으로 조회해주세요

  @Override
  public List<Store> findStoreByStoreCategory(final StoreCategory storeCategory, final Long offset, final Long size) {
    return jpaQueryFactory
    .select(
    Projections.fields(
    	// 가게, 가게의 평점 조회
    ).from(store)
    .leftJoin(store.orders, order)
    .leftJoin(order.review, review)
    .groupBy(store.id)
	.having(
        review.rate.avg().goe(
          JPAExpressions
          .select(notJoinedReview.rate.avg())
          .from(notJoinedReview)
        )
     )
    .orderBy(review.rate.sum().desc())
    .fetch();
  }

서브 쿼리 메소드로 분리

  • 서브쿼리 메서드로 분리 가능
    • 한줄의 having만으로도 어떤 조건을 걸고 싶은지 바로 이해 가능
.having(review.reate.avg().goe(
	JPAExpressions.select(reviewforSelectAllAvg.rate.avg())
    .from(reviewforSelectAllAvg)
))
.having(review.reate.avg().goe(calculateTotalRateAvg()))

private JPQLQuery<Double> calculateTotalRateAvg() {
	final QReview notJoinedReview = new QReview("review2");
    
    return review.rate.avg().goe(
    	JPAExpressions.select(reviewforSelectAllAvg.rate.avg())
    .from(notJoinedReview)
))
  • 새로운 QClass 생성 이유?
    • leftJoin에 사용된 review와 서브 쿼리에 사용되는 review 충돌 방지
    • 즉 메인 쿼리와 서브 쿼리 사이의 별칭 충돌 방지
    • 별칭을 새롭게 지정하지 않는다면 동일한 review에 대해 같거나 큼을 비교 -> 의도와 다른 쿼리

동적 쿼리 & 동적 정렬

가게 평점 순, 주문 순 중 선택해서 조회
원하는 기준 이상의 가게들만 조회

조건

  • 평점순 / 주문순

원하는 기준 값 자유

  • 최소값 / 미지정 : 필터링X

추가된 요구사항 분석

  1. 기준이 주문 순인지 평점 순인지에 따라 계산식, 정렬 순서가 달라진다.
  2. 기준이 되는 최소값의 선택 여부에 따라 가게들을 필터링 여부가 결정된다.

동적 쿼리

BooleanExpression

.from(store)
.where(store.name.eq("덮밥"))
  • BooleanExpression : 조건을 나타내는 부분에 들어가는 참/거짓 표현식

여러 BooleanExpression을 조합해서 사용

.from(store)
.where(expression1, expression2)

private BooleanExpression expression1(final String name) {
	return null이 아닌 expression;
}

private BooleanExpression expression2(final String name) {
	return null이 아닌 expression;
}
  • 컴마, and, or 로 조합해서 하나의 더 큰 Expression을 만들 수도 있음
    • 컴마 : and와 똑같은 의미를 가짐

null 반환 시 자동으로 조건에서 무시

.from(store)
.where(expression1, expression2)

private BooleanExpression expression1(final String name) {
	if (name == null) {
    	return null;
    }
	return null이 아닌 expression;
}

private BooleanExpression expression2(final String name) {
	return null이 아닌 expression;
}
  • expression1이 null 되면 expression2만 고려함

완성 쿼리

.having(matchesCondition(type, min))

private BooleanExpression matchesCondition(final String type, final String min) {
	// 주문순
	if(type.equals("orderCount")) {
    	return filterByOrderCount(min);
    }
    // 평점순
    if(type.equals("rate")) {
    	return filterByRate(min);
    }
    // 그 외 나머지 타입의 경우 조건 무시(필터링 안함)
    return null;
  • 필터링 조건을 바로 알 수 있도록 메서드 이름을 네이밍 하면 having 한줄만 보고도 어떤 조건으로 필터를 할 수 있을지 가독성 향상의 장점

동적 정렬

  • type이 "orderCount"면 주문 순 정렬, "rate"면 평점 순 정렬 기능은 아직 미구현
    • 직접 고정된 정렬 조건
.orderBy(review.rate.avg().desc(), store.id.desc())
  • 고정된 정렬 조건을 동적인 정렬 조건으로 변경 OrderSpecifier 생성

OrderSpecifier

    public OrderSpecifier(Order order, Expression<T> target) {
        this(order, target, NullHandling.Default);
        // Order : ASC, DESC enum class
        // target : 정렬 기준이 되는 QClass 필드
        // OrderSpecifier[] : 여러 정렬 조건을 순서대로 순차 정렬(배열 형태 가능, 첫번째꺼로 정렬 두번째, ...)
    }
  • Pageable의 정렬인 Sort를 사용하여 동적인 OrderSpecifier 생성

    • Sort도 개발자가 정렬할 어떤것을 기준으로 정렬할 지 문자열로 적어주면 됨
    • 엔티티의 필드일 필요는 없음
  • ORderSpecifier... 코드 추후 다시 돌려보며 추가 예정..

적용 코드

.orderBy(getSortCondition(sort))
  • 동적 쿼리처럼 메서드 이름을 통해 어떤 조건인지 알 수 있어 메서드명만 보고도 어떤 쿼리인지 판단할 수 있게 됨

Query dsl 정리

  • 가독성 향상
    • 메서드 네이밍을 통해 쿼리 조건, 정렬 방식 유추 가능
  • 메서드 분리를 통한 재사용성 향상
  • Type-safe한 QClass를 통한 문법으로 인한 런타임 에러 방지
    • 필드에 접근할 때도 QClass.필드 로 접근하기에 필드명 잘못 써서 생기는 문제 절대 생기지 않음

주의할점

주의할 점 1 - 1차 캐시 장점을 누릴 수 없다.

  • JPQL의 특징
  • Querydsl 작성 -> JPQL로 변환되어 DB한테 쿼리 전송
    • 항상 DB로 쿼리를 보냄
    • JPQL 메서드 호출 시 flush() 항상 일어나기 때문
  • Type-safe한 JPQL 빌더임을 인식하고 사용해야 한다.

주의할 점 2 - Querydsl의 마지막 업데이트..

  • 22년 7월 마지막 업데이트, 심지어 Java 8 기준 업데이트
  • 업데이트 없을 예정..!

주의할 점 3 - 한 방 쿼리 조심

  • 쿼리가 문득 점점 길어지고 있다면? 쿼리에 비즈니스 로직이 들어있는 것이 아닐까 의심해보자
profile
비슷한 어려움을 겪는 누군가에게 도움이 되길

0개의 댓글

관련 채용 정보