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;
- 방법 1: 별칭 직접 지정
- 전략
-
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%’ 검색
- AND, OR 조건
-
결과 조회
-
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
기능 사용이 어려우므로 파라미터로 받아 직접 처리 권장
- 스프링 데이터 JPA는
-
- 페이징
- 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);
- e.g.
-
CountQuery 최적화 (
PageableExecutionUtils
사용)- count 쿼리가 생략 가능한 경우 생략해서 처리 (스프링 데이터 라이브러리 제공)
- 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
- 마지막 페이지이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
- count 쿼리가 생략 가능한 경우 생략해서 처리 (스프링 데이터 라이브러리 제공)
- 기본:
- SQL 오프셋, 리미트:
- 집합
- 집합 함수
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();
- e.g.
- 종류:
join()
,innerJoin()
,leftJoin()
,rightJoin()
- 세타 조인
-
연관관계가 없는 필드로 조인
- e.g.
queryFactory .select(member) .from(member, team) .where(member.username.eq(team.name)) .fetch();
- e.g.
- 원리
- 카타시안 조인을 해버린 후 where절로 필터링 (cross join 후 where 필터링)
- DB가 성능 최적화함
- 단점: 외부조인이 불가능하므로 외부조인 필요시 on 절을 사용해야 함
-
연관관계가 없는 필드로 조인
-
on
절 활용 조인- 조인 대상 필터링
-
외부조인에 필터링이 필요한 경우에만 사용하자 (내부 조인이면
where
절로 해결)- 결과적으로 left join에만
on
절 활용이 의미있는 결과를 만듦 - 내부조인(inner join)을 사용하면,
where
절에서 필터링하는 것과 기능이 동일
- 결과적으로 left join에만
- 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();
- 문법 차이: leftJoin() 부분에 일반 조인과 다르게 엔티티 하나만 들어감
- 조인 대상 필터링
- 기본 조인
-
페치 조인 (
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)
- 해결책
- 서브 쿼리를 join으로 변경하기 (높은 확률로 가능)
- 애플리케이션에서 쿼리를 2번 분리해서 실행하기
- 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 처리에도 자주 사용)
- e.g.
- Case 문 (거의 사용 X)
복잡한 쿼리에 대한 제언
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 설정
- 장점: 컴파일러 타입 체크가 가능해 가장 안전
- 단점
- 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();
- 프로퍼티 접근 (Setter)
-
튜플 조회 (
- 프로젝션 대상이 하나
-
동적 쿼리
- 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)
- e.g.
- 장점
- 메서드를 다른 쿼리에서도 재활용 가능
- 쿼리 자체의 가독성 상승
-
조합을 사용하면 반복적으로 쓰이는 코드를 묶어 더 직관적인 코드로 재사용 가능
-
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; }
-
- BooleanBuilder
-
수정 및 삭제 벌크 연산 (
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();
- 유의점: JPQL과 마찬가지로 배치 쿼리 후에는 영속성 컨텍스트 초기화가 안전 (
- 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();
- e.g. “member”를 “M”으로 변경하는
- ANSI 표준 함수들은 QueryDSL이 상당 부분 내장
- e.g.
lower()
.where(member.username.eq(member.username.lower()))
- e.g.
- JPA와 같이 Dialect에 등록된 내용만 호출 가능
순수 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의 사용자 정의 리포지토리 기능 활용
- 이 경우,
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의
- Querydsl Web 지원
- 컨트롤러가 QueryDSL 의존
- 리포지토리 지원 -
QuerydslRepositorySupport
-
getQuerydsl().applyPagination()
- 스프링 데이터가 제공하는 페이징을 Querydsl로 편리하게 변환 가능
- 단점
- QueryDSL 3.x 버전 대상
-
QueryFactory
를 제공하지 않음 - Sort 기능이 정상 동작하지 않음
-
- Querydsl 지원 클래스 직접 만들기