아니면 QuerydslRepositorySupport 클래스를 통해 이를 사용자 정의 클래스에서 상속 받도록 하는 방법도 있습니다.
근데 이렇게 매번 상속받는 것이 불편하기도 하고 사실 JpaQueryFactory만 있다면 상관없기 때문에 상속 받는 구조보단 JpaQueryFactory만 주입받도록 하는게 가장 깔끔합니다.
// 기존 방법1 - JpaRepository 상속, Custom Interface 상속, Interface를 구현한 클래스 적용
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom{}
// 기존 방법2
public class MemberRepositoryCustom extends QuerydslRepositorySupport{}
// 추천 방법
@Configuration
@RequiredArgsConstructor
public class QuerydslConfiguration{
private fianl EntityManager em;
@Bean
public JPAQueryFactory jpaQueryFactory(){
return new JPAQueryFactory(em);
}
}
@Repository
@RequiredArgsConstructor
public class MemberRepositoryCustom{
private final JpaQueryFactory queryFactory;
}
//
동적쿼리를 작성하는 방법에는 BooleanBuilder를 작성하는 방법과 Where 절과 파라미터로 Predicate를 이용하는 방법, Where절과 파라미터로 Predicate를 상속한 BooleanExpression을 사용하는 방법이 있습니다.
BooleanBuilder를 사용하는 방법은 쿼리를 한눈에 파악하기 힘듭니다.
그리고 Predicate 보다는 BooleanExpression을 사용하는 이유로는 BooleanExpression은 and와 or 같은 메소드들을 이용해서 BooleanExpression을 조합해서 새로운 BooleanExpression을 만들 수 있다는 재사용성이 높은 장점이 있습니다.
BooleanExpression은 null을 반환하게 되면 Where 절에서 조건이 무시되기 때문에 안전합니다.
public List<MemberTeamDto> searchByBuilder(MemberSearchCondition condition){
BooleanBuilder builder = new BooleanBuilder();
if (hasText(condition.getUsername())) {
builder.and(member.username.eq(condition.getUsername()));
}
if(hasText(condition.getTeamName())){
builder.and(team.name.eq(condition.getTeamName()));
}
return queryFactory
.select(new QMemberTeamDto(
member.id.as("memberId"),
member.username,
member.age,
team.id.as("teamId"),
team.name.as("teamName")
))
.from(member)
.leftJoin(member.team, team)
.where(builder)
.fetch();
}
public List<MemberTeamDto> searchByWhere(MemberSearchCondition condition){
return queryFactory
.select(new QMemberTeamDto(
member.id.as("memberId"),
member.username,
member.age,
team.id.as("teamId"),
team.name.as("teamName")
))
.from(member)
.leftJoin(member.team, team)
.whyere(
userNameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
)
.fetch();
}
private BooleanExpression userNameEq(String username){
return hasText(username) ? member.username.eq(username) : null;
}
private BooleanExpression teamNameEq(String teamName){
return hasText(teamName) ? team.name.eq(teamName) : null;
}
private BooleanExpression ageGoe(Integer ageGoe){
return ageGoe != null ? member.age.goe(ageGoe) : null;
}
private BooleanExpression ageLoe(Integer ageLoe){
return ageLoe != null ? member.age.loe(ageLoe) : null;
}
Querydsl 에서 exist는 사용하지 않는 것이 좋습니다.
왜냐하면 Querydsl 에 있는 exist는 count 쿼리를 사용하기 때문입니다.
SQL exist 쿼리의 경우에는 첫번째로 조건에 맞는 값을 찾는다면 바로 반환하도록 하지만 count 쿼리는 전체 행을 모두 조회하도록 해서 성능이 떨어집니다.
이 차이는 스캔 대상이 앞에 있을수록 성능 차이가 심해집니다.
즉, Querydsl 에서는 이 메소드를 사용하지 않고 우회하도록 해야하는데 이를 위해 fetchFirst()를 사용하면 됩니다.
fetchFirst() 의 내부 구현에는 limit(1)이 있어서 결과를 한개만 가져오도록 하기 때문에 SQL exist 문과 큰 차이가 없습니다.
public boolean exists(Predicate predicate){
return this.createQuery(predicate).fetchCount() > 0L; // count로 조회
}
public Boolean exist(Long memberId){
Integer fetchOne = queryFactory
.selectOne()
.from(member)
.wheret(member.id.eq(memberId))
.fetchFirst();
return fetchOne != null;
}
묵시적 조인이라고 하는 조인을 명시하지 않고 엔터티에서 다른 엔터티를 조회해서 비교하는 경우 JPA가 알아서 크로스 조인을 하게 됩니다.
크로스 조인을 하게 되면 나올 수 있는 데이터가 그냥 조인들보다 많아지기 때문에 성능상에 단점이 있습니다.
그러므로 크로스 조인을 피하기 위해서는 쿼리를 보고 크로스 조인이 나간다면 명시적 조인을 이용해서 해결하도록 합니다.
public List<Member> crossJoin(){
return queryFactory
.selectFrom(member)
.where(member.team.id.gt(member.team.leader.id))
.fetch();
}
-> 위의 코드로 인하여 생성된 Query
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_ cross
join
team team1_
where
member0_.team_id=team1_.team_id
and member0_.team_id>team1_.member_id
public List<Member> crossJoinToInnerJoin(){
return queryFactory
.selectFrom(member)
.innerJoin(member.team, team)
.where(member.team.id.gt(member.team.leader.id))
.fetch();
}
-> 위의 코드로 인하여 생성된 Query
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_
inner join
team team1_
on member0_.team_id=team1_.team_id
where
member0_.team_id>team1_.member_id
Entity를 가지고 오면 영속성 컨택스트의 1차 캐시 기능을 사용하게 되기도 하고 불필요한 컬럼을 조회하기도 합니다.
그리고 OneToOne 관계에서는 N+1 문제가 생기기도 합니다.(참조: https://yongkyu-jang.medium.com/jpa-%EB%8F%84%EC%9E%85-onetoone-%EA%B4%80%EA%B3%84%EC%97%90%EC%84%9C%EC%9D%98-lazyloading-%EC%9D%B4%EC%8A%88-1-6d19edf5f4d3)
OneToOne N+1 문제는 외래 키를 가지고 있는 주인 테이블에서는 지연로딩이 제대로 동작하지만 mappedBy로 연결된 반대편 테이블에서는 지연로딩이 동작하지 않고 N+1 쿼리가 발생하는 문제입니다.
public List<MemberTeamDto> findSameTeamMember(Long teamId){
return querytFactory
.select(new QMemberTeamDto(
member.id,
member.username,
member.age,
Expressions.asNumber(teamId),
team.name
))
.from(member)
.innerJoin(member.team, team)
.where(member.team.id.eq(teamId))
.fetch();
}
SELECT 절에 보면 필요한 컬럼만 데이터베이스에서 가져오도록 하는데 teamId 같은 경우는 이미 기존에 매개변수로 받아 데이터베이스에서 가져올 필요가 없습니다. 이처럼 중복된 컬럼을 가져오지 않도록 해서 성능상 약간의 이득을 더 볼 수 있습니다.
이 예제에서는 Projection을 할 때 Dto 클래스 생성자에 @QueryProjection을 해서 Dto도 Q타입의 클래스를 만들도록 하였습니다.(참조: https://velog.io/@bey1548/Querydsl-DTO-%EC%A1%B0%ED%9A%8C-%EB%B0%A9%EB%B2%95)
If you use GROUP BY, output rows are sorted according to the GROUP BY columns as if you had an ORDER BY for the same columns. To avoid the overhead of sorting that GROUP BY produces, add ORDER BY NULL:
Relying on implicit GROUP BY sorting (that is, sorting in the absence of ASC or DESC designators) or explicit sorting for GROUP BY (that is, by using explicit ASC or DESC designators for GROUP BY columns) is deprecated. To produce a given sort order, provide an ORDER BY clause.
따라서 OrderByNull 클래스를 만들고 이를 통해서 OrderByNull을 지원하도록 합니다.
public class OrderByNull extends OrderSptecifier{
public static final OrderByNull DEFAULT = new OrderByNull();
private OrderByNull(){
super(Order.ASC, NullExpression.DEFAULT, NullHandling.Default);
}
}
public List<Integer> useOrderByNull(){
return queryFactory
.select(member.age.sum())
.from(member)
.innerJoin(member.team, team)
.groupBy(member.team)
.orderBy(OrderByNull.DEFAULT)
.fetch();
}
-> 위의 코드로 인하여 생성된 Query
select
sum(member0_.age) as col_0_0_
from
member member0_
inner join
team team1_
on member0_.team_id=team1_.team_id
group by
member0_.team_id
order by
null asc
주의할 점은 OrderByNULL 은 페이징 쿼리인 경우에는 사용하지 못합니다.
추가적인 정보로 정렬의 경우 100건 이하라면 애플리케이션 메모리로 가져와서 정렬하는 것을 추천합니다. 왜냐하면 일반적으로 DB 자원보다는 애플리케이션 자원이 더 싸기 때문에 더 효율적입니다.
커버링 인덱스는 쿼리를 충족시키는데 필요한 모든 컬럼을 가지고 있는 인덱스입니다.
select / where / order by / group by 등에서 사용되는 모든 컬럼이 인덱스에 포함된 상태로 No Offset 방식과 더불어 페이징 조회 성능을 향상시키는 가장 보편적인 방법입니다.
인덱스를 이용해서 질의를 한다면 select 절을 비롯해 order by, where 등 쿼리 내 모든 항목이 인덱스 컬럼으로만 이루어지게 되서 인덱스 내부에서 커리가 완성되므로 DB 데이터 블록을 가지고오기 보다 DB 인덱스 페이지 i/o 만으로 이뤄지기 때문에 성능이 올라간다라고 이해하시면 됩니다.
즉, 인덱스 검색으로 빠르게 처리하고 걸러진 항목에 대해서만 데이터 블록에 접근하기 때문에 성능의 이점을 얻게 된다.
커버링 인덱스를 사용할 땐 from 절에 subQuery에서 커버링 인덱스를 통해 필터를 하도록 하는게 보편적인데 아쉽게도 Querydsl에서는 JPQL은 from 절에서 서브쿼리를 지원하지 않습니다.
이를 위한 우회하는 방법으로는 두번의 select 절을 이용하는 건데 방법은 다음과 같습니다.
public List<MemberDto> useCoveringIndex(int offset, int limit){
List<Long> ids = queryFactory
.select(member.id)
.from(member)
.where(member.username.like("member%"))
.orderBy(member.id.desc())
.limit(limit)
.offset(offset)
.fetch();
if(ids.isEmpty()){
return new ArrayList<>();
}
return queryFactory
.select(new QMemberDto(
mwember.username,
member.age
))
.from(member)
.where(member.id.in(ids))
.orderBy(member.id.desc())
.fetch();
}
기존 페이징 방식인 offset과 limit를 이용한 방식은 서비스가 커짐에 따라서 장애를 유발할 수도 있습니다.
이유로는 초기에는 데이터가 적어서 문제가 없지만 데이터가 점점 많아지면 느려지기 때문인데 offset을 이용하면 offset + limit 만큼의 데이터를 읽어야 하기 때문입니다.
select *
from items
where 조건문
offset 페이지 번호
limit 페이지 사이즈
이와 같은 형태는 페이지 번호가 뒤로 갈수록 앞에서 읽었던 행을 다시 읽어야 합니다.
이 말은 offset이 10000이고 limit가 20이라면 10,020 행을 읽어야 한다는 것이고 그러고 나서 10,000 개의 행을 버립니다.
그렇기 때문에 성능상 안좋다는 점인데 No Offset 방식은 시작 지점을 인덱스로 빠르게 찾아 첫 페이지부터 읽도록 하는 방식입니다.
No Offset 을 이용하는 SQL 문은 다음과 같습니다.
select *
from items
where 조건문
and id < 마지막 조회 ID
order by id desc
limit 페이지 사이즈
이전에 조회된 결과를 한번에 건너 뛸 수 있게 마지막 조회 결과의 ID를 조건문에 사용하는 방식을 이용합니다.
public List<MemberDto> nooffset(Long lastMemberId, int limit){
return queryFactory
.select(new QMemberDto(
member.username,
member.age
))
.from(member)
.where(member.username.contains("member")
.and(memberIdLt(lastMemberId)))
.orderBy(member.id.desc())
.limit(limit)
.fetch();
}
private BooleanExpression memberIdLt(Long lastMemberId){
return lastMemberId != null ? member.id.lt(lastMemberId): null;
}
JPA를 사용하면 영속성 컨텍스트가 Dirty Chtecking 기능을 지원해주는데 이를 이용하면 엄청나게 많은 양의 데이터에 대해서 업데이트 쿼리가 나갈수도 있습니다.
이 방식은 Querydsl 의 일괄 Update 하는 것보다 확실히 성능이 낮습니다.
private void dirtyChecking(){
List<Member> result = queryFactory
.selectFrom(member)
.fetch();
for(Member member : result){
member.setUserName(member.getUserName() + "+");
}
}
public void batchUpdate(){
queryFactory
.update(member)
.set(member.userName, member.userName + "+")
.execute();
}
주의할 점은 일괄 업데이트는 영속성 컨텍스트의 1차 캐시 갱신이 안된다. 그러므로 Cache Eviction이 필요합니다.
그러므로 실시간 비즈니스 처리나 실시간 단건 처리가 필요하다면 Dirty Checking 기능을 이용하고 대량의 데이터 일괄 업데이트가 필요하면 위의 방식을 사용하는 것을 고려해보는게 좋을 것 같습니다.