RE-Heat 개발자 일지

[Querydsl] [6] 실무 활용 - 스프링 데이터 JPA와 Querydsl 본문

백엔드/Querydsl

[Querydsl] [6] 실무 활용 - 스프링 데이터 JPA와 Querydsl

RE-Heat 2023. 9. 30. 21:16
 

실전! Querydsl - 인프런 | 강의

Querydsl의 기초부터 실무 활용까지, 한번에 해결해보세요!, 복잡한 쿼리, 동적 쿼리는 이제 안녕! Querydsl로 자바 백엔드 기술을 단단하게. 🚩 본 강의는 로드맵 과정입니다. 본 강의는 자바 백엔

www.inflearn.com

인프런 김영한 님의 강의를 듣고 작성한 글입니다.

 

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

MemberRepository - 스프링 데이터 JPA

public interface MemberRepository extends JpaRepository<Member, Long> {
    //select m From Member m where m.username =:username
    List<Member> findByUsername(String username);
}

 

테스트

@SpringBootTest
@Transactional
class MemberRepositoryTest {
    @Autowired
    EntityManager em;
    @Autowired
    MemberRepository memberRepository;

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

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

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

        List<Member> result2 = memberRepository.findByUsername("member1");
        assertThat(result2).containsExactly(member);
    }
}
  • findById와 findAll은 스프링 데이터 JPA에 등록된 것 사용
  • findByUsername은 쿼리 메소드 기능 3가지 중 하나인 메소드 이름으로 쿼리 생성 방식으로 만듦.

▶ Querydsl를 쓰려면 사용자 정의 리포지토리가 필요하다.


[2] 사용자 정의 리포지토리

■ 사용자 정의 리포지토리 사용법

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

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

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

 

■ 사용자 정의 리포지토리 구성

 

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

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

 

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

public class MemberRepositoryImpl implements MemberRepositoryCustom {

    private final JPAQueryFactory queryFactory;

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

    @Override
    public List<MemberTeamDto> search(MemberSearchCondition condition) {
        List<Member> result =
                queryFactory.select(member)
                        .from(member)
                        .leftJoin(member.team, team)
                        .where(
                                usernameEq(condition.getUsername()),
                                teamNameEq(condition.getTeamName()),
                                ageBetween(condition.getAgeLoe(), condition.getAgeGoe())
                        )
                        .fetch();

        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) {
        if (ageLoe != null & ageGoe != null) {
            return ageLoe(ageLoe).and(ageGoe(ageGoe));
        } else {
            return null;
        }
    }

    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;
    }
}
  • MemberRepositoryImpl에서 extends로 MemberRepositoryCustom 상속
  • Querydsl where 파라미터 방식으로 동적 쿼리 작성
  • 반환은 QMemberTeamDto로
  • 명명 규칙이 있으므로 꼭 Impl로 만들자

 

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

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
    //select m From Member m where m.username =:username
    List<Member> findByUsername(String username);
}
  • extends로 사용자 정의 인터페이스(MemberRepositoryCustom) 상속

 

테스트 코드

@Test
public 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);

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

}

 

TIP : API나 화면에 너무 특화 돼 있으면 따로 MemberQueryRepository를 만들어 사용하는 편이 낫다.

자세한 내용은 [JPA 활용2] [3] API 개발 고급 - 지연 로딩과 조회 성능 최적화 : V4 단점 관련 해결책 부분 참고


[3] 스프링 데이터 페이징 활용1 - Querydsl 페이징 연동

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

사용자 정의 인터페이스 - 페이징 2가지 추가

public interface MemberRepositoryCustom {
    public List<MemberTeamDto> search(MemberSearchCondition condition);
    public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable);
    public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable);
}

 

■ 전체 카운트를 한 번에 조회하는 단순한 방법

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

@Override
public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
    QueryResults<MemberTeamDto> results = 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())
            )
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetchResults();

    List<MemberTeamDto> content = results.getResults();
    long total = results.getTotal();

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

offset과 limit를 지정해 준 뒤 fetchResults()를 호출해 준다. 이러면 자동으로 count 쿼리도 날아간다.

 

테스트 코드

@Test
public void searchPageSimpleTest() {
    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);

    MemberSearchCondition condition = new MemberSearchCondition();
    PageRequest pageRequest = PageRequest.of(0, 3);

    Page<MemberTeamDto> result = memberRepository.searchPageSimple(condition, pageRequest);
    assertThat(result.getSize()).isEqualTo(3);
    assertThat(result.getContent()).extracting("username").containsExactly("member1", "member2", "member3");
}
  • PageRequset에 offset 0과 limit 3을 담아 보낸다.

 

■ 데이터 내용과 전체 카운트를 별도로 조회하는 방법

Join한 테이블이 많아질 때 기존 방식을 쓰면 CountQuery도 수많은 조인과 함께 날아간다. 이럴 필요가 없으므로 분리하는 방법을 배워보자.

사용자 인터페이스 구현 - searchPageComplex

@Override
public Page<MemberTeamDto> searchPageComplex(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()),
                    ageBetween(condition.getAgeLoe(), condition.getAgeGoe())
            )
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    long total = queryFactory
        .select(member) 
        .from(member)
        .leftJoin(member.team, team)
        .where(usernameEq(condition.getUsername()), 
            teamNameEq(condition.getTeamName()), 
            ageGoe(condition.getAgeGoe()),
            ageLoe(condition.getAgeLoe())) 
        .fetchCount();
        
return new PageImpl<>(content, pageable, total);}
  • 전체 카운트를 조회하는 방법을 이런 식으로 분리하면 조회할 때 조인을 줄일 수 있다.
  • 코드를 리팩토링 해 내용 쿼리와 전체 카운트 쿼리를 읽기 좋게 분리하면 된다.


[4] 스프링 데이터 페이징 활용2 - CountQuery 최적화

PageableExecutionUtils.getPage()로 CountQuery 최적화가 가능하다.
@Override
public Page<MemberTeamDto> searchPageComplex(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()),
                    ageBetween(condition.getAgeLoe(), condition.getAgeGoe())
            )
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    JPAQuery<Member> countQuery = queryFactory
            .selectFrom(member)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageBetween(condition.getAgeLoe(), condition.getAgeGoe())
            );

    return PageableExecutionUtils.getPage(content, pageable, () -> countQuery.fetchCount());
    //return new PageImpl<>(content, pageable, total);
}

스프링 데이터가 PageableExecutionUtils 라이브러리 제공

count 쿼리가 생략 가능하면 자동으로 생략해서 처리하는 장점이 있다.

예시]

① 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때

  • 페이지 시작이면서 컨텐츠 사이즈(3) < 페이지 사이즈(100) 
    • 이러면 전체 사이즈를 구할 때 컨텐츠 사이즈를 가져다 쓰면 되므로 count 쿼리를 날리지 않는다.

② 마지막 페이지일 때(offset + 컨텐츠 사이즈를 더해서 전체 사이즈를 구한다)


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

MemberController

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberJpaRepository memberJpaRepository;
    private final MemberRepository memberRepository;

    @GetMapping("/v1/members")
    public List<MemberTeamDto> searchMemberV1(MemberSearchCondition condition){
        return memberJpaRepository.search(condition);
    }

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


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

 

실행화면

① 포스트맨

 

② SQL

count 쿼리가 날아가지 않는다. 왜냐면 제일 처음에 100개를 다 가져왔기 때문에 count 쿼리를 날릴 이유가 없다.

 

■ 스프링 데이터 정렬(Sort)

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

 

■ 스프링 데이터 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를 사용하기보다는 파라미터를 받아서 직접 처리하자!