[JPA]/실전! Querydsl

[Querydsl] 스프링 데이터 JPA 와 Querydsl

쿠릉쿠릉 쾅쾅 2022. 4. 28. 22:56
728x90

 

 

 

스프링 데이터 JPA 리포지토리로 변경

기존에 작성했던 MemberJpaRepository를 스프링 데이터 JPA를 이용하여 MemberRepository를 생성해보자.

📌 MemberRepository.interface

package study.querydsl.repository;

public interface MemberRepository extends JpaRepository<Member, Long> {

    List<Member> findByUsername(String username);
}

동적 쿼리를 제외한 정적쿼리는 스프링 JPA가 알아서 생성해준다.
동적쿼리는 사용자 정의 리포지토리가 필요하다.

📌 MemberRepositoryTest

package study.querydsl.repository;

@SpringBootTest
@Transactional
public class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Autowired
    EntityManager em;

    @Test
    void basicTest() {
        Member member = new Member("member1", 10);
        memberRepository.save(member);

        Member findMember = memberRepository.findById(member.getId()).orElse(null);
        assertThat(member).isEqualTo(findMember);

        List<Member> result1 = memberRepository.findAll();
        assertThat(result1).containsExactly(member);

        List<Member> result2 = memberRepository.findByUsername("member1");
        assertThat(result2).containsExactly(member);

    }
}

스프링 데이터 JPA를 이용한 정적 쿼리 테스트

 


 

사용자 정의 리포지토리

복잡한 쿼리 같은 경우에는 사용자 정의 리포지토리를 통해서 구현해야한다.

사용자 리포지토리 사용법
- 사용자 정의 인터페이스 작성
- 사용자 정의 인터페이스 구현
- 스프링 데이터 리포지토리에 사용자 정의 인터페이스를 상속

 

🔍 사용자 정의 인터페이스 작성

📌 MemberRepositoryCustom.interface

package study.querydsl.repository;


import study.querydsl.dto.MemberSearchCondition;
import study.querydsl.dto.MemberTeamDto;

import java.util.List;

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

}

🔍 사용자 정의 인터페이스 구현

📌 MemberRepositoryCustomImpl

package study.querydsl.repository;

@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom{

    private final JPAQueryFactory queryFactory;

    @Override
    public List<MemberTeamDto> search(MemberSearchCondition condition) {
        return 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()),
                        ageGoeEq(condition.getAgeGoe()),
                        ageLoeEq(condition.getAgeLoe())
                )
                .fetch();
    }

    private BooleanExpression usernameEq(String username) {
        return StringUtils.hasText(username) ? member.username.eq(username) : null;
    }

    private BooleanExpression teamNameEq(String teamName) {
        return StringUtils.hasText(teamName) ? team.name.eq(teamName) : null;
    }

    private BooleanExpression ageGoeEq(Integer ageGoe) {
        return ageGoe != null ? member.age.goe(ageGoe) : null;
    }

    private BooleanExpression ageLoeEq(Integer ageLoe) {
        return ageLoe != null ? member.age.loe(ageLoe) : null;
    }
}

여기서 사용자 정의 인터페이스를 구현할 클래스 명칭이 중요하다.
[사용자_정의_인터페이스명 + Impl] 이렇게 작성해야 스프링 데이터 JPA에서 해당 클래스가 사용자 정의 인터페이스 구현 클래스라는 것을 자동으로 인식할 수 있다.
또는, [리포지토리명 + Imple] 이렇게 작성할 수 있으나 위 방법이 더 직관적이다.

🔍 스프링 데이터 리포지토리에 사용자 정의 인터페이스 상속

📌 MemberRepository.interface

package study.querydsl.repository;

import java.util.List;

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
    List<Member> findByUsername(String username);
}

사용자 정의 인터페이스인 MemberRepositoryCustom을 상속받아야 한다.

🔍 커스텀 리포지토리 동작 테스트

📌 MemberRepositoryTest

package study.querydsl.repository;

@SpringBootTest
@Transactional
public class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Autowired
    EntityManager em;

    /**
     * 검색 조건
     *  - 나이 5 이상
     *  - 나이 40 이하
     *  - 팀명이 'teamB'인 경우
     */
    @Test
    void searchTest() {
        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");
        em.persist(teamA);
        em.persist(teamB);

        Member member1 = new Member("member1", 10, teamA);
        Member member2 = new Member("member2", 20, teamA);

        Member member3 = new Member("member3", 30, teamB);
        Member member4 = new Member("member4", 40, teamB);

        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);

        em.flush();
        em.clear();

        MemberSearchCondition condition = new MemberSearchCondition();
        condition.setAgeGoe(35);
        condition.setAgeLoe(40);
        condition.setTeamName("teamB");

        List<MemberTeamDto> result = memberRepository.search(condition);

        assertThat(result).extracting("username").containsExactly("member4");
    }
}
    /* select
        member1.id,
        member1.username,
        member1.age,
        team.id,
        team.name 
    from
        Member member1   
    left join
        member1.team as team 
    where
        team.name = ?1 
        and member1.age >= ?2 
        and member1.age <= ?3 */ select
            member0_.member_id as col_0_0_,
            member0_.username as col_1_0_,
            member0_.age as col_2_0_,
            team1_.team_id as col_3_0_,
            team1_.name as col_4_0_ 
        from
            member member0_ 
        left outer join
            team team1_ 
                on member0_.team_id=team1_.team_id 
        where
            team1_.name=? 
            and member0_.age>=? 
            and member0_.age<=?

 


 

스프링 데이터 페이징 활용 - Querydsl 페이징 연동

스프링 데이터의 Page와 Pageable을 활용해보자.

🔍 사용자 정의 인터페이스에 페이징 기능을 추가한다.

📌 MemberRepositoryCustom.interface

package study.querydsl.repository;

import java.util.List;

public interface MemberRepositoryCustom {

    // 페이징 (count 쿼리와 아닌 것을 분리)
    Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable);

}

페이징 기능을하는 searchPageComplex() 메서드를 추가한다.

🔍 searchPagecomplex() 메서드 구현

📌 MemberRepositoryCustomImpl

package study.querydsl.repository;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.util.StringUtils;
import study.querydsl.dto.MemberSearchCondition;
import study.querydsl.dto.MemberTeamDto;
import study.querydsl.dto.QMemberTeamDto;

import java.util.ArrayList;
import java.util.List;

import static study.querydsl.entity.QMember.member;
import static study.querydsl.entity.QTeam.team;

@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
        
        // 데이터 조회 쿼리
        List<MemberTeamDto> content = getMemberTeamDtos(condition, pageable);

        // 전체 카운터 쿼리
        JPAQuery<Long> totalCount = getTotalCount(condition);

        return PageableExecutionUtils.getPage(content, pageable, totalCount::fetchOne);
        // PageImpl보다 PageableExecutionUtils.getPage가 더 낫다.
//        return new PageImpl<>(content, pageable, totalCount);
    }

    // 전체 카운터 쿼리
    private JPAQuery<Long> getTotalCount(MemberSearchCondition condition) {
        JPAQuery<Long> totalCount = queryFactory
                .select(member.count())
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoeEq(condition.getAgeGoe()),
                        ageLoeEq(condition.getAgeLoe())
                );
        return totalCount;
    }

    // 데이터 조회 쿼리
    private List<MemberTeamDto> getMemberTeamDtos(MemberSearchCondition condition, Pageable pageable) {
        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()),
                        ageGoeEq(condition.getAgeGoe()),
                        ageLoeEq(condition.getAgeLoe())
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
        return content;
    }
}

데이터 내용과 전체 카운트 쿼리를 개별적으로 처리한다. 이렇게 개별적으로 처리해야 전체 카운트 쿼리를 개별적으로 최적화가 가능하다.
Page<T> 타입을 반환할 때 PageImpl 생성자로 반환할 수 있으나 PageableExecutionUtils.getPage() 메서드로 반환하는 것이 더 낫다.

PageImpl은 Page인터페이스의 구현체이다 PageImpl에 첫번째 인자로는 content(조회된 컨텐츠), Pageable(요청으로부터 가져온 페이지 요청데이터), totalCount(전체 컨텐츠의 개수)를 주면 된다.  페이징 데이터가 많지않거나 접속량이 중요하지 않은 곳이라면  해당 방법을 사용해도 문제 없을 것 같다 .

PageableExecutionUtils.getPage() 메서드는 PageImpl과 같은 역할을 한다. 그러나 PageableExecutionUtils.getPage()는 마지막 인자로 함수를 전달하는데 내부 작동에 의해서 토탈카운트가 페이지사이즈 보다 적거나 , 마지막페이지 일 경우 해당 함수를 실행하지 않는다 쿼리를 조금더 줄일 수 있다. 위의 코드의 경우 카운트 쿼리 마지막에 fetchCount()가 여기서 관리되고 있다.  PageableExecutionUtils.getPage() 메서드를 사용하면 조금 더 성능 최적화가 된다. PageImpl 보다는 PageableExecutionUtils.getPage() 메서드를 사용하자

🔍 페이징 처리 테스트

📌 MemberRepositoryCustomImplTest

package study.querydsl.repository;

@SpringBootTest
@Transactional
public class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Autowired
    EntityManager em;

    @Test
    void basicTest() {
        Member member = new Member("member1", 10);
        memberRepository.save(member);

        Member findMember = memberRepository.findById(member.getId()).orElse(null);
        assertThat(member).isEqualTo(findMember);

        List<Member> result1 = memberRepository.findAll();
        assertThat(result1).containsExactly(member);

        List<Member> result2 = memberRepository.findByUsername("member1");
        assertThat(result2).containsExactly(member);

    }
    
    /**
     * 페이징 처리 테스트
     * condition에 조건을 추가할 수 있으니 현재 데이터 수가 적어서 조건을 추가하지 않았다.
     */
    @Test
    void searchPageComplex() {
        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");
        em.persist(teamA);
        em.persist(teamB);

        Member member1 = new Member("member1", 10, teamA);
        Member member2 = new Member("member2", 20, teamA);

        Member member3 = new Member("member3", 30, teamB);
        Member member4 = new Member("member4", 40, teamB);

        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);

        em.flush();
        em.clear();

        MemberSearchCondition condition = new MemberSearchCondition();

        PageRequest pageRequest = PageRequest.of(0, 3);


        Page<MemberTeamDto> result = memberRepository.searchPageComplex(condition, pageRequest);

        assertThat(result.getSize()).isEqualTo(3);
        assertThat(result.getContent()).extracting("username").containsExactly("member1", "member2", "member3");

    }
}
    /* select
        member1.id,
        member1.username,
        member1.age,
        team.id,
        team.name 
    from
        Member member1   
    left join
        member1.team as team */ select
            member0_.member_id as col_0_0_,
            member0_.username as col_1_0_,
            member0_.age as col_2_0_,
            team1_.team_id as col_3_0_,
            team1_.name as col_4_0_ 
        from
            member member0_ 
        left outer join
            team team1_ 
                on member0_.team_id=team1_.team_id limit ?
                
    /* select
        count(member1) 
    from
        Member member1   
    left join
        member1.team as team */ select
            count(member0_.member_id) as col_0_0_ 
        from
            member member0_ 
        left outer join
            team team1_ 
                on member0_.team_id=team1_.team_id

PageableExecutionUtils.getPage() 메서드는 count 쿼리가 생략 가능한 경우 생각해서 처리한다.
 - 페이지 시작이면서 컨텐츠 사이즈가 페이즈 사이즈보다 작을 때
 - 마지막 페이지 일 때 (offset + 컨텐츠 사이즈를 더해서 전체 사이즈 구함)

 


 

스프링 데이터 페이징 활용3 - 컨트롤러 개발

📌 MemberController

package study.querydsl.controller;

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberJpaRepository memberJpaRepository;
    private final MemberRepository memberRepository;

    @GetMapping("/v2/members")
    public Page<MemberTeamDto> searchMemberV2(MemberSearchCondition condition, Pageable pageable) {
        return memberRepository.searchPageComplex(condition, pageable);
    }

    ...

}

페이지는 0, size는 10으로 보내보자.

    /* select
        member1.id,
        member1.username,
        member1.age,
        team.id,
        team.name 
    from
        Member member1   
    left join
        member1.team as team */ select
            member0_.member_id as col_0_0_,
            member0_.username as col_1_0_,
            member0_.age as col_2_0_,
            team1_.team_id as col_3_0_,
            team1_.name as col_4_0_ 
        from
            member member0_ 
        left outer join
            team team1_ 
                on member0_.team_id=team1_.team_id limit ?


    /* select
        count(member1) 
    from
        Member member1   
    left join
        member1.team as team */ select
            count(member0_.member_id) as col_0_0_ 
        from
            member member0_ 
        left outer join
            team team1_ 
                on member0_.team_id=team1_.team_id

결과에 데이터 조회쿼리와 count쿼리가 둘 다 생성된 것을 알 수 있다.

PageableExecutionUtils.getPage()를 사용하면 마지막 페이지일 때와 시작페이지면서 컨텐즈 사이즈가 페이지 사이즈보다 작을 때 count 쿼리를  발생시키지 않는다. 이것을 확인해보자.

먼저 마지막 페이지일 경우

    /* select
        member1.id,
        member1.username,
        member1.age,
        team.id,
        team.name 
    from
        Member member1   
    left join
        member1.team as team */ select
            member0_.member_id as col_0_0_,
            member0_.username as col_1_0_,
            member0_.age as col_2_0_,
            team1_.team_id as col_3_0_,
            team1_.name as col_4_0_ 
        from
            member member0_ 
        left outer join
            team team1_ 
                on member0_.team_id=team1_.team_id limit ? offset ?

카운트 쿼리가 발생하지 않았다.

시작 페이지이면서 컨텐즈 사이즈가 페이즈 사이즈보다 작을 경우

    /* select
        member1.id,
        member1.username,
        member1.age,
        team.id,
        team.name 
    from
        Member member1   
    left join
        member1.team as team */ select
            member0_.member_id as col_0_0_,
            member0_.username as col_1_0_,
            member0_.age as col_2_0_,
            team1_.team_id as col_3_0_,
            team1_.name as col_4_0_ 
        from
            member member0_ 
        left outer join
            team team1_ 
                on member0_.team_id=team1_.team_id limit ?

이 경우에도 count 쿼리가 발생하지 않았다는 것을 알 수 있다.

🧷 참고. 스프링 데이터 정렬(Sort)

스프링 데이터 JPA는 자신의 정렬(Sort)을 Querydsl의 정렬(OrderSpecifier)로 편리하게 변경하는 기능을 제공한다. 

스프링 데이터 Sort를 Querydsl의 OrderSpecifier로 변환하기

JPAQuery<Member> query = queryFactory
        .selectFrom(member);

for (Sort.Order o : pageable.getSort()) {

    PathBuilder pathBuilder = new PathBuilder(
            member.getType(), 
            member.getMetadata());

    query.orderBy(new OrderSpecifier(
            o.isAscending() ? Order.ASC : Order.DESC,
            pathBuilder.get(o.getProperty())));
}

List<Member> result = query.fetch()

참고로 정렬(Sort)은 조건이 조금만 복잡해도 Pageable의 Sort기능을 사용하기 어렵다. 조인이 필요한 경우나 동적 정렬 기능이 필요하면 스프링 데이터 페이징이 제공하는 Sort를 사용하기보다는 파라미터를 받아서 직접 정렬 기능을 구현하는 것을 권장한다.

 

 

 

 


👀 참고자료

https://www.inflearn.com/course/Querydsl-%EC%8B%A4%EC%A0%84/dashboard

 

실전! Querydsl - 인프런 | 강의

Querydsl의 기초부터 실무 활용까지, 한번에 해결해보세요!, - 강의 소개 | 인프런...

www.inflearn.com

 

https://ugo04.tistory.com/142

 

QueryDSL 공부 11 - Spring data JPA 와 Querydsl 페이징 연동

Spring Data 의 Pageble 과 Page를 Querydsl과 함께 사용하는 것을 알아보려 한다. 간단한 방법과 성능최적화를 위한 방법 두가지 방법을 알아보자 public interface MemberRepositoryCustom { Page searchPageSim..

ugo04.tistory.com

 

728x90