우아콘 QueryDSL

zini9188·2024년 2월 28일

추가공부

목록 보기
4/4

우아콘 QueryDSL 요약

QueryDsl이란?

  • SQL 형식의 쿼리를 Type-Safe하게 생성하도록 DSL을 제공하는 라이브러리
  • 정적 타입을 이용한 SQL과 같은 쿼리를 생성
  • DSL (Domain-Specific-Language)
    • 특정 도메인에서 발생하는 문제를 효과적으로 해결하기 위해 설계된 언어

사용 이유

  • 순수 Data JPA만으로는 복잡한 쿼리, 동적 쿼리를 구현하는 데 한계가 존재
  • 복잡한 쿼리의 경우 문자열이 상당히 길어짐
  • 기존 JPQL, Criteria 등 문자열 형태의 쿼리문은 정적 쿼리인 경우 어플리케이션 로딩 시점에 이를 발견할 수 있지만, 그 외에는 런타임 시점에 에러가 발생
  • QueryDSL은 자바 코드로 쿼리를 작성하기에 컴파일 시점에 오류 발견 가능
    • 코드 기반이므로 자동완성 등의 IDE의 도움을 받음
    • 동적 쿼리의 작성이 편리
    • 제약 조건을 메서드 추출로 재사용 가능

QClass란?

  • Entity 클래스 속성과 구조를 설명해주는 Meta-Data
  • Entity로 등록한 클래스들을 Q라는 접두사가 붙은 형태로 만들어진 클래스
  • Q는 쿼리를 의미하며, Q 클래스 혹은 쿼리 타입이라고 불림
  • Q클래스를 통해 Type-Safe한 쿼리 작성이 가능

개발 환경

  • Spring Boot 3.2.1
  • QueryDSL 5.0.0
  • Gradle 8.5

환경 설정

  • build.gradle dependencies
    dependencies {
    	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    	runtimeOnly 'com.mysql:mysql-connector-j'
    	compileOnly 'org.projectlombok:lombok'
    	annotationProcessor 'org.projectlombok:lombok'
    
    	// queryDSL
    	implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    	
    	// QClass 생성을 위한 annotaionProcessor
    	annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    	annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    	annotationProcessor "jakarta.persistence:jakarta.persistence-api"
    }

사용법

  1. JPARepository 선언

    public interface ExampleRepository extends JpaRepository<Example, Long> {
    }
  2. JPAQueryFactory 빈 등록

    @Configuration
    public class QueryDslConfig {
    		
    		@PersistenceContext
    		private EntityManager entityManager;
    		
    		@Bean
    		public JPAQueryFactory jpaQueryFactory(){
    				return new JPAQueryFactory(entityManager)
    		}
    }
  3. CustomRepository interface 생성

  4. 선언한 인터페이스 구현 (반드시 인터페이스 이름 뒤 Impl을 붙여 구현)

  5. 사용하려는 Repository에서 extends로 CustomRepository 상속받아 사용


QueryDSL을 이용한 성능 개선 및 주의점

Entity가 정말 필요한 것이 아니면, QueryDsl과 Dto만으로 필요한 항목만 조회하고 업데이트한다.

extends/implements 사용하지 않기

  • JPAQueryFactory만 있으면 QueryDSL 사용 가능
  • 매번 Interface, Impl 구조를 만들지 않아도 됨
  • 매번 특정 Entity에 구애받지 않고 사용 가능
    @Repository
    @RequireArgsContstructor
    public class QueryRepository {
    		private final JPAQueryFactory queryFactory;
    
    		// QueryDsl 메서드 작성
    
    }

동적 쿼리

  • BooleanBuilder를 사용하는 경우 어떤 쿼리인지 예상하기 어려움
    @Override
    public List<Member> findDynamicQuery(Stirng nmae, String address, String phoneNumber){ 
    		BooleanBuilder builder = new BooleanBuilder();
    
    		if(!StringUtils.isEmpty(name)){
    			builder.and(member.name.eq(name));
    		}
    		
    		if(!StringUtils.isEmpty(address)){
    			builder.and(member.address.eq(address));
    		}
    		
    		if(!StringUtils.isEmpty(phoneNumber)){
    			builder.and(member.phoneNumber.eq(phoneNumber));
    		}
    	
    		return queryFactory
    						.selectFrom(academy)
    						.where(builder)
    						.fetch();
    }

BooleanExpression 을 이용하여 조건을 메서드 단위로 분리

  • null을 반환하면 자동으로 조건절에서 제거
  • 모든 조건이 Null이면 조건문이 사라져 서비스에 많은 장애가 발생하니 사용에 주의
@Override
public List<Member> findDynamicQuery(Stirng nmae, 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 member.name.eq(name);
}

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

Select에서 성능 개선 방법

1. QueryDSL의 exist 금지

  • SQL에서의 exist는 값이 처음으로 발견되면 종료하며, count는 모든 데이터를 찾음 → 스캔 대상이 앞에 있을 수록 더 심한 성능 차이가 발생
  • QueryDSL의 exist method는 SQL에서 count와 동작 원리가 같아 성능에 있어 불리

2. SQL의 exist를 직접 구현하기

  1. limit 1을 이용한 조회 제한
    1. 결과가 없는 경우 null이기 때문에 0으로 비교하지 않고 null인지를 판별

      @Transactional(readOnly = true)
      public Boolean exist(Long memberId){
      		Integer fetchOne = queryFactory
      							.selectOne()
      							.from(member)
      							.where(mebmer.id.eq(mebmerId))
      							.fetchFirst();
      
      		return fetchOne != null;
      }

3. Cross Join 회피

  • Cross Join은 나올 수 있는 모든 경우의 수를 대상으로 찾으므로 성능이 좋지 않음
    • 일부 DB의 경우 어느정도 최적화가 지원
    • JPA에서 실질적인 Join을 하지 않더라도, where문에서 묵시적 Join이 발생
      • 이때 사용되는 Join은 Cross Join
      • Hibernate 이슈로 Spring Data JPA에서 JPQL을 사용하더라도 동일하게 발생하는 이슈

→ 명시적 Join을 이용한 Inner/Outer Join 사용

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

4. Entity가 아닌 Dto 우선 조회

  • JPA를 사용할 때 Entity와 Dto를 동일 선상으로 두는 것은 좋지 않음
  • Entity 조회의 단점
    • Hibernate 캐시
    • 불필요한 컬럼 조회
    • OneToOne N + 1 쿼리
    • 단순 조회 기능에서 성능 이슈 요소가 많음
  1. Entity 조회

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

  2. Dto 조회

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

4-1 조회 컬럼 최소화하기

  • 이미 알고 있는 값은 조회하지 않음
    • as 표현식을 이용하면 select에서 제외 가능
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();
}

4-2 Select 컬럼에 Entity 사용 자제

  • oneToOne은 Lazy Loading이 안됨 → N + 1이 무조건 발생
  • 연관된 Entity의 save를 위해서는 반대편 Entity ID만 있으면 됨
    • insert 기준으로 반대편 Entity Id만 조회하여 저장하면 됨
  • distinct 문제 발생
    → select에 선언된 Entity 컬럼 전체가 대상이 되므로 문제 발생

4-3 Group By 최적화

  • MySQL에서는 Group By를 실행하면 Filesort가 필수로 발생 (Index가 아닌 경우)
    • order by null을 사용하면 Filesort가 제거됨
    • 그러나 QueryDSL에서는 해당 문법을 지원하지 않음
      → 우회를 통한 최적화 필요
      public class OrderByNull extends OrderSpecifier {
      		public static final OrderByNull DEFAULT = new OrderByNull();    
      		private OrderByNull(){
      			super(Order.ASC, NullExpression.DEFAULT, Default);
      		}
      }    
      // .orderBy(OrderByNull.DEFAULT)
    • 정렬이 필요해도, 조회 결과가 100건 이하인 경우 애플리케이션에서 정렬
      • DB보다 WAS의 자원이 저렴하여 WAS가 자원적으로 여유로움
      • 페이징의 경우 order by null을 사용하지 못하므로 페이징이 아닌 경우에만 사용

Update/Insert에서 성능 개선 방법

일괄 Update 최적화

  • JPA를 사용하는 경우 DirtyChecking을 많이 사용 → Transaction 내부에 있을 때, Entity를 조회하여 Entity 값을 변경하면 자동으로 DB에 적용되는 것

    DirtyChecking

    List<Student> students = queryFactory
    				.selectFrom(student)
    				.where(student.id.loe(studentId))
    				.fetch();
    
    for(Student student : students){
    		student.updateName(name);
    }

    → 실시간 비지니스처리, 실시간 단건 처리의 경우

    Querydsl.update

    queryFactory.update(student)
    				.where(student.id.loe(studentId))
    				.set(student.name, name)
    				.execute();

    → Hibernate Cache는 일괄 Update시 Cache 갱신이 안되어 업데이트 대상들에 대한 Cache Evicition이 필요

    → 대량의 데이터의 일괄 Update가 필요한 경우

JPA에서 Bulk Insert를 자제

  • JPA의 merge나 persist는 Jdbc.Batch를 사용할 때 보다 성능 차이가 몇백~몇천배의 차이
  • 개선을 위해서는 JdbcTemplate으로 Bulk Insert를 사용하는 것이 좋지만, 문자열로 작성하기 때문에 컴파일 체크나 코드-테이블간의 불일치 체크와 같은 Type Safe한 개발이 어렵다.
profile
똑같은 짓은 하지 말자

0개의 댓글