본문 바로가기

QueryDSL

[Query DSL] 스프링 데이터JPA 리포지토리와 Querydsl

 

스프링 데이터 JPA - MemberRepository 생성

package com.example.querytest.repository;

import com.example.querytest.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

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

 

- Querydsl 전용 기능인 회원 search를 작성할 수 없다. -> 사용자 정의 리포지토리 필요

 

 

사용자 정의 리포지토리

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

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

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

 

 

 

 

 

스프링 데이터 JPA 테스트

package com.example.querytest.repository;

import com.example.querytest.entity.Member;
import jakarta.transaction.Transactional;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@Transactional
class MemberRepositoryTest {

    @Autowired
    private 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(member.getUsername());

        assertThat(result2).containsExactly(member);

    }

}

 

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

package com.example.querytest.repository;

import com.example.querytest.dto.MemberSearchCondition;
import com.example.querytest.dto.MemberTeamDto;
import com.example.querytest.dto.QMemberTeamDto;
import static com.example.querytest.entity.QMember.member;
import static com.example.querytest.entity.QTeam.team;

import com.querydsl.core.types.Predicate;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;

import java.util.List;

import static org.springframework.util.StringUtils.isEmpty;

public class MemberRepositoryImpl implements MemberRepositoryCustom{
    
    private final JPAQueryFactory jpaQueryFactory;
    
    public MemberRepositoryImpl(EntityManager em){
        this.jpaQueryFactory = new JPAQueryFactory(em);
    }

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

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

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

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

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

 

 

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

package com.example.querytest.repository;

import com.example.querytest.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

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

}

 

커스텀 리포지토리 동작 테스트 추가

package com.example.querytest.repository;

import com.example.querytest.dto.MemberSearchCondition;
import com.example.querytest.dto.MemberTeamDto;
import com.example.querytest.entity.Member;
import com.example.querytest.entity.Team;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@Transactional
class MemberRepositoryTest {

    @Autowired
    private 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(member.getUsername());

        assertThat(result2).containsExactly(member);

    }

    @Autowired
    EntityManager em;

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

}

 

 

팁)

약간 공용성이 없고 특정 API에 되게 종속되어있고 이러면 라이프 사이클 수정 라이프 사이클 잧가 그 API나 화면에 맞춰가지고 이 기능이 변경된다. 찾기도 편하고 그러 이제 이렇게 별도로 이런 조회용 리포지토리들을 만드는 것도  되게 괜찮아진다. 

상황에 맞게 trade-off를 잘하면 된다.

package com.example.querytest.repository;

import com.example.querytest.dto.MemberSearchCondition;
import com.example.querytest.dto.MemberTeamDto;
import com.example.querytest.dto.QMemberTeamDto;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Repository;

import java.util.List;

import static com.example.querytest.entity.QMember.member;
import static com.example.querytest.entity.QTeam.team;
import static org.springframework.util.StringUtils.isEmpty;

@Repository
public class MemberQueryRepository {

    private final JPAQueryFactory jpaQueryFactory;

    public MemberQueryRepository(EntityManager em){
        this.jpaQueryFactory = new JPAQueryFactory(em);
    }
    
    public List<MemberTeamDto> search(MemberSearchCondition condition){
        return jpaQueryFactory
                .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()))
                .fetch();
    }

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

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

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

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

}

 

 

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

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

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

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

 

 

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

package com.example.querytest.repository;

import com.example.querytest.dto.MemberSearchCondition;
import com.example.querytest.dto.MemberTeamDto;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

import java.util.List;

public interface MemberRepositoryCustom {

    List<MemberTeamDto> search(MemberSearchCondition condition);

    Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable);

    Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable);
}

 

 

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

searchPageSimple(), fetchResults() 사용

// 단순한 페이징, fetchResults() 사용

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

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

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

- Querydsl이 제공하는 fetchResults() 를 사용하면 내용과 전체 카운트를 한번에 조회할 수 있다.(실제 쿼리는 2번 호출)

0 fetchResults() 는 카운트 쿼리 실행시 필요없는 order by는 제거한다.

 

테스트

@Test
    public void searchPageSimple(){

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

    }

 

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

searchPageComplex()

@Override
    public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
        List<MemberTeamDto> content =  jpaQueryFactory
                .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();
        
        long total  = jpaQueryFactory
                .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);
    }

- 전체 카운트를 조회 하는 방법을 최적화 할  수 있으면 이렇게 분리하면 된다.(예를 들어서 전체 카운트를 조회할 때 조인 쿼리를 줄일 . 수있다면 상당한 효과가 있다.)

- 코드를 리팩토링해서 내용 쿼리과 전체 카운트 쿼리를 읽기 좋게 분리하면 된다.

 

 

 

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

PageableExecutionUtils.getPage()로 최적화

@Override
    public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
        List<MemberTeamDto> content =  jpaQueryFactory
                .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();

        JPAQuery<Member> countQuery = jpaQueryFactory
                .select(member)
                .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.fetchCount());
        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchCount);
    }
  • 스프링 데이터 라이브러가 제공
  • count 쿼리가 생략 가능한 경우 생략해서 처리
    • 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
    • 마지막 페이지 일 때(offset + 컨테츠 사이즈를 더해서 전체 사이즈 구함, 더 정확히는 마지막 페이지면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때 )

 

fetchResults() 방식으로 모든 것을 처리할 수도 있지만, 복잡한 쿼리에서 CountQuery를 최적화하는 것이 더 좋은 성능을 발휘할 수 있습니다. 특히 데이터가 많고 조인이 복잡한 상황에서는 CountQuery를 따로 최적화하는 것이 중요합니다. 이는 성능 문제를 줄이고 애플리케이션의 반응 속도를 향상시킬 수 있습니다.

 

fetchResults() 메서드를 사용하면 쿼리가 두 번 실행됩니다: 첫 번째 쿼리는 페이징된 데이터를 가져오기 위해 실행되고, 두 번째 쿼리는 전체 레코드 수를 계산하기 위해 실행됩니다. 이 두 번째 쿼리가 불필요하게 복잡하거나 성능에 부담이 될 수 있습니다.

왜 분리가 더 좋은가?

  1. 쿼리 두 번 실행의 문제:
    • fetchResults()는 페이징된 데이터와 함께 전체 데이터의 개수를 계산하기 위해 두 번의 쿼리를 실행합니다. 이 두 번째 쿼리, 즉 count 쿼리는 단순히 레코드 수만 세면 되지만, 전체 데이터를 가져오는 쿼리와 동일한 조인과 필터링을 수행해야 하므로 성능에 영향을 미칠 수 있습니다.
    • 특히 조인이 많은 경우, 불필요하게 복잡한 count 쿼리가 실행되어 성능 저하가 발생할 수 있습니다.
  2. 불필요한 count 쿼리 생략:
    • 만약 페이지의 첫 번째 페이지이면서 페이지 사이즈보다 데이터가 적다면, 그 자체로 해당 페이지가 마지막 페이지임을 알 수 있습니다. 이 경우 count 쿼리를 생략할 수 있습니다.
    • 마지막 페이지에서 데이터를 불러올 때도, 페이지 오프셋(offset)과 컨텐츠의 크기(content size)를 더해 전체 크기를 추정할 수 있으므로 count 쿼리를 생략할 수 있습니다.
  3. 최적화된 count 쿼리:
    • PageableExecutionUtils.getPage()를 사용하면, count 쿼리가 반드시 필요할 때만 실행되도록 최적화할 수 있습니다.
    • 또한, count 쿼리를 더 간단하게 작성하거나 생략할 수 있는 경우, 성능 최적화에 도움이 됩니다.

가정:

  • 전체 데이터: 100개의 데이터가 있다고 가정합니다.
  • 페이지 사이즈: 한 페이지에 10개의 데이터를 보여준다고 가정합니다.

1. fetchResults()를 사용한 경우:

  • 예를 들어, 사용자가 3번째 페이지(페이지 번호 2, 0부터 시작한다고 가정)를 요청했을 때, fetchResults()를 사용하면 다음과 같이 두 개의 쿼리가 실행됩니다.
    1. 첫 번째 쿼리: LIMIT 10 OFFSET 20 쿼리로 21번째부터 30번째 데이터를 가져옵니다.
    2. 두 번째 쿼리: COUNT(*) 쿼리로 전체 데이터의 수인 100을 계산합니다.

2. 최적화된 count 쿼리:

  • 이제, 불필요한 count 쿼리를 생략할 수 있는 경우를 살펴봅니다.
  • **첫 번째 페이지(페이지 번호 0)**를 요청할 때:
    • 첫 번째 쿼리에서 LIMIT 10 OFFSET 0으로 10개의 데이터를 가져옵니다.
    • 이때 가져온 데이터의 개수가 10보다 작으면, 이 페이지가 마지막 페이지임을 알 수 있습니다. 따라서 count 쿼리가 필요 없습니다.
    • 예를 들어, 가져온 데이터가 8개라면, 첫 페이지에서 이 페이지가 마지막임을 알 수 있습니다.
  • **마지막 페이지(페이지 번호 9)**를 요청할 때:
    • 첫 번째 쿼리에서 LIMIT 10 OFFSET 90으로 91번째부터 100번째 데이터를 가져옵니다.
    • 만약 가져온 데이터가 10개보다 적다면, 이 페이지가 마지막 페이지임을 알 수 있습니다.
    • 예를 들어, 100개의 데이터가 있는 상황에서 마지막 페이지를 요청했다면, count 쿼리가 필요 없이 OFFSET + 가져온 데이터 수 = 전체 데이터 수로 계산할 수 있습니다.
    • 즉, 90 + 10 = 100으로 전체 데이터 수를 계산할 수 있습니다.

 

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

실제 컨트롤러

package com.example.querytest.controller;

import com.example.querytest.dto.MemberSearchCondition;
import com.example.querytest.dto.MemberTeamDto;
import com.example.querytest.repository.MemberJpaRepository;
import com.example.querytest.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberJpaRepository memberJpaRepository;
    private final MemberRepository memberRepository;

    @GetMapping("/v1/members")
    public List<MemberTeamDto> searchMemberV1(MemberSearchCondition condition){
        System.out.println("conditions: " + 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);
    }
}

 

- v2/members

 

- v3/members

 

 

스프링 데이터 정렬(Sort)

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

스프링 데이터 SortQuerydslOrderSpecifier로 변환**

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

pageable.getSort().forEach(order -> {
    PathBuilder<?> pathBuilder = new PathBuilder<>(member.getType(), member.getMetadata());
    Order direction = order.isAscending() ? Order.ASC : Order.DESC;
    query.orderBy(new OrderSpecifier<>(direction, pathBuilder.get(order.getProperty())));
});

참고: 정렬( `Sort` )은 조건이 조금만 복잡해져도 `Pageable` `Sort` 기능을 사용하기 어렵다. 루트 엔티티 범 위를 넘어가는 동적 정렬 기능이 필요하면 스프링 데이터 페이징이 제공하는 `Sort` 를 사용하기 보다는 파라미터 를 받아서 직접 처리하는 것을 권장한다.

 

요약된 내용:

  1. Sort의 기본 사용법:
    • Sort는 Pageable과 함께 사용되어 데이터베이스 쿼리에 정렬 조건을 추가할 수 있습니다. 예를 들어, 특정 컬럼을 기준으로 오름차순이나 내림차순으로 정렬하는 기능을 제공합니다.
  2. Sort의 한계:
    • Sort는 루트 엔티티(즉, 쿼리의 메인 대상이 되는 테이블)에 대해서는 잘 작동하지만, 쿼리가 복잡해지거나 여러 테이블(예: 조인된 테이블)에서 정렬을 하려는 경우, Sort를 사용하는 것이 제한적일 수 있습니다.
    • 특히, 루트 엔티티 외의 다른 테이블이나 서브쿼리에서의 동적 정렬 조건을 처리해야 하는 상황에서는 Sort를 통해 원하는 결과를 얻기가 어렵습니다.
  3. 대체 방법:
    • 이러한 복잡한 정렬이 필요한 경우, 스프링 데이터의 Pageable이 제공하는 Sort 기능을 사용하는 대신, 정렬 조건을 직접 파라미터로 받아서 처리하는 것을 권장합니다.
    • 직접 처리한다는 것은, 정렬 기준을 쿼리 메서드 내에서 동적으로 구성하고, JPA 또는 QueryDSL과 같은 도구를 사용하여 직접 쿼리에 적용하는 것을 의미합니다.

 

예를 들어, 여러분이 Member와 Team 두 테이블을 조인한 후, Member의 username과 Team의 name을 기반으로 동적으로 정렬을 하고 싶다고 가정해보겠습니다. Sort는 단일 엔티티에서의 정렬에는 유용하지만, Member와 Team을 조인한 결과에서의 정렬 조건을 동적으로 설정하는 것은 어렵습니다.

 

Sort sort = Sort.by("team.name").ascending();
Pageable pageable = PageRequest.of(0, 10, sort);

위와 같이 team.name을 기반으로 정렬을 하고 싶어도, Sort는 루트 엔티티(여기서는 Member) 외의 엔티티에 대해서는 제대로 동작하지 않을 수 있습니다.

 

복잡한 정렬 조건을 직접 처리해야 하는 경우, 아래와 같이 QueryDSL을 사용하여 직접 정렬 조건을 추가하는 것이 필요할 수 있습니다:

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

if ("teamName".equals(sortBy)) {
    query.orderBy(team.name.asc());
} else if ("username".equals(sortBy)) {
    query.orderBy(member.username.asc());
}

// 그 외 추가적인 동적 정렬 조건들...

 

  • 스프링 데이터의 Sort와 Pageable은 기본적인 정렬에는 유용하지만, 복잡한 동적 정렬(특히 여러 테이블을 조인하는 상황)에서는 한계가 있습니다.
  • 이러한 경우에는 직접 정렬 조건을 처리하도록 쿼리 로직을 작성하는 것이 더 적합하며, QueryDSL과 같은 도구를 사용해 정렬 조건을 동적으로 추가하는 것이 좋습니다.