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

Chooooo·2024년 6월 25일
0

Querydsl

목록 보기
7/8

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

순수 JPA 레포지토리에서 스프링 데이터 JPA 레포지토리로 변경

스프링 데이터 JPA를 이용하면 순수 JPA를 이용해서 만들었던 기본적인 기능들은 제공해주므로 간단해진다.

  • 즉 만들어야 할 코드가 굉장히 많이 줄어든다.
public interface MemberRepository extends JpaRepository<Member, Long> {

    // select m from Member m where m.username = ?
    List<Member> findByUsername(String username);
}

기본적인 save() 메서드나 findById()와 같은 메서드들은 모두 스프링 데이터 JPA에서 제공해준다.

사용자 정의 레포지토리

복잡한 쿼리를 쓰려면 사용자 정의 레포지토리를 작성해야 한다.

먼저 JpaRepository를 상속한 스프링 데이터 JPA 레포지토리인 MemberRepository(어쨋든 내가 만들고자 하는 엔티티의 레포지토리)를 만들고 복잡한 쿼리를 담당한 MemberRepositoryCustom 인터페이스를 만들고 여기에 선언을 해준다.

그 후 실제 구현을 담당한 MemberRepositoryImpl이라는 클래스를 만들고 MemberRepositoryCustom을 상속한다.

이때 이름이 스프링 데이터 JPA 레포지토리 이름을 따라가야 실제 구현체를 찾을 수 있으므로 이름에 조심하자.

위 과정을 코드로 보면 아래와 같다.

스프링 데이터 JPA - MemberRepository

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

    // select m from Member m where m.username = ?
    List<Member> findByUsername(String username);
}

MemberRepositoryCustom interface

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

MemberRepositoryImpl

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) {
        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()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .fetch();
    }

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

    private BooleanExpression ageBetween(Integer ageLoe, Integer ageGoe) {
        return ageLoe(ageLoe).and(ageGoe(ageGoe));
    }
}
  • 만약 Querydsl을 이용하는 기능이 너무 특화된 기능이라면 굳이 MemberRepository로 상속하도록 하는게 아니라, 별도의 클래스를 만들고 거기서 쿼리를 관리하는 것도 좋다.
  • 해당 내용을 따로 다뤄야지…

Querydsl 페이징 연동

스프링 데이터 JPA에 있는 페이징 기능을 Querydsl에서 활용하는 방법을 소개한다.

먼저 사용자 정의 인터페이스에 페이징을 할 수 있는 메소드를 추가한다

public interface MemberRepositoryCustom {

    List<MemberTeamDto> search(MemberSearchCondition condition);
    Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable); // 새로 추가한 페이징 메소드 1
    Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable); // 새로 추가한 페이징 메소드 2
}

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

@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()),
                        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번 호출)
  • fetchResults()는 카운트 쿼리 실행 시 필요없는 order by는 제거한다.

데이터와 전체 카운트를 별도로 조회하는 방법 - 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()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        long total = queryFactory
                .selectFrom(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);
}
  • 데이터를 가지고 오는 쿼리와 count를 가지고 오는 쿼리를 별도로 작성해서 이후에 조회 후 합치는 방식.
  • 이렇게 사용하는 이유는 count 쿼리의 경우에는 조인을 탈 필요가 없는 경우도 있기 때문에 별도로 작성하는게 성능을 높일 수 있다.

CountQuery 최적화

count 쿼리가 생략 가능한 경우가 있고, 이를 스프링 데이터 JPA에서 지원해주기도 한다.

일단.. 모든 경우에 count 쿼리가 필요하지 않을 때가 있다.

  • 첫 페이지를 조회할 때, 조회된 데이터의 개수가 페이지 사이즈보다 적으면 전체 개수를 알 필요가 없다. 이 경우 이미 전체 데이터가 첫 페이지에 포함된 것이기 때문에.
  • 마지막 페이지를 조회할 때, offset과 pageSize를 통해 전체 개수를 유추할 수 있다.

Count Query 최적화

@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()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            )
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    JPAQuery<Member> countQuery = queryFactory
            .selectFrom(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);
}
  • countQuery::fetchCount는 메서드 참조 구문. 특정 메서드를 참조하여 람다 표현식처럼 사용할 수 있게 해준다.
  • 구체적으로 countQuery::fetchCount는 countQuery 객체의 fetchCount 메서드를 참조하는 것을 의미한다.
    • 이렇게 3번째 인자에 바로 값을 반환하는게 아니라 메서드 참조로 작성하는 이유는 3번째 인자가 함수형 인터페이스로 받기 때문.
  • PageableExecutionUtils.getPage() 메서드는 두 가지 조건에서 count 쿼리를 생략할 수 있다.
    • 조회된 데이터의 개수가 페이지 사이즈보다 작을 때
    • 마지막 페이지를 조회할 때

이렇게 최적화를 통해 불필요한 count 쿼리 실행 방지 → 성능 향상

PageableExecutionUtils.getPage() 역할

이 메서드는 페이징된 결과를 반환한다. 이 과정에서 페이지 정보를 계산하기 위해 총 레코드 수를 알아야 할 때가 있다. 하지만 모든 경우에 총 레코드 수를 계산할 필요는 없음.

메서드 참조 (countQuery::fetchCount)와 람다 표현식 (() → countQuery.fetchCount())는 LongSupplier 인터페이스를 구현하는 방법이다. LongSupplier는 long getAsLong() 메서드를 가지고 있는데, 이는 총 레코드 수를 계산할 때 호출된다.

메서드 참조가 필요한 이유

만약 countQuery.fechCount()를 직접 전달한다면, 즉시 호출되어 long 값을 반환한다. 하지만 PageableExecutionUtils.getPage는 나중에 필요할 때만 총 레코드 수를 계산하기 위해 이 값을 사용하고 싶어 한다 → 이를 위해 메서드 참조나 람다 표현식을 사용하여 countQuery.fetchCount()를 나중에 호출되도록 설정하는 것!!

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

content : 페이지에 표시될 데이터 리스트 (결과값)

pageable : 페이지 정보 (페이지 번호, 크기)

countQuery::fetchCount : 총 레코드 수를나중에 계산하기 위한 메서드 참조

결국, PageableExecutionUtils.getPage는 필요할 때 이 메서드 참조를 호출하여 총 레코드 수를 계산한다.

  • 필요할 때란, 예를 들어, 첫 페이지를 제외한 다른 페이지를 요청할 때, 데이터 크기가 페이지 크기보다 작지 않을 때 등을 의미..

페이징을 사용하는 컨트롤러 개발

@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);
}
profile
back-end, 지속 성장 가능한 개발자를 향하여

0개의 댓글