이 블로그는 해당 링크의 영상을 참고하여 제작하였습니다.
https://www.youtube.com/watch?v=zMAX7g6rO_Y&t=174s
1. BASIC
1-1. extends/implements 사용하지 않기
기본적으로 Query DSL을 사용하기 위해선 다음과 같은 클래스들이 필요하다.
Repository : JpaRepository, CustomRepository 상속
RepositoryImpl : Repository를 주입받아 구현
따라서 Query DSL을 사용하기 위해선 하나의 관련 DB 작업에 총 4개의 클래스/인터페이스가 필요하다.
문제 : 많은 클래스/인터페이스 생성 작업을 줄일 필요가 있다.
해결 : JPAQueryFactory 사용
- Config로 JPAQueryFactory 빈 생성
- 사용하고자 하는 클래스에 주입
- 사용하고자 하는 큐클래스 static import
- 이 후 사용
" 훨씬 간략해진 구조 "
1-2. 동적 쿼리는 BooleanExpression
- 보통은 BooleanBuilder 를 사용하였음
기본 구조
BooleanBuilder builder = new BooleanBuilder();
// 만약 name, address를 입력받은 메서드라면
if(!StringUtils.isEmpty(name)){
builder.and(qclass.name.eq(name));
}
if(!StringUtils.isEmpty(address)){
builder.and(qclass.name.eq(address));
}
// 이후 where에 사용
return queryFactory
.selectFrom(qclass)
.where(builder)
.fetch();
BooleanBuilder의 단점 : 어떤 쿼리가 예상하기 어렵다.
- 체크하고자하는 칼럼이 많아질수록 if문이 증가하고 관리하기 가독성이 안좋아짐.
해결 : BooleanExpression을 사용
기본 구조
.where(eqName(name), eqAddress(address))
private BooleanExpression eqName(String name){
if(StringUtils.isEmpty(name)){
return null;
}
return qclass.name.eq(name);
}
private BooleanExpression eqAddress(String address){
if(StringUtils.isEmpty(address)){
return null;
}
return qclass.name.eq(address);
}
메서드에서 null 반환시 where절에서 조건 자동으로 삭제
-> 명시적으로 쿼리를 이해하기 쉬움
-> 모두 null일 경우 where절이 날라가는 구조이기에 사용에 주의, 따라서 모두 Null일 경우 쿼리 실행안하는게 좋을 듯하다.
2. SELECT 관련
2-1. QueryDSL의 Exist 금지
SQL의 exist vs count(1)
exist : 조건에 맞는 데이터 발견시 종료
count : count=1이 되어도 끝까지 탐색
-> 데이터가 앞에있을수록 성능의 차이가 더 커짐.
문제 : QueryDSL에서 exists 메서드 사용시 count(1)을 사용
// Exist Method 내부 구조
.fetchCount()>0;
exist()로 변경도 불가능
-> JPQL(JPA Query)에서는 from 없이는 쿼리문을 생성 불가능
-> 아래와 같이 exist() 형태가 불가능
// 기존 Exist 구조
select exist(
select 1
from table
where 조건)
해결 : limit(1)로 조회 제한
-> .fetchFirst() 사용시 limit(1)이 생겨 발견시 바로 종료됨.
-> 결과가 없을 시 0이 아닌 null이므로 null체크 해야함.
// fetchFirst 사용시 생성되는 query
select 1
from table
where 조건
limit 1
// 메서드 시용
// fetchFirst == limit(1).fetchOne();
Integer result = queryFacotry
.selectOne()
.from()
.where()
.fetchFirst();
return result != null;
2-2. Cross Join 회피
크로스 조인 : 두 테이블의 모든 행을 연결 시키는 것, 예를 들어, 3개의 데이터를 갖는 A 테이블, 4개의 데이터를 갖는 B 테이블 존재시, 12개의 결과물 출력, 즉, 나올수 있는 모든 경우를 출력
묵시적 조인 : where절만을 이용해 연결시켜 조인을 하는 경우, 예를 들어, 두 테이블의 기본키, 외래키 연결만으로 조인을 하는 경우가 있다. where(a.id.gt(b.id))
명시적 조인 : left, right, full, inner 조인 등 조인을 명시하여 두 테이블을 연결하는 경우를 의미한다.
" 묵시적 조인을 한다면 Cross Join이 발생가능하다.(모든 데이터 출력) 이는 성능에 당연하게도 좋지 않다. "
-> 따라서 명시적 조인을 사용해서 조인을 처리해야 Cross Join을 회피할 수 있다.
2-3. Entity 보단 DTO를 우선시 하자.
Entity Select 문제점
- Hibernate 1차, 2차 캐시 문제
(1차 캐시 : DB연결 세션마다 생성되는 캐시, 2차 캐시 : 여러 세션간 공유되는 캐시, 외부 캐시 프로그램 필요)
- 불필요한 칼럼도 select 하게 됨.
- OneToOne N+1 문제 발생 (OneToOne은 Lazy Loading이 안되기에 발생하는 문제)
사용법
- Entity Select 는 실시간으로 Entity 변경이 필요한 경우에 사용
- Dto Select 는 고강도 성능 개선, 대량의 데이터 조회가 필요한 경우에 사용
DTO 사용
-> 이는 기본적으로 필드명으로 매핑을 진행하고 최소 한개 이상의 필드를 조회할때 사용하면 좋다.
1. 조회 컬럼 최소화 작업
- 이미 알고 있는 값들 같은 경우 select 문 사용없이 바로 대입
- as를 사용
// 기존
public List<BookDTO> getBooks(int bookNo, int PageNo)
return queryFacotry
.select(Projections.fields(BookDTO.class,
book.name
book.bookNo
book.id
))
.from(book)
.where(book.bookNo.eq(bookNo))
.offset(pageNo)
.limit(10)
.fetch();
// 변경
public List<BookDTO> getBooks(int bookNo, int PageNo)
return queryFacotry
.select(Projections.fields(BookDTO.class,
book.name
Expressions.asNumber(bookNo).as("bookNo")
book.id
))
.from(book)
.where(book.bookNo.eq(bookNo))
.offset(pageNo)
.limit(10)
.fetch();
(+) offset
- 주로 페이징에서 사용되며 건너 뛸 행의 개수를 의미, 이를 활용하여 offset(page * pageSize) 입력시, 해당 page에 데이터 전에는 건너뛰고 이 후 출력해주는 메서드
2. Select 칼럼에 Entity 자제
- 아래 예시를 보면 book과 store는 조인 관계이다.
- 조회시 store는 Entity로 조회가 된다.
- 문제점1 : 아래와 같이 작성시 store의 모든 칼럼이 조회된다.
- 문제점2 : 만약 store와 OneToOne 관계인 다른 추가적인 테이블이 존재한다면 그 테이블까지 조회됨.(OneToOne은 Lazy처리가 안되기 때문에, N+1 문제가 무조건 발생) -> 한번의 쿼리로 수많은 쿼리가 발생하게 됨
- 문제점3 : distinct 사용시 distinct를 적용대상이 Join 된 테이블까지로 설정되기에 이를 위한 임시 테이블 생성 등의 불필요한 시간이 소요된다.
- "필요한 데이터만을 명시해 가져오고 이를 활용해 조인관계의 데이터를 저장하고 위의 문제를 해결하자"
// 기존
public List<BookDTO> getBooks(int bookNo, int PageNo)
return queryFacotry
.select(Projections.fields(BookDTO.class,
book.name
book.bookNo
book.store
))
.from(book)
.where(book.bookNo.eq(bookNo))
.offset(pageNo)
.limit(10)
.fetch();
// 변경
public List<BookDTO> getBooks(int bookNo, int PageNo)
return queryFacotry
.select(Projections.fields(BookDTO.class,
book.name
book.bookNo
book.store.id.as("storeId")
))
.from(book)
.where(book.bookNo.eq(bookNo))
.offset(pageNo)
.limit(10)
.fetch();
// 추가적으로 위의 데이터를 활용해 특정 데이터 저장 시
// Builder로 해당 book DTO를 엔터티로 변경 후 저장시 위에서 받아온 아이디로 객체 생성해 저장
2-4. Group By 최적화
- Group By : 특정 열을 기준으로 그룹화하여 데이터 요약 및 분석, 집계함수(COUNT 등) 사용에 편리를 제공한다.
예를 들어, '천안에 사는 사람들의 소득 평균'과 같은 데이터를 수집할 때, 거주지가 천안인 사람들을 그룹짓고 집계함수를 사용하여 이를 구한다.
일반적으로 Mysql에서 Group By를 사용하면 모든 칼럼이 인덱스를 사용하는 경우를 제외하고 filesort를 사용하게 된다.
이 때, group by + order by null 사용 시 filesort가 제거된다.
하지만 Query DSL에서는 order by null을 지원하지 않는다. -> Class를 구현해 사용
추가적으로 페이징일 경우 order by null을 사용하지 못하기에 위의 방법은 페이징이 아닐경우에만 사용가능하다.
- filesort :
대량의 데이터를 정렬할 때 발생하며, 정렬은 디스크 I/O 작업과 CPU 작업을 요하기 때문에 비용이 크며, 디스크 공간을 사용하여 정렬하기 때문에 성능에 영향을 줄 수 있는 작업이다.
일반적으로 ORDER BY 절을 사용하거나 GROUP BY절 사용시 사용된다.
사용되는 쿼리가 인덱스를 사용한다면 발생하지 않는다.
- order by null :
정렬을 사용하지 않고 원본을 유지하거나 특정 정렬기준 제시, 예를 들어 order by null 사용시 정렬 없이 사용을 의미하고 order by null asc 설정시 기존의 정렬 방식이 아닌 오름차순으로 정렬을 사용함을 의미한다.
구현 Class
public class OrderByNull extends OrderSpeicifier{
public static final OrderByNull DEFAULT = new OrderByNull();
private OrderByNull(){
super(Order.ASC, NullExpresion.DEFAULT, Default);
}
}
.groupBy(열)
.orderBy(OrderByNull.DEFAULT)
.fetch();
" 정렬이 필요하더라도 조회 수가 100건 이하라면 APP 내(WAS)에서 정렬하는게 효율적"
2-5. 커버링 인덱스
커버링 인덱스
- 쿼리를 충족 시키는데 필요한 모든 컬럼이 인덱스 처리된 경우
- select/where/order by/group by 등에서 사용하는 모든 칼럼이 인덱스에 포함된 상태(인덱스 처리가 된 상태)
- NoOffset 방식과 더불어 페이징 조회 성능을 향상시키는 방법
장점
- 디스크 I/O 감소 및 쿼리 성능 개선 : 데이터를 디스크에서 가져오지 않고 인덱스에서만 가져오기 때문에 디스크 작업이 줄어들고 디스크에서 데이터를 읽어 오는 시간 또한 줄어들어 성능 향상
- 인덱스 크기 감소 : 커버링 인덱스는 실제 데이터를 포함하지 않고, 필요한 칼럼만을 인덱스로 저장하기 때문에 인덱스 크기가 작아진다.
예시 코드
select *
from academy a
join(select id
from academy
order by id
limit 1000, 10) as temp
on temp.id = a.id;
-> 하지만 JPQL에서는 from절의 서브 쿼리를 지원하지 않는다.
해결
- 커버링 인덱스 조회는 나눠서 진행
- Cluster Key(PK)를 커버링 인덱스로 빠르게 조회하고, 조회된 Key를 활용한 where절을 통해 Select 컬럼들을 후속 조회
- 두번의 쿼리문을 발생시키지만 성능에 있어서 효과적
List<Long> ids = queryFactory
.select(book.id)
.from(book)
.where(book.name.like(name + "%"))
.orderBy(book.id.desc())
.limit(pageSize)
.offset(pageNo * pageSize)
.fetch();
return queryFactory
.select(Projections.fields(BookPaginationDTO.class,
book.id.as("bookId"),
book.name,
book.bookNo,
book.bookType))
.from(book)
.where(book.id.in(ids))
.orderBy(book.id.desc())
.fetch();
(+) as : 별명 붙이기
3. UPDATE/INSERT 관련
3-1. 일관 UPDATE 최적화
Dirty Checking : 해당 Transaction 안에서 특정 Entity를 조회해서 수정하고 종료시 DB에 반영되므로 이를 통해 데이터를 수정하는 방식을 의미한다.
-> 처리해야하는 데이터양 늘어날 시 성능사 문제 발생
-> 일괄 업데이트로 처리하자.
코드
// 기존, Dirty Checking
List<Student> students = queryFactory
.selectFrom(student)
.where(student.id.loe(studentId))
.fetch();
for(Student student : students){
student.setName(name);
}
// 개선, 일괄 업데이트
queryFactory
.update(student)
.where(student.id.loe(studentId))
.set(student.name, name)
.execute();
일괄 업데이트의 단점
- 하이버네이트의 캐시는 일괄 업데이트시 캐시 갱신이 안됨. 이럴 경우에 업데이트 대상들에 대한 Cache Eviction이 필요
정리
- Dirty Checking : 실시간 비지니스 처리, 실시간 단건 처리시 사용
- Querydsl.update : 대량의 데이터를 일괄로 update시 사용한다.
최종 정리
- 진짜 Entity가 필요한게 아니라면 Querydsl과 DTO를 통해 딱 필요한 항목들만 조회하고 update하자
- 상황에 따라 ORM / 정통적 Query 방식을 골라서 사용할 것
- JPA / Querydsl로 발생하는 쿼리 한번 더 확인하자.
Querydsl 분류
- Querydsl-JPA : JPQL
- Querydsl-SQL : Native SQL
- Querydsl-MongoDB : Mongo Query
- Querydsl-ElasticSearch : ES Query
- Querydsl-JPA 대신 Querydsl-SQL을 활용해 Bulk Insert(Insert 합치기) 진행시 좀 더 효과적, QClass 만드는데는 table 스캔이 아닌 다른 방식 요구
'Review' 카테고리의 다른 글
REVIEW - 박정국 CTO가 알려주는 ‘서버 성능 측정 방법’ (0) | 2023.06.05 |
---|