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;
  • select와 from
    • 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();
              
          • 장점: 컴파일러 타입 체크가 가능해 가장 안전
          • 단점
            • DTO가 QueryDSL 애노테이션을 의존
            • 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
    • 이 경우, MemberRepositoryImpl은 MemberRepositoryCustomImpl 도 가능 (권장)
    • 순서
      • 사용자 정의 인터페이스 작성
          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