[QueryDSL] 4. Spring Data JPA 와 QueryDSL

HJ·2024년 3월 13일
0

QueryDSL

목록 보기
4/4
post-thumbnail

김영한 님의 실전! Querydsl 강의를 보고 작성한 내용입니다.


1. 사용자 정의 리포지토리

Spring Data JPA 를 사용하면 findByUsername 과 같은 쿼리를 자동으로 생성해주지만, 검색 조건에 따른 동적 쿼리를 작성할 수 없습니다. 그래서 사용자 정의 인터페이스를 사용하여 QueryDSL 을 활용한 동적 쿼리를 작성합니다.

[ 1. 인터페이스 생성 ]

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

[ 2. 인터페이스 상속 ]

public interface MemberRepository extends JpaRepository<Member, Long>, 
                                          MemberRepositoryCustom {
    ...
}

JpaRepository 를 상속 받는 인터페이스가 새롭게 정의한 인터페이스를 상속 받도록 합니다. 이렇게 하면 생성한 동적 쿼리를 MemberRepository.search() 로 사용할 수 있습니다.


[ 3. 구현체 생성 ]

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    public MemberRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    @Override
    public List<MemberTeamDto> search(MemberSearchCond condition) {
        ...
    }
}

사용자 정의 인터페이스의 구현체를 만들 때 MemberRepositoryImpl 혹은 MemberRepositoryCustomImpl 둘 중 아무거나 선택해서 이름을 지정하면 됩니다.
( Spring Data JPA 강의 참고 )

QueryDSL 을 활용하기 때문에 생성자를 통해 EntityManager 를 주입받아 JpaQueryFactory 를 생성하고, 이를 이용해 동적 쿼리를 작성하면 됩니다. 동적 쿼리는 이전 시간에 작성한 것과 동일하게 작성하면 됩니다.




2. QueryDSL 과 페이징 연동

fetchResult() 를 사용하면 데이터를 가져오는 쿼리와 총 데이터 수를 가져오는 쿼리가 실행되는데 해당 기능은 deprecated 되었습니다.

그래서 QueryDSL 5.0 부터 페이징 처리를 할 때는 데이터를 조회하는 쿼리, 데이터 수를 가져오는 쿼리를 따로 작성해서 구현해야 합니다.

또 content 를 가져올 때는 조인이 필요하지만 count 를 가져올 때는 조인 여부에 관계 없이 데이터의 수가 동일한 경우가 있습니다. 이러한 이유로 count 쿼리를 따로 작성하는 것이 성능 상 이점을 가져갈 수 있습니다.

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
    ...
    @Override
    public Page<MemberTeamDto> searchPageComplex(MemberSearchCond condition, 
                                                 Pageable pageable) {
        // content 쿼리
        List<MemberTeamDto> content = queryFactory
                .select(new QMemberTeamDto(
                        member.id,
                        member.username,
                        member.age,
                        team.id,
                        team.name
                ))
                .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();

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

        return new PageImpl<>(content, pageable, total);
    }
}
  1. content 를 가져올 때는 fetch() 를 사용합니다.

  2. count 를 가져올 때는 select 에 count() 를 사용하며, fetchOne() 으로 실행합니다.




3. CountQuery 최적화

페이지의 시작이면서 content 사이즈가 page 사이즈보다 작은 경우, 혹은 마지막 페이지인 경우에는 count 쿼리를 생략할 수 있습니다.

Spring Data 가 제공하는 라이브러리를 사용하면 count 쿼리를 생략할 수 있는 경우, 자동으로 count 쿼리를 생략합니다.

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
    ...
    @Override
    public Page<MemberTeamDto> searchPageComplex(MemberSearchCond condition, 
                                                 Pageable pageable) {
        // content 쿼리 생략
        JPAQuery<Long> countQuery = queryFactory
                .select(member.count())
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()));

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

count 쿼리를 작성한 후에 fetchXXX 를 사용하지 않으면 실제 쿼리가 수행되지 않고 fetchXXX 를 호출해야 쿼리가 실행됩니다.

count 쿼리를 반환받고, 위처럼 반환하면 getPage() 에서 content 와 pageable 의 totalSize 를 보고 count 쿼리를 생략할 수 있다면 count 쿼리를 실행하지 않게 됩니다.




4. Spring Data JPA 가 제공하는 QueryDSL 기능

여기서 소개하는 기능은 제약이 커서 복잡한 실무 환경에서 사용하기에는 많이 부족하다고 합니다.

4-1. QuerydslPredicateExecutor

[ 인터페이스 ]

public interface QuerydslPredicateExecutor<T> {
	Optional<T> findOne(Predicate predicate);
	Iterable<T> findAll(Predicate predicate);
	Page<T> findAll(Predicate predicate, Pageable pageable);
	long count(Predicate predicate);
	boolean exists(Predicate predicate);
    ...
}

[ 테스트 코드 ]

@Test
void querydslPredicateExecutor() {
    QMember member = QMember.member;
    Iterable<Member> result = memberRepository.findAll(
                                    member.age.between(10, 40)
                                        .and(member.username.eq("member1")));
}

JpaRepository 를 상속 받는 인터페이스에서 해당 인터페이스를 상속 받으면 인터페이스가 제공하는 모든 기능을 사용할 수 있으며, 파라미터로 QueryDSL 조건을 넣을 수 있게 됩니다.

Pagable, Sort를 모두 지원하고 정상적으로 동작하지만 left join 이 불가능하며, 서비스 클래스가 QueryDSL 이라는 구현 기술에 의존해야 합니다.



4-2. QuerydslRepositorySupport

[ 생성 및 쿼리 작성 ]

public class MemberRepositoryCustomImpl extends QuerydslRepositorySupport 
                                        implements MemberRepositoryCustom {

    public MemberRepositoryCustomImpl() {
        super(Member.class);
    }

    @Override
    public List<MemberTeamDto> search(MemberSearchCond condition) {
        return from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .select(new QMemberTeamDto(
                        member.id,
                        member.username,
                        member.age,
                        team.id,
                        team.name
                ))
                .fetch();
    }
}

기존에는 EntityManager 를 주입 받고 JpaQueryFactory 를 생성했는데 해당 인터페이스가 엔티티 매니저를 주입 받기 때문에 super(Member.class) 만 하면 됩니다.

JpaQueryFactory 없이 from 으로 시작하도록 하고 마지막에 select 를 넣는 형식으로 쿼리를 작성할 수 있습니다.


[ 엔티티 매니저와 쿼리 팩토리 ]

public class MemberRepositoryCustomImpl extends QuerydslRepositorySupport {

    private final JPAQueryFactory queryFactory;

    // queryFactory 사용
    public MemberRepositoryCustomImpl(EntityManager em) {
        super(Member.class);
        this.queryFactory = new JPAQueryFactory(em);
    }

    // entityManager
    private final EntityManager entityManager = getEntityManager();
}

생성자에서 EntityManager 를 주입 받아 JpaQueryFactory 를 사용할 수 있으며, getEntityManager() 를 통해 엔티티 매니저를 사용할 수 있습니다.


[ 페이징 ]

@Override
public void searchPage(MemberSearchCond condition, Pageable pageable) {
    JPQLQuery<MemberTeamDto> query = 
            from(member)
            .leftJoin(member.team, team)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe()))
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name
            ));

    JPQLQuery<MemberTeamDto> pagingQuery = getQuerydsl()
                                            .applyPagination(pageable, query);
    List<MemberTeamDto> result = pagingQuery.fetch();
}

getQuerydsl().applyPagination() 을 사용하면 Spring Data 가 제공하는 페이징을 QueryDSL 로 편리하게 변환할 수 있으며, offset 과 limit 를 자동으로 넣어주게 됩니다.

이 메서드로 스프링 데이터가 제공하는 페이징을 Querydsl로 편리하게 변환할 수 있지만 Sort는 오류가 발생합니다.


[ 단점 ]

  1. Querydsl 3.x 버전을 대상으로 만들었기 때문에 Querydsl 4.x에 나온 JPAQueryFactory로 시작할 수 없습니다.

  2. Spring Data 가 제공하는 Sort 기능이 정상적으로 동작하지 않습니다.

profile
공부한 내용을 정리해서 기록하고 다시 보기 위한 공간

0개의 댓글