BackEnd/JPA

[JPA] Spring Data JPA가 제공하는 QueryDsl 기능

샤아이인 2022. 5. 16.

내가 공부한 것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼 겸 상세히 기록하고 얕은 부분들은 가볍게 포스팅하겠습니다.

 

1. 인터페이스 지원 - QuerydslPredicateExecutor

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

그래도 Spring Data 에서 제공하는 기능이므로 간단히 소개하고, 왜 부족한지 설명하겠다.

 

1-1) Interface 지원 - QuerydslPredicateExecutor

출처 - https://docs.spring.io/spring-data/jpa/docs/2.2.3.RELEASE/reference/html/#core.extensions.querydsl

기존의 repository에 다음과 같이 QuerydslPredicateExecutor<Member>를 추가로 상속하게 되었다.

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

이를 사용하는 테스트 코드는 다음과 같다.

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

    // then
    QMember member = QMember.member;
    Iterable<Member> results = memberRepository.findAll(member.age.between(10, 40).and(member.username.eq("member1")));
    for (Member result : results) {
        System.out.println("result = " + result);
    }
}

생성되는 쿼리는 다음과같다.

select
    member0_.member_id as member_i1_1_,
    member0_.age as age2_1_,
    member0_.team_id as team_id4_1_,
    member0_.username as username3_1_ 
from
    member member0_ 
where
    (member0_.age between ? and ?) and member0_.username=?

 

1-2) 한계점

  • 조인X (묵시적 조인은 가능하지만 left join이 불가능하다.)
  • 클라이언트가 Querydsl에 의존해야 한다.
  • 서비스 클래스가 Querydsl이라는 구현 기술에 의존해야 한다. 복잡한 실무환경에서 사용하기에는 한계가 명확하다.

 

테이블이 작은거면 모를까 실무에서는 권장하지 않는 방식이다!

 

2. Querydsl Web 지원

https://docs.spring.io/spring-data/jpa/docs/2.2.3.RELEASE/reference/html/#core.web.type-safe

 

Spring Data JPA - Reference Documentation

Example 108. Using @Transactional at query methods @Transactional(readOnly = true) public interface UserRepository extends JpaRepository { List findByLastname(String lastname); @Modifying @Transactional @Query("delete from User u where u.active = false") v

docs.spring.io

 

▶ 한계점

  • 단순한 조건만 가능
  • 조건을 커스텀하는 기능이 복잡하고 명시적이지 않음
  • 컨트롤러가 Querydsl에 의존
  • 복잡한 실무환경에서 사용하기에는 한계가 명확

 

3. 리포지토리 지원 - QuerydslRepositorySupport

▶ 장점

  • getQuerydsl().applyPagination() 스프링 데이터가 제공하는 페이징을 Querydsl로 편리하게 변환 가능(단! Sort는 오류발생)
  • from() 으로 시작 가능(최근에는 QueryFactory를 사용해서 select() 로 시작하는 것이 더 명시적)
  • EntityManager 제공

 

▶ 한계

  • Querydsl 3.x 버전을 대상으로 만듬
  • Querydsl 4.x에 나온 JPAQueryFactory로 시작할 수 없음
  • select로 시작할 수 없음 (from으로 시작해야함) QueryFactory 를 제공하지 않음
  • 스프링 데이터 Sort 기능이 정상 동작하지 않음

 

다음시간에는 이러한 한계를 극복하도록 직접 구현해보자.

 

4. Querydsl 지원 클래스 직접 만들기

Spring Data 가 제공하는 QuerydslRepositorySupport 가 지닌 한계를 극복하기 위해 직접 Querydsl 지원 클래스를 만들어보자.

 

4-1) Querydsl4RepositorySupport

우선 이번에 사용할 Querydsl4RepositorySupport 코드는 다음고 같다.

@Repository
public abstract class Querydsl4RepositorySupport {
    private final Class domainClass;
    private Querydsl querydsl;
    private EntityManager entityManager;
    private JPAQueryFactory queryFactory;

    public Querydsl4RepositorySupport(Class<?> domainClass) {
        Assert.notNull(domainClass, "Domain class must not be null!");
        this.domainClass = domainClass;
    }

    @Autowired
    public void setEntityManager(EntityManager entityManager) {
        Assert.notNull(entityManager, "EntityManager must not be null!");
        JpaEntityInformation entityInformation = JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager);
        SimpleEntityPathResolver resolver = SimpleEntityPathResolver.INSTANCE;
        EntityPath path = resolver.createPath(entityInformation.getJavaType());
        this.entityManager = entityManager;
        this.querydsl = new Querydsl(entityManager, new PathBuilder<>(path.getType(), path.getMetadata()));
        this.queryFactory = new JPAQueryFactory(entityManager);
    }

    @PostConstruct
    public void validate() {
        Assert.notNull(entityManager, "EntityManager must not be null!");
        Assert.notNull(querydsl, "Querydsl must not be null!");
        Assert.notNull(queryFactory, "QueryFactory must not be null!");
    }

    protected JPAQueryFactory getQueryFactory() {
        return queryFactory;
    }

    protected Querydsl getQuerydsl() {
        return querydsl;
    }

    protected EntityManager getEntityManager() {
        return entityManager;
    }

    protected <T> JPAQuery<T> select(Expression<T> expr) {
        return getQueryFactory().select(expr);
    }

    protected <T> JPAQuery<T> selectFrom(EntityPath<T> from) {
        return getQueryFactory().selectFrom(from);
    }

    protected <T> Page<T> applyPagination(Pageable pageable, Function<JPAQueryFactory, JPAQuery> contentQuery) {
        JPAQuery jpaQuery = contentQuery.apply(getQueryFactory());
        List<T> content = getQuerydsl().applyPagination(pageable, jpaQuery).fetch();
        return PageableExecutionUtils.getPage(content, pageable, jpaQuery::fetchCount);
    }

    protected <T> Page<T> applyPagination(Pageable pageable,
                                          Function<JPAQueryFactory, JPAQuery> contentQuery, Function<JPAQueryFactory,
            JPAQuery> countQuery) {
        JPAQuery jpaContentQuery = contentQuery.apply(getQueryFactory());
        List<T> content = getQuerydsl().applyPagination(pageable, jpaContentQuery).fetch();
        JPAQuery countResult = countQuery.apply(getQueryFactory());
        return PageableExecutionUtils.getPage(content, pageable, countResult::fetchCount);
    }
}

위 코드를 상속하여 사용하는 MemberTestRepository를 하나 만들어 보자.

 

4-2) Querydsl4RepositorySupport의 applyPagination 기능을 적용시키기 (V1)

우선 Querydsl4RepositorySupport의 applyPagination 기능을 적용시키기 전의 코드는 다음과같다.

public Page<Member> applyPaginationV1(MemberSearchCondition condition, Pageable pageable) {
    JPAQuery<Member> query = selectFrom(member)
            .leftJoin(member.team, team)
            .where(usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            );

    List<Member> content = getQuerydsl().applyPagination(pageable, query).fetch();
    return PageableExecutionUtils.getPage(content, pageable, query::fetchCount);
}

쿼리가 한번에 메서드 체인으로 연결되면서 진행되는 방식이 아니라 매우 아쉽다.

결과를 우선 JPAQuery<Member> query로 받은 후, 해당 query를 다시 applyPagination() 의 인자로 넘기고 있다.

 

4-3) Querydsl4RepositorySupport의 applyPagination 기능을 적용시킨 (V2)

이를 좀더 깔끔하게 변경한 버전은 다음과 같다.

public Page<Member> applyPaginationV2(MemberSearchCondition condition, Pageable pageable) {
    return applyPagination(pageable, query -> query
            .selectFrom(member)
            .leftJoin(member.team, team)
            .where(usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            )
    );
}

두번째 인자로 람다식을 전달하게 되었다.

 

이를 다음 applyPagination의 두번째 인자로 전달받아 사용하게 된다.

protected <T> Page<T> applyPagination(Pageable pageable, Function<JPAQueryFactory, JPAQuery> contentQuery) {
    JPAQuery jpaQuery = contentQuery.apply(getQueryFactory());
    List<T> content = getQuerydsl().applyPagination(pageable, jpaQuery).fetch();
    return PageableExecutionUtils.getPage(content, pageable, jpaQuery::fetchCount);
}

사실 그냥 applyPaginationV1 버전을 한번더 wrapping한 것 이다.

 

4-4) Querydsl4RepositorySupport의 applyPagination 기능 + CountQuery 기능 (V3)

이번에는 아예 count 쿼리까지 전달하는 V3를 만들어 보자.

public Page<Member> applyPaginationV3(MemberSearchCondition condition, Pageable pageable) {
    return applyPagination(pageable,
            contentQuery -> contentQuery
            .selectFrom(member)
            .leftJoin(member.team, team)
            .where(usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe()))
            , countQuery -> countQuery
                    .select(Wildcard.count)
                    .from(member)
                    .leftJoin(member.team, team)
                    .where(usernameEq(condition.getUsername()),
                            teamNameEq(condition.getTeamName()),
                            ageGoe(condition.getAgeGoe()),
                            ageLoe(condition.getAgeLoe()))
    );
}

 

4-5) 전체 MemberTestRepository 코드

이를 사용한 전체 Repository 코드는 다음과 같다.

@Repository
public class MemberTestRepository extends Querydsl4RepositorySupport {

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

    public List<Member> basicSelect() {
        return select(member)
                .from(member)
                .fetch();
    }

    public List<Member> basicSelectFrom() {
        return selectFrom(member).fetch();
    }

    public Page<Member> applyPaginationV1(MemberSearchCondition condition, Pageable pageable) {
        JPAQuery<Member> query = selectFrom(member)
                .leftJoin(member.team, team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                );

        List<Member> content = getQuerydsl().applyPagination(pageable, query).fetch();
        return PageableExecutionUtils.getPage(content, pageable, query::fetchCount);
    }

    public Page<Member> applyPaginationV2(MemberSearchCondition condition, Pageable pageable) {
        return applyPagination(pageable, query -> query
                .selectFrom(member)
                .leftJoin(member.team, team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
        );
    }

    public Page<Member> applyPaginationV3(MemberSearchCondition condition, Pageable pageable) {
        return applyPagination(pageable,
                contentQuery -> contentQuery
                .selectFrom(member)
                .leftJoin(member.team, team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                , countQuery -> countQuery
                        .select(Wildcard.count)
                        .from(member)
                        .leftJoin(member.team, team)
                        .where(usernameEq(condition.getUsername()),
                                teamNameEq(condition.getTeamName()),
                                ageGoe(condition.getAgeGoe()),
                                ageLoe(condition.getAgeLoe()))
        );
    }

    private BooleanBuilder usernameEq(String username) {
        if (StringUtils.hasText(username)) {
            return new BooleanBuilder(member.username.eq(username));
        }
        return new BooleanBuilder();
    }

    private BooleanBuilder teamNameEq(String teamName) {
        if (StringUtils.hasText(teamName)) {
            return new BooleanBuilder(team.name.eq(teamName));
        }
        return new BooleanBuilder();
    }

    private BooleanBuilder ageGoe(Integer ageGoe) {
        if (ageGoe != null) {
            return new BooleanBuilder(member.age.goe(ageGoe));
        }
        return new BooleanBuilder();
    }

    private BooleanBuilder ageLoe(Integer ageLoe) {
        if (ageLoe != null) {
            return new BooleanBuilder(member.age.loe(ageLoe));
        }
        return new BooleanBuilder();
    }
}

댓글