Home > Java-Ecosystem > JPA > QueryDSL Dive

QueryDSL Dive
JPA QueryDSL

QueryDSL

  • 소개
    • JPQL 빌더
    • 장점
      • 문자인 JPQL을 코드로 작성해 컴파일 오류 발생 가능
      • JPQL과 달리 파라미터 바인딩을 자동 처리
  • 라이브러리 종류
    • querydsl-apt: Querydsl 관련 코드 생성 기능 제공 (Q 클래스 빌드)
    • querydsl-jpa: Querydsl 라이브러리

기본 문법

  • JPAQueryFactory
    • 쿼리 작성의 기본 토대
    • EntityManager를 전달해 생성
      • JPAQueryFactory queryFactory = new JPAQueryFactory(em);
    • 필드에 두어도 동시성 문제 걱정 없음
      • EntityManager가 동시성 문제 걱정이 없기 때문에 마찬가지다
  • Q-Type
    • 전략
      • 기본 인스턴스 사용 방법을 static import해 사용하자
      • 같은 테이블을 조인해야 하는 경우에만 별칭 직접 지정 방법을 사용하자
    • Q 클래스 인스턴스 사용 방법 2가지
      • 방법 1: 별칭 직접 지정
        • QMember qMember = new QMember("m");
        • 별칭 = JPQL 별칭
          • e.g. “select m from Member m” - m이 별칭
        • 같은 테이블을 조인해야할 때만 사용 (다른 때는 쓸 일 없음)
      • 방법 2: 기본 인스턴스 사용
        • QMember qMember = QMember.member;
  • selectfrom
    • select, from
    • selectFrom (축약 버전)
  • 검색 조건 쿼리 (where)
    • AND, OR 조건
      • where 조건에 ,로 파라미터를 추가하면 AND 조건 형성
        • -> null 값은 무시 -> 메서드 추출을 활용해 깔끔한 동적 쿼리 작성 가능
        • e.g. .where(member.username.eq("member1"), member.age.eq(10))
      • .and(), .or()메서드 체이닝 가능
    • 검색 조건 예시
        member.username.eq("member1") // username = 'member1'
        member.username.ne("member1") //username != 'member1'
        member.username.eq("member1").not() // username != 'member1'
        member.username.isNotNull() //이름이 is not null
        member.age.in(10, 20) // age in (10,20)
        member.age.notIn(10, 20) // age not in (10, 20)
        member.age.between(10,30) //between 10, 30
        member.age.goe(30) // age >= 30
        member.age.gt(30) // age > 30
        member.age.loe(30) // age <= 30
        member.age.lt(30) // age < 30
        member.username.like("member%") //like 검색 
        member.username.contains("member") // like ‘%member%’ 검색
        member.username.startsWith("member") //like ‘member%’ 검색
      
  • 결과 조회
    • fetch() : 리스트 조회
      • 데이터 없으면 : 빈 리스트
    • fetchOne() : 단건 조회
      • 결과가 없으면 : null
      • 결과가 둘 이상이면 : com.querydsl.core.NonUniqueResultException
    • fetchFirst() : 처음 한 건 조회 (= limit(1).fetchOne())
    • fetchResults() -> deprecated : 페이징 정보 포함 + total count 쿼리 추가 실행
    • fetchCount() -> deprecated : count 쿼리로 변경해서 count 수 조회
  • 정렬 (orderBy)
    • desc(), asc() : 일반 정렬
    • nullsLast(), nullsFirst() : null 데이터 순서 부여
    • 사용 예시
      • .orderBy(member.age.desc(), member.username.asc().nullsLast())
      • 1번 순서: 회원 나이 내림차순(desc)
      • 2번 순서: 회원 이름 올림차순(asc)
        • 단, 2에서 회원 이름이 없으면 마지막에 출력(nulls last)
    • 참고: 스프링 데이터 JPA의 Sort 객체를 함께 사용할 수 있을까?
      • 스프링 데이터 JPA는 Sort를 QueryDSL의 OrderSpecifier로 변경하는 기능 제공
      • 다만, 정렬은 조금만 복잡해도 Sort 기능 사용이 어려우므로 파라미터로 받아 직접 처리 권장
  • 페이징
    • SQL 오프셋, 리미트: offset, limit
    • 스프링 부트 3.x(2.6 이상) 유의점 (QueryDSL 5.0)
      • PageableExecutionUtils 패키지 변경
        • 신규: org.springframework.data.support.PageableExecutionUtils
      • fetchResults() , fetchCount() => Deprecated
    • fetchCount() 대체 사용 예제 - count 쿼리 예제 (fetchOne())
        Long totalCount = queryFactory
                //.select(Wildcard.count) //select count(*)
                .select(member.count()) //select count(member.id)
                .from(member)
                .fetchOne();
      
    • fetchResults() 대체 사용 예제
        import org.springframework.data.support.PageableExecutionUtils; //패키지 변경
      		
        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<Long> countQuery = queryFactory
                    .select(member.count()) //count 쿼리
                    .from(member)
                    .leftJoin(member.team, team)
                    .where(
                            usernameEq(condition.getUsername()),
                            teamNameEq(condition.getTeamName()),
                            ageGoe(condition.getAgeGoe()),
                            ageLoe(condition.getAgeLoe())
            // ).fetchOne();
            );
      		    
            // return new PageImpl<>(content, pageable, total);
            return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); //fetchOne() 사용
        }
      
      • 두 쿼리각각 메서드로 추출해도 좋음!
      • 반환 전략 (스프링 데이터와 함께 사용하기)
        • 기본: Page 구현체 반환 (PageImpl)
          • e.g. return new PageImpl<>(content, pageable, total);
        • CountQuery 최적화 (PageableExecutionUtils 사용)
          • count 쿼리가 생략 가능한 경우 생략해서 처리 (스프링 데이터 라이브러리 제공)
            • 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
            • 마지막 페이지이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
  • 집합
    • 집합 함수
        List<Tuple> result = queryFactory
                .select(member.count(),
                        member.age.sum(),
                        member.age.avg(),
                        member.age.max(),
                        member.age.min())
                .from(member)
                .fetch();
      		
        Tuple tuple = result.get(0);
        tuple.get(member.count()); //회원수
        tuple.get(member.age.sum()); //나이 합
        tuple.get(member.age.avg()); //평균 나이
        tuple.get(member.age.max());
        tuple.get(member.age.min());
      
    • groupBy(), having()
        .groupBy(item.price)
        .having(item.price.gt(1000))
      
  • 조인
    • 기본 조인
      • 연관관계로 조인
      • 문법: join(조인 대상, 별칭으로 사용할 Q타입)
        • e.g.
            queryFactory
                .selectFrom(member)
                .join(member.team, team)
                .where(team.name.eq("teamA"))
                .fetch();
          
      • 종류: join(), innerJoin(), leftJoin(), rightJoin()
    • 세타 조인
      • 연관관계가 없는 필드로 조인
        • e.g.
            queryFactory
                .select(member)
                .from(member, team)
                .where(member.username.eq(team.name))
                .fetch();
          
      • 원리
        • 카타시안 조인을 해버린 후 where절로 필터링 (cross join 후 where 필터링)
        • DB가 성능 최적화
      • 단점: 외부조인이 불가능하므로 외부조인 필요시 on 절을 사용해야 함
    • on 절 활용 조인
      • 조인 대상 필터링
        • 외부조인필터링이 필요한 경우에만 사용하자 (내부 조인이면 where 절로 해결)
          • 결과적으로 left join에만 on 절 활용이 의미있는 결과를 만듦
          • 내부조인(inner join)을 사용하면, where 절에서 필터링하는 것과 기능이 동일
        • e.g.
            queryFactory
                .select(member, team)
                .from(member)
                .leftJoin(member.team, team)
                .on(team.name.eq("teamA"))
                .fetch();
          
      • 연관관계 없는 엔터티 외부 조인 - 보통 이 이유로 많이 쓰임
        • 문법 차이: leftJoin() 부분에 일반 조인과 다르게 엔티티 하나만 들어감
          • 일반조인: leftJoin(member.team, team) - SQL on절에 id값 매칭 O
          • on조인: from(member).leftJoin(team).on(xxx) - SQL on절 id값 매칭 X
        • 참고) 내부조인도 가능
        • e.g.
            queryFactory
                .select(member, team)
                .from(member)
                .leftJoin(team)
                .on(member.username.eq(team.name))
                .fetch();
          
  • 페치 조인 (fetchJoin())
    • join(), leftJoin()조인 기능 뒤에 fetchJoin() 추가
    • e.g.
        queryFactory
            .selectFrom(member)
            .join(member.team, team).fetchJoin()
            .where(member.username.eq("member1"))
            .fetchOne();
      
  • distinct
    • select 절 뒤에 distinct() 추가 (JPQL distinct와 동일)
    • e.g.
        queryFactory
            .select(member.username).distinct()
            .from(member)
            .fetch();
      
  • 서브 쿼리 (JPAExpressions) - static import 활용하면 코드가 더욱 깔끔해짐
    • 서브쿼리 지원
      • where 절 서브 쿼리 지원
      • select 절 서브 쿼리 지원 (하이버네이트 사용 시 지원)
      • from 절 서브 쿼리(인라인 뷰) 지원 X (JPA, JPQL이 지원 X)
        • 해결책
          1. 서브 쿼리를 join으로 변경하기 (높은 확률로 가능)
          2. 애플리케이션에서 쿼리를 2번 분리해서 실행하기
          3. nativeSQL을 사용하기
    • e.g.
        queryFactory
            .selectFrom(member)
            .where(member.age.eq(
                    JPAExpressions
                        .select(memberSub.age.max())
                        .from(memberSub)
            ))
            .fetch();
      
  • 기타
    • Case 문 (거의 사용 X)
      • select 절, 조건절(where), order by에서 사용 가능
      • e.g. 단순한 조건
          select(member.age
                 .when(10).then("열살")
                 .when(20).then("스무살")
                 .otherwise("기타"))
        
      • e.g. 복잡한 조건
          select(new CaseBuilder()
                 .when(member.age.between(0, 20)).then("0~20살")
                 .when(member.age.between(21, 30)).then("21~30살")
                 .otherwise("기타"))
        
      • e.g. 임의의 순서로 출력하기
          NumberExpression<Integer> rankPath = new CaseBuilder()
                  .when(member.age.between(0, 20)).then(2)
                  .when(member.age.between(21, 30)).then(1)
                  .otherwise(3);
        			
          List<Tuple> result = queryFactory
              .select(member.username, member.age, rankPath)
              .from(member)
              .orderBy(rankPath.desc())
              .fetch();
        
    • 상수 (거의 사용 X)
      • Expressions.constant(xxx) 사용
      • e.g. select(member.username, Expressions.constant("A"))
    • 문자 더하기 (concat)
      • e.g. select(member.username.concat("_").concat(member.age.stringValue()))
      • 참고: 문자가 아닌 타입들은 stringValue()문자 변환 가능 (ENUM 처리에도 자주 사용)

복잡한 쿼리에 대한 제언

SQL이 화면을 맞추기위해 너무 복잡할 필요는 없다. (from… from… from…)
따라서, DB는 데이터를 퍼올리는 용도로만 사용하자. (필터링, 그룹핑 등 데이터를 최소화해 가져오는 역할)
그리고 뷰 로직은 애플리케이션의 프레젠테이션 계층에서 처리하자.

결과적으로, 서브 쿼리와 복잡한 쿼리가 감소할 것이다.

중급 문법

  • 프로젝션 (select 대상 지정)
    • 프로젝션 대상이 하나
      • 타입을 명확하게 지정
      • e.g. select(member.username)
    • 프로젝션 대상이 둘 이상
      • 튜플 조회 (Tuple)
          List<Tuple> result = queryFactory
                  .select(member.username, member.age)
                  .from(member)
                  .fetch();
        			        
          for (Tuple tuple : result) {
              String username = tuple.get(member.username);
              Integer age = tuple.get(member.age);
        
      • DTO 조회 (4가지 방법) => 실용적 관점에서는 @QueryProjection이 편리하나 답은 없음
        • 프로퍼티 접근 (Setter)
          • 이름(별칭)을 보고 매칭
          • e.g. Projections.bean()
              select(Projections.bean(MemberDto.class, 
                  member.username,
                  member.age)
              )
            
        • 필드 직접 접근
          • getter, setter는 무시하고 리플렉션 등의 방법으로 필드에 직접 값을 꽂음
          • 이름(별칭)을 보고 매칭
          • e.g. Projections.fields()
              select(Projections.fields(MemberDto.class, 
                  member.username, 
                  member.age)
              )
            
        • 생성자 사용
          • 타입을 보고 매칭
          • e.g. Projections.constructor()
              select(Projections.constructor(MemberDto.class,
                  member.username,
                  member.age)
              )
            
        • @QueryProjection (생성자 활용)
          • 사용법
            • DTO 설정
                @Data
                public class MemberDto {
              						
                private String username;
                    private int age;
              							
                    public MemberDto() {}
              							
                    @QueryProjection
                    public MemberDto(String username, int age) {
                        this.username = username;
                        this.age = age;
                    }
              						
                }
              
              • 빌드 후 DTO의 Q 클래스 생성 확인
            • 사용
                List<MemberDto> result = queryFactory
                    .select(new QMemberDto(member.username, member.age))
                    .from(member)
                    .fetch();
              
          • 장점: 컴파일러 타입 체크가 가능해 가장 안전
          • 단점
            • DTOQueryDSL 애노테이션의존
            • DTO까지 Q 파일을 생성해야 함
        • 유의점: 프로퍼티 or 필드 직접 접근 방식에서 이름이 다를 때
          • Q 클래스의 필드 이름DTO의 필드 이름이 다르면 별칭으로 맞춰줘야 함
          • 별칭 적용 방법
            • ExpressionUtils.as(source,alias) : 필드서브 쿼리에 별칭 적용
            • username.as("memberName") : 필드에 별칭 적용
          • e.g.
              queryFactory
                  .select(Projections.fields(UserDto.class,
                      member.username.as("name"),
                      ExpressionUtils.as(
                          JPAExpressions
                              .select(memberSub.age.max())
                              .from(memberSub), "age")
                      )
                  ).from(member)
                  .fetch();
            
  • 동적 쿼리
    • BooleanBuilder
      • 사용 예시
          private List<Member> searchMember1(String usernameCond, Integer ageCond) {
              BooleanBuilder builder = new BooleanBuilder();
        			    
              if (usernameCond != null) {
                  builder.and(member.username.eq(usernameCond));
              }
              if (ageCond != null) {
                  builder.and(member.age.eq(ageCond));
              }
        			    
              return queryFactory
                      .selectFrom(member)
                      .where(builder)
                      .fetch();
          }
        
    • Where 다중 파라미터 사용 (권장, 가장 깔끔)
      • where 조건에 null 값은 무시
      • 검색조건의 반환결과는 Predicate보다 BooleanExpression 이 좋음 (and, or 조립 가능)
        • e.g. private BooleanExpression usernameEq(String usernameCond)
      • 장점
        • 메서드를 다른 쿼리에서도 재활용 가능
        • 쿼리 자체의 가독성 상승
        • 조합을 사용하면 반복적으로 쓰이는 코드를 묶어직관적인 코드재사용 가능
          • null 체크는 조금 더 신경써야함 (e.g. null.and(null))
          • e.g.1
            • 광고 상태를 나타내는 isServiceable() = isValid() + 날짜 IN
          • e.g.2
              private BooleanExpression allEq(String usernameCond, Integer ageCond) {
                  return usernameEq(usernameCond).and(ageEq(ageCond));
              }
            
      • 사용 예시
          private List<Member> searchMember(String usernameCond, Integer ageCond) {
              return queryFactory
                      .selectFrom(member)
                      .where(usernameEq(usernameCond), ageEq(ageCond))
                      .fetch();
          }
        			
          private BooleanExpression usernameEq(String usernameCond) {
              return usernameCond != null ? member.username.eq(usernameCond) : null;
          }
        			
          private BooleanExpression ageEq(Integer ageCond) {
              return ageCond != null ? member.age.eq(ageCond) : null;
          }
        
  • 수정 및 삭제 벌크 연산 (execute())
    • 유의점: JPQL과 마찬가지로 배치 쿼리 후에는 영속성 컨텍스트 초기화가 안전 (em.clear())
    • 대량 데이터 수정
      • 기본 수정
          long count = queryFactory
                   .update(member)
                   .set(member.username, "비회원")
                   .where(member.age.lt(28))
                   .execute();
        
      • 기존 숫자에 1 더하기 (빼고 싶을 때는 -1 전달)
          long count = queryFactory
                   .update(member)
                   .set(member.age, member.age.add(1))
                   .execute();
        
      • 곱하기: .multiply(x)
    • 대량 데이터 삭제
        long count = queryFactory
                 .delete(member)
                 .where(member.age.gt(18))
                 .execute();
      
  • SQL function 호출하기
    • JPA와 같이 Dialect에 등록된 내용만 호출 가능
      • e.g. “member”를 “M”으로 변경하는 replace 함수 사용
          String result = queryFactory
                  .select(Expressions.stringTemplate("function('replace', {0}, {1}, {2})", member.username, "member", "M"))
                  .from(member)
                  .fetchFirst();
        
    • ANSI 표준 함수들은 QueryDSL이 상당 부분 내장
      • e.g. lower()
        • .where(member.username.eq(member.username.lower()))

순수 JPA + QueryDSL 조합 활용

  • 기본 사용법: 동일 리포지토리 사용
      @Repository
      public class MemberJpaRepository {
    	    
          private final EntityManager em;
          private final JPAQueryFactory queryFactory;
    		
          public MemberJpaRepository(EntityManager em) {
              this.em = em;
              this.queryFactory = new JPAQueryFactory(em);
          }
          ...
      }
    
    • JPAQueryFactory도 스프링 빈으로 주입해 사용해도 된다 (선택 사항)
        @Bean
        JPAQueryFactory jpaQueryFactory(EntityManager em) {
            return new JPAQueryFactory(em);
        }
      

스프링 데이터 JPA와 QueryDSL

  • 기본 사용법: 스프링 데이터 JPA의 사용자 정의 리포지토리 기능 활용
    combination_of_spring_data_jpa_and_querydsl
    • 이 경우, MemberRepositoryImplMemberRepositoryCustomImpl 도 가능 (권장)
    • 순서
      • 사용자 정의 인터페이스 작성
          public interface MemberRepositoryCustom {
              List<MemberTeamDto> search(MemberSearchCondition condition);
          }
        
      • 사용자 정의 인터페이스 구현
          public class MemberRepositoryImpl implements MemberRepositoryCustom {
        			    
              private final JPAQueryFactory queryFactory;
        			    
              public MemberRepositoryImpl(EntityManager em) {
                  this.queryFactory = new JPAQueryFactory(em);
              }
        				
              @Override
              //회원명, 팀명, 나이(ageGoe, ageLoe)
              public List<MemberTeamDto> search(MemberSearchCondition condition) {
                  return queryFactory
                          .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);
              }
        				
          }
        
      • 스프링 데이터 리포지토리에 사용자 정의 인터페이스 상속
          public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
              List<Member> findByUsername(String username);
          }
        

기타 (실무 사용 어려움)

  • 인터페이스 지원 - QuerydslPredicateExecutor
    • QueryDSL의 Predicate을 파라미터로 넘길 수 있음
    • 단점
      • 클라이언트가 QueryDSL 의존
      • Left Join 불가
  • Querydsl Web 지원
    • 컨트롤러가 QueryDSL 의존
  • 리포지토리 지원 - QuerydslRepositorySupport
    • getQuerydsl().applyPagination()
      • 스프링 데이터가 제공하는 페이징을 Querydsl로 편리하게 변환 가능
    • 단점
      • QueryDSL 3.x 버전 대상
      • QueryFactory 를 제공하지 않음
      • Sort 기능이 정상 동작하지 않음
  • Querydsl 지원 클래스 직접 만들기

Reference

실전! Querydsl