[Querydsl] QuerydslPredicateExecutor 와 Querydsl 올바른 사용법

Coodori·2023년 4월 4일
4

CherishU

목록 보기
21/29

문제 발생

팀원에게 코드리뷰를 하던 도중
평소에 나는 리포지토리 인터페이스를 작성할때 Jpa RepostioryCustomRepository(Querydsl)을 사용하기위해 두가지를 상속 받았다.

하지만 처음보는

public interface ItemFilterRepository extends JpaRepository<ItemFilter, Long>, ItemFilterRepositoryCustom, QuerydslPredicateExecutor<ItemFilter> {

QuerydslPredicateExecutor 를 발견하게되었고 팀원분께 물어보니
자신이 배운 강의에서 자주 상속을 받길래 습관적으로 확장성을 주었다 라는 찾아보고 싶은 매우 궁금한 답변을 듣게 되었다.

그래서 한번 해당 QuerydslPredicateExecutor에 대해서 조사를 해보기 시작했다.

QuerydslPredicateExecutor

일단, Spring Data Jpa에 Querydsl을 사용하기 위해 제공되는 기능이다.
과거에 조회 쿼리 로직 클래스에 Querydsl을 사용하기 위해implement QuerydslReositorySupport를 쓰던 시절의 관행과 비슷하게 interface인 repository에서 바로 querydsl을 사용하려고 썼던것 같다.

QuerydslReositorySupport 인터페이스는
1. Querydsl 3.x 버전을 대상으로 만듬
2. Querydsl 4.x에 나온 JPAQueryFactory로 시작할 수 없음
3. select로 시작할 수 없음 (from으로 시작해야함)
4. QueryFactory 를 제공하지 않음
5. 스프링 데이터 Sort 기능이 정상 동작하지 않음 -> 큐 소트를 사용하면 되긴함. (소팅을 직접해줘야 된다는 의미)
6. 메서드 체인이 끊깁니다.
라는 단점이 있다.

Querydsl 사용 방법

중요한건 두개 다 동적으로 해당 값을 처리한다는 것이다

현재는 총 3가지의 방법으로 querydsl 을 구현 가능하다.
1. CustomRepository 상속
2. QuerydslReositorySupport 상속
3. JpaQueryFactory 주입해서 단독 사용(해당 추천)

사실 3번이 가장 베스트프랙티스인 이유는 상속을 매번 받는 것도 불편하고 실질적으로 작업하는 JpaQueryFactort만 존재하면 되기 때문이다.

물론 하나의 리포지토리로 통합 관리를 하고 싶으면 1번 방법을 사용하면 된다.

public interface ItemFilterRepository extends JpaRepository<ItemFilter, Long>, ItemFilterRepositoryCustom, QuerydslPredicateExecutor<ItemFilter> {

    @Query("select i from ItemFilter i join i.filter f where i.name = :name and f.id = :filterId")
    List<ItemFilter> findItemFilterByNameAndFilterId(@Param("name") String name, @Param("filterId") Long filterId);
}
  • 전에 사용하던 1번 방식

  • 개선된 3번 방식(상속을 지웠으니 @Overrride도 지워야한다.)

QuerydslPredicateExecutor

일단 기본적으로 제약이 커서 실무 환경에서 사용하는 것이 부족하다고 한다.
공식 문서
1. 간단한 곳에서 사용이 가능하다.
2. 조인이 불가능하다.
3. 클라이언트가 Querydsl 에 의존해야한다.

  • repository를 만드는 이유는 하부 기술을 숨기려고 하는 것인데 service와 controller가 의존한다.
  1. 순수한 자바객체가 아닌 Querydsl 객체가 넘어간다.
    Predicate와 함께 사용이 되며 제공되는 findOne() findAll() 등 동적으로 조건문을 적어주면된다.

    현재 내가 알고있는 방식

    여기서 의문점이 든다.
    그럼 위의 3번 방식인 JpaQueryFactory 주입해서 단독 사용하고 조건문을 동적으로 만들어 주는 것과 크게 다른 바가 없다.

    그리고 심지어 인터페이스에 상속을 하고 커스텀 역시 상속을 하니 같은 실행문을 두번 상속하는 느낌이 든다.

    베스트 프랙티스

    동적 쿼리는
    1.Predicate 를 파라미터로 이용하는 방법
    2.BooleanBuidler 를 이용하는 방법
    3.Predicate를 상속받은 BooleanExpression을 쓰는 방법

    일단 2번 부터 설명을 하자면
    BooleanBuilder는 하나의 메소드 안에서 필요한 조건문들을 이어 붙이는 것이다.

    public List<MemberTeamDto> searchByBuilder(MemberSearchCondition condition){
       BooleanBuilder builder = new BooleanBuilder();
    
       if (hasText(condition.getUsername())) {
           builder.and(member.username.eq(condition.getUsername()));
       }
    
       if(hasText(condition.getTeamName())){
           builder.and(team.name.eq(condition.getTeamName()));
       }
    
       if(condition.getAgeGoe() != null) {
           builder.and(member.age.goe(condition.getAgeGoe()));
       }
    
       if(condition.getAgeLoe() != null){
           builder.and(member.age.loe(condition.getAgeLoe()));
       }
    
       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(builder)
               .fetch();
    }

    보는 것 처럼 직관적이지 않고 쿼리가 어떻게 나가는지 모른다.
    그래서 개선된 3번의 방법을 사용하면

    
    public List<MemberTeamDto> searchByWhere(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));
    }

메소드는 많아졌지만 해당 조건문이 어떤 쿼리문을 만들지 직관적이게 되었다.

그렇다면 1번과 3번의 차이점은?

3번은 메소드 형태로 제공이 되기 때문에 추후 다른 where()문을 구성할때 재사용을 할 수 있다. 그리고 BooleanExpression은 null을 반환하게 되면 Where절에서 조건이 무시되고 안전하다.
물론 Predicate를 상속받았으므로 사용 하는 방법은 일치하다.

결론

1.독립된 QuerydslRepository를 만드는 것이 좋다.

  • QuerydslPredicateExecutor 상속 X

2.동적 쿼리문을 짤때는 Predicate 보단 BooleanExpression 을 사용하여 안전하게 where 문을 넣자.

추가로 해당 Querydsl에 찾아보며 알게된 사안(우아한 테크 콘서트)

https://www.youtube.com/watch?v=zMAX7g6rO_Y

1. 조회시는 Dto, 변경이 필요하면 Entity

  • 대량의 데이터를 조회할 시 필요없는 컬럼까지 가져올 필요는 없다.
  • 하지만 변경을 해야할시 영속성컨텍스트에서 해당 객체를 관리해야하니 Entity를 가져온다.

Querydsl 생성자 주입

  1. Setter(변경점 관리에 취약해서 추천X), Reflection(기본 생성자 필요) 이용
public void findDtoBySetter() {
    List<MemberDto> members = queryFactory
            .select(Projections.bean(MemberDto.class,
                member.name,
                member.age
            ))
            .from(member)
            .fetch();
}
  1. 생성자를 이용(바인딩 과정에서 문제가 생길 수 있음)
  • 순서, 타입 등
  • 생성자를 이용하는 방법이라 컴파일 당시에는 오류를 검출 못함, 실행해야 알 수 있다.
public void findDtoByConstructor() {
    List<MemberDto> members = queryFactory
            .select(Projections.constructor(MemberDto.class,
                member.name,
                member.age
            ))
            .from(member)
            .fetch();
}
  1. @QueryProjection
public class MemberDto {
    private String name;
    private int age;

    @QueryProjection // 어노테이션 추가
    public MemberDto(String name, int age) {
        this.name = name;
        this.age = age;
    }
}


public void findDtoByQueryProjection() {
	queryFactory
		.select(new QMemberDto(member.name, member.age))
		.from(member)
		.fetch();
}

QDto로 생성된 생성자를 사용하는 방법
런타임 에러와 컴파일 에러 모두 잡을 수 있다.
하지만 DTO 특성상 전 계층(Service, Controller)에 데이터를 전달하기 위한 객체인데 모든 사용에서 Querydsl 의존성을 가지게 된다.

결론

처음 프로젝트에서는 2번 방법을 사용했지만 현재 컴파일 오류 라는 장점을 너무 강력하기 때문에 3번 방법을 사용하는 것이 좋을 듯하다.

Group BY 최적화하기

MySQL에서 Group BY를 실행하면 index가 없을 시 Filesort라는 정렬 알고리즘이 수행된다.

그래서 상대적으로 더 느려지므로 이 경우를 막으려면 order by null을 써야한다.

Querydsl은 order by null을 지원하지 않아서 클래스를 만들어서 OrderByNull을 지원하도록해야한다.

public class OrderByNull extends OrderSpecifier {

    public static final OrderByNull DEFAULT = new OrderByNull();

    private OrderByNull(){
        super(Order.ASC, NullExpression.DEFAULT, NullHandling.Default);
    }
}
# Querydsl
public List<Integer> useOrderByNull() {
   		...
        .orderBy(OrderByNull.DEFAULT)
        .fetch();
}

OrderByNull은 우리가 한번 정제한 쿼리이기 때문에 일대다 fetch join 처럼 페이징 쿼리 는 사용하지 못한다.

데이터가 100건 이하라면 DB(2~3대)보다는 WAS(수십대)의 자원이 더 저렴하기 때문에 애플리케이션에서 정렬해주는 것이 좋다.

페이징 성능 개선을 위한 No Offset

offset을 이용하는 것은 offset + limit 만큼의 데이터를 읽어야 하기 때문에 뒤로가면 갈수록 중복되게 조회하는 행들이 생기게된다.

앞에서 부터 몇번째까지 세어주고 다시 거기서부터 필요한 만큼 세어 준다.

그래서 No Offset을 이용하는 곳이다.

SELECT *
FROM items
WHERE 조건문
AND id < 마지막 조회 ID 
ORDER BY id desc 
LIMIT 페이지 사이즈 

이렇게 하면 앞쪽은 한번에 뛰어 넘을 수 있다.
사용은

public List<MemberDto> noOffset(Long lastMemberId, int limit){
    return queryFactory
        .select(new QMemberDto(
                member.username,
                member.age
        ))
        .from(member)
        .where(member.username.contains("member")
                .and(memberIdLt(lastMemberId)))
        .orderBy(member.id.desc())
        .limit(limit)
        .fetch();
}

private BooleanExpression memberIdLt(Long lastMemberId) {
    return lastMemberId != null ? member.id.lt(lastMemberId): null;
}

이렇게 한다.
이 코드는 다음 성능 최적화때 한번 활용해보면 좋을 듯 한 것 같고 처음 보는 좋은 성능 최적화여서 메모해두었다.

결론

다른 좋은 최적화도 있지만 이미 알고 있던 사안들과 겹쳐서 복습하며 읽어보았고 추후 적용하고 싶은 옳은 Querydsl 방법들을 정리해보았다.

기술 하나 하나가 알면 알수록 보이는 모습과 달라지는 것 같다.

REFERENCE

https://jjunn93.com/entry/QuerydslPredicateExecutor-querydsl-%EC%A1%B0%EA%B1%B4-%EC%A1%B0%ED%9A%8C-%EA%B0%84%EB%8B%A8%ED%9E%88-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0
https://velog.io/@youngerjesus/우아한-형제들의-Querydsl-활용법#2-동적쿼리는-booleanexpression-사용하기

profile
https://coodori.notion.site/0b6587977c104158be520995523b7640

2개의 댓글

comment-user-thumbnail
2023년 8월 7일

쏠로잉 개발공부 중인데 글이 이해가 잘 되도록 써주셔서 감명받기도 했구요,
알찬 내용에 정말 도움 많이 받고있습니다. 경험 공유 감사해요!
북마크에 넣어봅니다^^

1개의 답글