Back-End/QueryDsl

QueryDsl - 핵심만 정리

Meluu_ 2024. 8. 14. 11:20

QueryDsl은 객체 지향적 쿼리 작성 프레임워크이기에 매우 편리하고,

컴파일 시점에서 오류를 잡아준다. 

 

또한 가독성이 매우 좋고, 동적 쿼리 작성도 매우 좋다. 

 

✔️ JPAQueryFactory


QueryDsl이 제공하는 JPQL 툴이다.

이를 통하여 쿼리를 작성한다. 

 

사용할 때마다 생성하는 것보다 빈에 등록하여 사용하는 것이 좋다.

 

Config 클래스를 만들어서 빈으로 등록하고 의존관계를 주입받아 사용하자.

@Configuration
@RequiredArgsConstructor
public class QuerydslConfiguration {

    private final EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(em);
    }
}

 

 

 

✔️ DTO 조회


이전에 스프링 데이터 JPA나 JPA에서 DTO 생성자를 통한 직접 조회를 했을 때 

패키지명을 다 적어야해서 매우 불편했다. 

QueryDsl은 @QueryProjection 어노테이션을 제공하여 DTO 클래스도 Q타입의 클래스를 생성해준다. 

이로써 패키지명을 적을 필요없이 간단하게 Q타입으로 생성자를 지정하면 된다.

@Data
public class MemberDto {
    private String username;
    private int age;

    public MemberDto() {
    }

    @QueryProjection // DTO도 Q타입의 클래스로 생성하여 사용
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

 

 

@Repository
@RequiredArgsConstructor
public class MemberRepository {

    private final JPAQueryFactory queryFactory;

	public List<MemberDto> findAllAsDto() {
        return queryFactory
                .select(new QMemberDto(
                        member.username,
                        member.age
                ))
                .from(member)
                .fetch();
    }
}



✔️ 동적 쿼리 


Querydsl에서 동적쿼리는 

1. BooleanBuilder 사용

2. where 다중 파라미터 사용

이 있다. 

 

1. BooleanBuilder 사용

@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {
    
    private final JPAQueryFactory queryFactory;
    
	public List<MemberTeamDto> searchByBuilder(MemberSearchCondition condition) {
        BooleanBuilder builder = new BooleanBuilder();

        // null 과 "" 을 체크
        if (hasText(condition.getUsername())) {
            builder.and(member.username.eq(condition.getUsername()));
        }

        if (hasText(condition.getTeamName())) {
            builder.and(team.name.eq(condition.getTeamName()));
        }

        if (condition.getAgeGoe() != null) {
            builder.and(member.age.goe(condition.getAgeGoe()));
        }

        if (condition.getAgeLoe() != null) {
            builder.and(member.age.loe(condition.getAgeLoe()));
        }

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

    }
}

1. builder를 생성

2. 동적 조건을 if문으로 처리

3. 조건이 있다면 builder에 and로 조건을 추가

 

 

2. where 다중 파라미터 사용

    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)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageBetWeen(condition.getAgeLoe(), condition.getAgeGoe()))
                .fetch();
    }
    // 조합
    private BooleanExpression ageBetWeen(Integer ageLoe, Integer ageGoe) {
        return ageLoe(ageLoe).and(ageGoe(ageGoe));
    }

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

BooleanExpression은 Predicate 타입을 구현했다.

조건을 조합할 수도 있고, 재사용성도 매우 높으며 null을 반환하면 where절에서 무시한다.

 

강추!

 

참고 
hasText : StringUtils 클래스의 메소드

 

 

✔️ 벌크 연산


벌크 update - set를 지원 

단, 영속성 컨텍스트를 무시하고 DB에 다이렉트로 update하니 

1차캐시에는 반영되지 않음

 

따라서 기존 영속성 컨텍스트에 있는 엔티티가 업데이트 된 것을 보려면 

영속성 컨텍스트 초기화하고 다시 조회해야함.

 

queryFactory
        .update(member)
        .set(member.username, "비회원")
        .where(member.age.lt(20))
        .execute();

 

참고로 delete도 지원한다.

 

 

ps. SQLFunction, function()도 지원한다. 자세한건 필요할때 찾아서 사용하자.

 

✔️ Custom으로 스프링 데이터 JPA와 결합


 

QueryDsl과 스프링 데이터 JPA를 결합하여 하나로 사용해보자.

 

public interface MemberRepositoryCustom {
    List<MemberTeamDto> search(MemberSearchCondition condition);
}

이를 구현한 클래스 

@Repository
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {

    private final JPAQueryFactory queryFactory;

	// querydsl 로 작성한 쿼리메서드들..
}
  • 규칙 : 리포지토리 인터퍼이스 이름 || 사용자 정의 인터페이스 이름 + Impl
  • 스프링 데이터 JPA가 인식해서 스프링 빈으로 등록

 

// 이 인터페이스 이름이 리포지토리 이름이다.
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
}

스프링 데이터 JPA에서 Custom을 상속하면 된다. 

 

 

✔️ Page 기능


page 기능도 가능하다. 

PageImpl 을 생성하여 만들면 된다.

 

    public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
        List<MemberTeamDto> content = 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(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        Long total = queryFactory
                .select(member.count())
                .from(member)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                ).fetchOne();


        return new PageImpl<>(content, pageable, total);


    }

 

생성자의 파라미터는

1. content : 말 그대로 페이징할 데이터 리스트

2. pageable : 페이지 정보

3. total : 전체 데이터의 개수

 

 

PageableExcutionUtils를 사용하면 마지막 페이지는 count쿼리를 날리지 않는다. 

// totalCount가 페이지 사이즈 보다 적거나, 마지막 페이지 일 경우 Count쿼리를 실행하지 않는다
JPAQuery<Long> countQuery = queryFactory
        .select(member.count())
        .from(member)
        .where(
                usernameEq(condition.getUsername()),
                teamNameEq(condition.getTeamName()),
                ageGoe(condition.getAgeGoe()),
                ageLoe(condition.getAgeLoe())
        );

return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);

 

기존 page 쿼리에서 이렇게만 바꾸면 된다. 

 

 

✔️ NoOffset 사용


사실 페이징 시 문제가 있다면 그것은 DB에서 1~1000000까지를 페이징한다 했을 때 

처음 1~20까지 조회하는 것은 빠르다. 하지만

50만~50만20 조회부터는 느려진다. 

그 이유는 DB에서 offset을 적용할 때 50만20 까지 읽은 다음 뒤에 50만을 버리고 20을 반환한다.

이때문에 성능적으로 매우 비효율적임을 알 수 있다. 

 

따라서 offset을 사용하지 않고 페이징한 마지막 조회 데이터의 pk값을 받아서 이를 조건식에 넣어 조회 성능을 높인다. 

기본적으로 pk는 클러스터링 인덱스 이기에 매우 빠르게 조회가 가능하다. 

  • 클러스터링 인덱스는 데이터가 실제(물리적)로 저장되는 순서인덱스 (논리적) 의 순서가 일치하는 인덱스입니다.

여기서는 내림차순으로 최신데이터를 먼저 보여주기에 Lt(less than)을 사용한다.

member.id  < 마지막 데이터 pk
@Repository
@RequiredArgsConstructor
public class MemberRepository {

    private final JPAQueryFactory queryFactory;

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

}

 

여기서는 간단하게 했지만 

Slice를 구현하여 무한스크롤을 구현할 수 있다. 

 

 

 

 

 

🔖 학습내용 출처


실전! Querydsl / 김영한

김영한. (2021). 자바 ORM 표준 JPA 프로그래밍. 에이콘출판사