관리 메뉴

나구리의 개발공부기록

스프링 데이터 JPA가 제공하는 Querydsl기능, 인터페이스 지원 - QuerydslPredicateExecutor, Querydsl Web 지원, 리포지토리 지원 - QuerydslRepositorySupport, Querydsl 지원 클래스 직접 만들기 본문

인프런 - 스프링부트와 JPA실무 로드맵/실전! Querydsl

스프링 데이터 JPA가 제공하는 Querydsl기능, 인터페이스 지원 - QuerydslPredicateExecutor, Querydsl Web 지원, 리포지토리 지원 - QuerydslRepositorySupport, Querydsl 지원 클래스 직접 만들기

소소한나구리 2024. 10. 31. 15:05

출처 : 인프런 - 실전! Querydsl (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


** 참고

  • 여기서 소개하는 기능은 제약이 커서 복잡한 실무 환경에서 사용하기에는 많이 부족함
  • 스프링 데이터에서 제공하는 기능이기에 간단히 소개하고 왜 부족한지에 대해서 초점을 맞춰 설명

1. 인터페이스 지원 - QuerydslPredicateExecutor

1) 인터페이스 내부 구조

  • 해당 인터페이스에 선언된 메서드들을 사용할 수 있으며 직접 들어가서 살펴보면 동일한 이름으로 오버로딩된 메서드들이 더 있음
  • Pageable, Sort기능도 지원함
  • 공식 문서
public interface QuerydslPredicateExecutor<T> {
	Optional<T> findOne(Predicate predicate);
	Iterable<T> findAll(Predicate predicate);
	long count(Predicate predicate);
	boolean exists(Predicate predicate);
	<S extends T, R> R findBy(Predicate predicate, Function<FluentQuery.FetchableFluentQuery<S>, R> queryFunction);
	
    // ... 등등
}

2) 예제 코드

(1) 리포지토리에 적용

  • 스프링 데이터 JPA를 사용하는 리포지토리에 QuerydslPredicateExecutor<엔터티>를 상속
public interface MemberRepository extends JpaRepository<Member, Long>,
                                            MemberRepositoryCustom,
                                            QuerydslPredicateExecutor<Member> {

    List<Member> findByUsername(String username);
}

 

(2) 테스트

  • findAll메서드의 인자에 조건을 바로 입력하여 결과를 조회할 수 있으며 실행해보면 쿼리도, 반환값도 정상적으로 출력됨
  • 단순히 메서드만 호출해서 인수로 검색 조건을 입력할 수 있어서 편하다고 느낄 수 있지만 한계가 분명함
// QuerydslPredicateExecutor Test
@Test
void querydslPredicateExecutorTest() {
    // ... 초기화 코드 생략

    Iterable<Member> result = memberRepository
            .findAll(member.age.between(10, 30)
            .and(member.username.eq("member1")));

    for (Member member : result) {
        System.out.println("member = " + member);
    }
}

 

(3) 한계점

  • 묵시적 조인은 가능하지만 left join이 불가능하기에 관계형 데이터베이스를 주로 사용하는 실무에서는 치명적인 단점일 수 있음
  • 클라이언트, 서비스에서 Querydsl에 의존할 수밖에 없어 Querydsl을 다른기술로 바꾸게 되면 많은 코드가 바뀌어야함
  • 특히 실무환경은 상당히 복잡하기 때문에 유연하지 않은 기술을 실무에서 사용하기에 쉽지 않음

2. Querydsl Web지원

1) 설명

(1) 공식문서 예제

  • 공식 문서
  • Controller의 파라미터로 Predicate를 받으면 쿼리 파라미터로 전송된 값들을 컨트롤러의 코드에서 리포지토리의 메서드를 호출할 때 인수의 값으로 입력하여 사용할 수 있음
@Controller
class UserController {

  @Autowired UserRepository repository;

  @RequestMapping(value = "/", method = RequestMethod.GET)
  String index(Model model, @QuerydslPredicate(root = User.class) Predicate predicate,    
          Pageable pageable, @RequestParam MultiValueMap<String, String> parameters) {

    model.addAttribute("users", repository.findAll(predicate, pageable));

    return "index";
  }
}

 

(2) 한계점

  • equals, contains, in을 공식 지원하며 사실상 정상적으로 사용가능한 기능은 equals 정도만 원활하게 사용할 수 있으며, 실무에서 사용 가능한 경우가 거의 없음
  • 조건을 커스텀하는 기능이 너무 복잡하고 명시적이지 않음
  • 컨트롤러가 Querydsl에 의존하게되어 계층구조가 무너짐
  • 마찬가지로 복잡한 실무환경에서는 사용하기가 한계가 너무 명확하기에 사용하지 말 것을 권장하며 실습도 생략

3. 리포지토리 지원 - QuerydslRepositorySupport

1) 설명

  • 공식 메뉴얼에는 나와있지 않으며 특별한 기능을 제공함
  • 추상클래스여서 상속받아서 해당 클래스에 선언된 기능들을 사용할 수 있으며 EntityManager도 자동으로 주입을 받고 Querydsl이라는 클래스가 선언되어있어 해당 기능들도 사용 가능함

2) 적용

(1) 사용자 정의 리포지토리에 상속

  • 사용자 정의 리포지토리에 QuerydslRepositorySupport 클래스를 상속받고 생성자에서 super 연산자로 조상클래스에 엔터티를 넘겨주면 됨
  • 생성자의 코드가 깔끔해짐
public class MemberRepositoryCustomImpl extends QuerydslRepositorySupport implements MemberRepositoryCustom {

    public MemberRepositoryCustomImpl() {
        super(Member.class);
    }
    
    // ... 기존코드 생략
}

 

(2) 기존 메서드를 수정하여 적용해보기

  • 기존에 테스트로 작성했던 searchPageComplex메서드를 수정
  • 기존에 offset, limit 정보를 쿼리에서 직접 작성해야 했던 것을 getQuerydsl().applyPagination()으로 페이지 정보와 쿼리를 입력하면 자동으로 offset과 limit 정보를 입력해서 반환해줌
  • 반환된 결과를 fetch()로 쿼리의 결과를 반환하면 됨
  • 특이한점은 쿼리가 from부터 시작하여 가독성이 좋지 않음(대부분의 개발자는 SQL 문법에 익숙하기 때문)
// QuerydslRepositorySupport를 사용하여 메서드를 작성
public Page<MemberTeamDto> searchQuerydslRepositorySupport(MemberSearchCondition condition, Pageable pageable) {
    JPQLQuery<MemberTeamDto> querydsl = from(member)
            .leftJoin(member.team, team)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            )
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name));

    JPQLQuery<MemberTeamDto> query = getQuerydsl().applyPagination(pageable, querydsl);
    List<MemberTeamDto> result = query.fetch();

    return getPage(result, pageable,
            () -> queryFactory
                    .select(member.count())
                    .from(member)
                    .leftJoin(member.team, team)
                    .where(
                            usernameEq(condition.getUsername()),
                            teamNameEq(condition.getTeamName()),
                            ageGoe(condition.getAgeGoe()),
                            ageLoe(condition.getAgeLoe())
                    ).fetchOne());
}

 

(3) 한계

  • Querydsl 3.x 버전을 기반으로 동작하기에 from()부터 쿼리를 작성해야하며 Querydsl 4.x에 나온 JPAQueryFactory를 사용해야 select부터 시작할 수 있음
  • 즉, 쿼리를 가독성을 좋게 하기 위하여 JPAQueryFactory를 사용하려면 생성자에서 EntityMaganer를 파라미터로 받아서 JPAQueryFactory의 객체를 생성해야 하는데, 생성자의 코드가 깔끔해졌던 장점이 사라지게됨
  • 그리고 스프링 데이터의 Sort 기능이 버그로인하여 정상적으로 동작하지 않기에 페이징 기능만 사용할 수 있어 실무에서 사용하기에 불안요소가 존재함
// JPAQueryFactory를 사용하기위해서는 이렇게 생성자를 생성해야하는데, 오히려 더 지저분해짐
private final JPAQueryFactory queryFactory;

public MemberRepositoryCustomImpl(EntityManager em) {
    super(Member.class);
    queryFactory = new JPAQueryFactory(em);
}

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

** 참고

  • QuerydslRepositorySupport가 지닌 한계를 극복하기 위해 직접 Querydsl 지원 클래스를 작성
  • 직접 구현해보면서 라이브러리를 사용하지 않아도 유틸리티 클래스나 지원 클래스를 직접 만들어서 적용하는 과정을 보고 실제 프로젝트에서 어떻게 코드를 줄일 수 있을지에 대한 관점으로 해당 과정을 진행

1) 코드

(1) 구현 항목

  • 스프링 데이터가 제공하는 페이징을 편리하게 변환
  • 페이징과 카운트 쿼리 분리
  • 스프링 데이터 Sort 지원
  • select(), selectFrom()으로 시작가능
  • EntityManager, QueryFactory제공

(2) 클래스 생성

  • 추상클래스로 생성하여 Querydsl을 사용할 때 필요한 정보를 모두 집어넣은 집약체 클래스
  • 강의에서는 applyPagination()에 Deprecated된 fetchCount()를 사용하여 전체 카운트 쿼리를 가져오도록 작성되어있어 하나는 삭제하고 하나는 countQuery를 fetchOne으로 실행되도록 수정함
  • @Autowired로 주입하고있는 setentityManager()의 메서드에서 동적 Sort 버그가 발생하는 원인을 해결하는 코드가 들어있음
/**
 * Querydsl 4.x 버전에 맞춘 Querydsl 지원 라이브러리 *
 *
 * @author Younghan Kim
 * @see org.springframework.data.jpa.repository.support.QuerydslRepositorySupport
 */
@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,
                                          Function<JPAQueryFactory, JPAQuery> countQuery) {
        JPAQuery jpaContentQuery = contentQuery.apply(getQueryFactory());
        List<T> content = getQuerydsl().applyPagination(pageable, jpaContentQuery).fetch();
        JPAQuery<Long> countResult = countQuery.apply(getQueryFactory());
        return PageableExecutionUtils.getPage(content, pageable, countResult::fetchOne);
    }
}

 

(3) 구현된 지원클래스를 사용

  • Querydsl4RepositorySuppoert를 상속받는 클래스를 만들어서 해당 메소드들을 사용하면 됨
  • 상속받은 Support클래스의 applyPagination와 동일한 기능을 하도록 직접 구현한 searchPageByApplyPage()와 applyPagination을 사용한 applyPagination()의 쿼리코드를 보면 직접 구현한 것보다 파라미터로 쿼리만 넘길 수 있도록 상속받을 메서드를 사용하는 것이 조금 더 가독성이 좋은 것을 볼 수 있음
  • 이렇게 미리 가독성이 좋아보이게하거나, 코드를 줄일 수 있는 용도로 기존 라이브러리의 기능을 수정하거나 새롭게 정의하여 작성하여 실제 프로젝트에 사용할 수도 있음
package study.querydsl.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> searchPageByApplyPage(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,
                () -> Optional.ofNullable(
                                select(member.count())
                                .from(member)
                                .leftJoin(member.team, team)
                                .where(
                                        usernameEq(condition.getUsername()),
                                        teamNameEq(condition.getTeamName()),
                                        ageGoe(condition.getAgeGoe()),
                                        ageLoe(condition.getAgeLoe())
                                ).fetchOne()).orElse(0L));
    }
    
    // 위와 동일한 코드, applyPagination()의 인수로 페이지 정보와 contentQuery, countQuery를 람다식으로 넘겨서 계산
    public Page<Member> applyPagination(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(member.count())
                        .from(member)
                        .leftJoin(member.team, team)
                        .where(usernameEq(condition.getUsername()),
                            teamNameEq(condition.getTeamName()),
                            ageGoe(condition.getAgeGoe()),
                            ageLoe(condition.getAgeLoe())));

    }

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

 

(4) Test 

  • 교재에는 Test가 없지만 직접 테스트 케이스를 만들어서 실행해보면 두개의 메서드가 모두 동일하게 정상 동작하고, 출력된 결과와 쿼리가 모두 동일하게 동작하는 것을 알 수 있음
// Querydsl4RepositorySupport 메소드 테스트
@Test
void applyPagination() {
    Team teamA = new Team("teamA");
    Team teamB = new Team("teamB");

    em.persist(teamA);
    em.persist(teamB);

    for (int i = 0; i < 100; i++) {
        Team selectedTeam = i % 2 == 0 ? teamA : teamB;
        em.persist(new Member("member" + i, i, selectedTeam));
    }

    MemberSearchCondition condition = new MemberSearchCondition();      // 예제이므로 조건은 입력 X
    condition.setAgeGoe(30);
    condition.setAgeLoe(45);

    PageRequest pageRequest = PageRequest.of(1, 5); // 페이지 정보만 생성
    Page<Member> members1 = memberTestRepository.applyPagination(condition, pageRequest);
    assertThat(members1.getSize()).isEqualTo(5);
    int i = 0;
    for (Member member : members1) {
        System.out.println("member = " + member);
        assertThat(member.getAge()).isEqualTo(35+i);
        i++;
    }

    Page<Member> members2 = memberTestRepository.searchPageByApplyPage(condition, pageRequest);
    for (Member member : members1) {
        System.out.println("member = " + member);
    }
}

/* 실행 결과 일부
applyPagination 실행 결과 로그
where member1.age >= 301 and member1.age <= 452 */ select count(m1_0.member_id) from member m1_0 where m1_0.age>=NULL and m1_0.age<=?;
member = Member(id=36, username=member35, age=35)
member = Member(id=37, username=member36, age=36)
member = Member(id=38, username=member37, age=37)
member = Member(id=39, username=member38, age=38)
member = Member(id=40, username=member39, age=39)

searchPageByApplyPage 실행 결과 로그
where member1.age >= 301 and member1.age <= 452 */ select count(m1_0.member_id) from member m1_0 where m1_0.age>=NULL and m1_0.age<=?;
member = Member(id=36, username=member35, age=35)
member = Member(id=37, username=member36, age=36)
member = Member(id=38, username=member37, age=37)
member = Member(id=39, username=member38, age=38)
member = Member(id=40, username=member39, age=39)
*/