[Querydsl] 꿀팁

배세훈·2022년 12월 22일
0

JPA

목록 보기
3/3

1. extends / implements 사용하지 않기

  • 일반적으로 필요한 Repository를 만들면 Spring Data Jpa 기능을 이용하기 위해 JpaRepository를 상속받고 또 Querydsl-Jpa를 위해 사용자 정의 Repository를 만들고 이를 상속받습니다. 그리고 이를 구현한 실제 구현 객체가 필요합니다.

아니면 QuerydslRepositorySupport 클래스를 통해 이를 사용자 정의 클래스에서 상속 받도록 하는 방법도 있습니다.

근데 이렇게 매번 상속받는 것이 불편하기도 하고 사실 JpaQueryFactory만 있다면 상관없기 때문에 상속 받는 구조보단 JpaQueryFactory만 주입받도록 하는게 가장 깔끔합니다.

Querydsl 사용 방법

// 기존 방법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;
}

//

동적쿼리는 BooleanExpression 사용하기

동적쿼리를 작성하는 방법에는 BooleanBuilder를 작성하는 방법과 Where 절과 파라미터로 Predicate를 이용하는 방법, Where절과 파라미터로 Predicate를 상속한 BooleanExpression을 사용하는 방법이 있습니다.

BooleanBuilder를 사용하는 방법은 쿼리를 한눈에 파악하기 힘듭니다.
그리고 Predicate 보다는 BooleanExpression을 사용하는 이유로는 BooleanExpression은 and와 or 같은 메소드들을 이용해서 BooleanExpression을 조합해서 새로운 BooleanExpression을 만들 수 있다는 재사용성이 높은 장점이 있습니다.
BooleanExpression은 null을 반환하게 되면 Where 절에서 조건이 무시되기 때문에 안전합니다.

BooleanBuilder를 이용하는 예제

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();
}

Where절과 BooleanExpression을 이용하는 예제

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;
}

3. exist 메소드 사용하지 않기

Querydsl 에서 exist는 사용하지 않는 것이 좋습니다.
왜냐하면 Querydsl 에 있는 exist는 count 쿼리를 사용하기 때문입니다.

SQL exist 쿼리의 경우에는 첫번째로 조건에 맞는 값을 찾는다면 바로 반환하도록 하지만 count 쿼리는 전체 행을 모두 조회하도록 해서 성능이 떨어집니다.
이 차이는 스캔 대상이 앞에 있을수록 성능 차이가 심해집니다.

즉, Querydsl 에서는 이 메소드를 사용하지 않고 우회하도록 해야하는데 이를 위해 fetchFirst()를 사용하면 됩니다.

fetchFirst() 의 내부 구현에는 limit(1)이 있어서 결과를 한개만 가져오도록 하기 때문에 SQL exist 문과 큰 차이가 없습니다.

Querydsl 내부 exists 구현 상태 - QuerydslJpaPredicateExecutor.class

public boolean exists(Predicate predicate){
	return this.createQuery(predicate).fetchCount() > 0L; // count로 조회
}

fetchFirst를 이용한 exist 구현하기

public Boolean exist(Long memberId){
	Integer fetchOne = queryFactory
    	.selectOne()
        .from(member)
        .wheret(member.id.eq(memberId))
        .fetchFirst();
        
    return fetchOne != null;
}

4. Cross Join 회피하기

묵시적 조인이라고 하는 조인을 명시하지 않고 엔터티에서 다른 엔터티를 조회해서 비교하는 경우 JPA가 알아서 크로스 조인을 하게 됩니다.
크로스 조인을 하게 되면 나올 수 있는 데이터가 그냥 조인들보다 많아지기 때문에 성능상에 단점이 있습니다.
그러므로 크로스 조인을 피하기 위해서는 쿼리를 보고 크로스 조인이 나간다면 명시적 조인을 이용해서 해결하도록 합니다.

Cross Join 발생 예제

  • where조건안의 member에서 team Entity를 lazy로 호출하는 부분이 있어 cross Join 발생
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

Cross Join을 InnerJoin으로 변경

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

5. 조회할땐 Entity 보다는 Dto를 우선적으로 가져오기

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 쿼리가 발생하는 문제입니다.

Dto 조회할 때 필요한 컬럼만 가져오기

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)

6. Group By 최적화하기

  • 일반적으로 MySQL 에서 Group By를 실행하면 GROUP BY column에 의한 Filesort라는 정렬 알고리즘이 추가적으로 실행됩니다(Index가 없는 경우)

MySQL 5.7 Reference

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.

  • 이 말은 Filesort가 발생하면 상대적으로 더 느려지므로 이 경우를 막으려면 order by null을 사용하면 된다는 말입니다.
    하지만 Querydsl 에서는 order by null을 지원하지 않습니다.

따라서 OrderByNull 클래스를 만들고 이를 통해서 OrderByNull을 지원하도록 합니다.

OrderByNull 클래스

public class OrderByNull extends OrderSptecifier{
	public static final OrderByNull DEFAULT = new OrderByNull();
    
    private OrderByNull(){
    	super(Order.ASC, NullExpression.DEFAULT, NullHandling.Default);
    }
}

Group By와 OrderByNull 사용 예제

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 자원보다는 애플리케이션 자원이 더 싸기 때문에 더 효율적입니다.

7. Querydsl에서 커버링 인덱스 사용하기

커버링 인덱스는 쿼리를 충족시키는데 필요한 모든 컬럼을 가지고 있는 인덱스입니다.

select / where / order by / group by 등에서 사용되는 모든 컬럼이 인덱스에 포함된 상태로 No Offset 방식과 더불어 페이징 조회 성능을 향상시키는 가장 보편적인 방법입니다.

인덱스를 이용해서 질의를 한다면 select 절을 비롯해 order by, where 등 쿼리 내 모든 항목이 인덱스 컬럼으로만 이루어지게 되서 인덱스 내부에서 커리가 완성되므로 DB 데이터 블록을 가지고오기 보다 DB 인덱스 페이지 i/o 만으로 이뤄지기 때문에 성능이 올라간다라고 이해하시면 됩니다.
즉, 인덱스 검색으로 빠르게 처리하고 걸러진 항목에 대해서만 데이터 블록에 접근하기 때문에 성능의 이점을 얻게 된다.

커버링 인덱스를 사용할 땐 from 절에 subQuery에서 커버링 인덱스를 통해 필터를 하도록 하는게 보편적인데 아쉽게도 Querydsl에서는 JPQL은 from 절에서 서브쿼리를 지원하지 않습니다.
이를 위한 우회하는 방법으로는 두번의 select 절을 이용하는 건데 방법은 다음과 같습니다.

  • 첫 번째 select 절로 커버링 인덱스를 활용해 조회 대상의 PK를 조회합니다.
  • 그 후 두 번째 select 절로 해당 PK로 필요한 컬럼 항목들을 조회합니다.

Querydsl 에서 커버링 인덱스를 사용한 예제

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();
}
  • 이 방식의 단점은 너무 많은 인덱스가 생길 수 있다는 점입니다. 결국 쿼리의 모든 항목이 인덱스로 필요하기 때문에 느린 쿼리가 발생할 때마다 새로운 신규 인덱스가 생성될 수 있습니다.
  • 인덱스의 크기도 점점 커질 수 있는데 인덱스도 결국 데이터이기 때문에 들어가는 항목이 점점 많아진다면 인덱스가 비대해진다는 단점이 있습니다.

8. 페이징 성능 개선을 위해 No Offsewet 사용하기

기존 페이징 방식인 offset과 limit를 이용한 방식은 서비스가 커짐에 따라서 장애를 유발할 수도 있습니다.
이유로는 초기에는 데이터가 적어서 문제가 없지만 데이터가 점점 많아지면 느려지기 때문인데 offset을 이용하면 offset + limit 만큼의 데이터를 읽어야 하기 때문입니다.

offset을 이용한 기존 페이징 쿼리

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를 조건문에 사용하는 방식을 이용합니다.

NoOffset 예제 코드

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;
}

9. 일괄 Update 최적화하기

JPA를 사용하면 영속성 컨텍스트가 Dirty Chtecking 기능을 지원해주는데 이를 이용하면 엄청나게 많은 양의 데이터에 대해서 업데이트 쿼리가 나갈수도 있습니다.

이 방식은 Querydsl 의 일괄 Update 하는 것보다 확실히 성능이 낮습니다.

JPA Dirty Checking을 이용한 예제

private void dirtyChecking(){
	List<Member> result = queryFactory
    	.selectFrom(member)
        .fetch();
        
    for(Member member : result){
    	member.setUserName(member.getUserName() + "+");
    }
}

Querydsl 일괄 업데이트를 이용한 예제

public void batchUpdate(){
	queryFactory
    	.update(member)
        .set(member.userName, member.userName + "+")
        .execute();
}

주의할 점은 일괄 업데이트는 영속성 컨텍스트의 1차 캐시 갱신이 안된다. 그러므로 Cache Eviction이 필요합니다.
그러므로 실시간 비즈니스 처리나 실시간 단건 처리가 필요하다면 Dirty Checking 기능을 이용하고 대량의 데이터 일괄 업데이트가 필요하면 위의 방식을 사용하는 것을 고려해보는게 좋을 것 같습니다.

profile
성장형 인간

0개의 댓글