
자바 코드로 쿼리를 작성할 수 있다.
필드명을 잘못 작성하거나 쿼리문에 오타가 있으면 컴파일 에러를 발생시킨다.
쉽게 동적쿼리를 작성할 수 있다.
dependencies {
// ...
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}
build.gradle에 다음과 같이 추가한다.


다음과 같이 build 디렉토리 하위에 Q-Type 파일이 생성된다.
JPAQueryFactory query = new JPAQueryFactory(em);
// QHello hello = new QHello("hello");
QHello hello = QHello.hello;
Hello h1 = query
.selectFrom(hello)
.fetchOne();
EntityManager 를 넣어 JPAQueryFactory 를 생성해야 쿼리문을 작성할 수 있다.@Autowired
EntityManager em;
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
Q-Type 클래스는 직접 생성하기 보다는 내부의 기본 인스턴스를 사용하는 것이 깔끔하다.
before
QMember mem = new QMember("member");
Member findMember = queryFactory
.select(mem)
.from(mem)
.where(mem.username.eq("member1"))
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
============================================
after
// QMember.member 를 static import
Member findMember = queryFactory
.select(member)
.from(member)
.where(member.username.eq("member1"))
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
Member findMember = queryFactory
.selectFrom(member)
.where(
member.username.eq("member1"),
member.age.between(10, 30)
)
.fetchOne();
// JPQL
// select m from Member m
// where m.username = 'member1' and m.age between 10 and 30;
selectFrom은select와from을 합친 메서드이다.where안에 조건 쿼리들을,로 구분하면 and 를 붙여 쿼리를 만든다.
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%’ 검색
// ...
조건 쿼리 예시
메서드에 따라 조회 쿼리 결과물을 가져오는 방식이 다르다.
count 쿼리를 자동으로 날려주는 결과 조회 메서드인 .fetchResults() 와 .fetchCount() 는 deprecated 되었다.
List<Member> fetch = queryFactory
.selectFrom(member)
.fetch(); // 리스트에 담아 반환
Member fetchOne = queryFactory
.selectFrom(member)
.fetchOne(); // 단 건 조회인 경우, 객체로 바로 반환
Member fetchFirst = queryFactory
.selectFrom(member)
.fetchFirst(); // 첫 번째만 조회 (limit 1) 후, 객체로 바로 반환
.fetch()
- 결과물을 리스트에 담는다.
.fetchOne()
- 결과물이 1개인 경우 바로 객체로 반환한다.
- 결과물이 없는 경우
null을 반환한다.- 결과물이 여러개인 경우 예외를 발생시킨다.
.fetchFirst()
- 1개만 조회하는 limit 1 쿼리를 추가한다.
- 결과물이 1개인 경우 바로 객체로 반환한다.
- 결과물이 없는 경우
null을 반환한다.
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(100))
.orderBy(member.age.desc(), member.username.asc().nullsLast())
.fetch();
orderBy내에 다음과 같이 정렬 조건을 추가하여 정렬할 수 있다.- 정렬 시
null을 뒤로 가게하는nullsLast()도 사용가능하다.
List<Member> contents = queryFactory
.selectFrom(member)
.orderBy(member.username.desc())
.offset(1)
.limit(2)
.fetch(); // 콘텐츠를 가져오는 페이징 쿼리
Long totalCount = queryFactory
.select(member.count())
.from(member)
.fetchOne(); // total count 쿼리
- 다음과 같이 페이징의
contents를 가져오는 쿼리와totalCount쿼리를 각각 날린다.offset,limit메서드를 사용해 쉽게 페이징할 수 있다.
집계함수를 사용한 경우, 특정 객체에 데이터를 담아 반환하는 대신 튜플로 반환할 수도 있다.
튜플 객체.get(select 한 컬럼) 을 사용해 튜플에 담긴 데이터를 가져온다. Tuple tuple = queryFactory
.select(
member.count(), // 총 개수
member.age.sum(), // 총합
member.age.avg(), // 평균
member.age.max(), // 최대값
member.age.min() // 최소값
)
.from(member)
.fetchOne();
assertThat(tuple.get(member.count())).isEqualTo(4);
assertThat(tuple.get(member.age.sum())).isEqualTo(100);
assertThat(tuple.get(member.age.avg())).isEqualTo(25);
assertThat(tuple.get(member.age.max())).isEqualTo(40);
assertThat(tuple.get(member.age.min())).isEqualTo(10);
groupBy 메서드를 사용해 그룹화도 가능하다. List<Tuple> result = queryFactory
.select(team.name, member.age.avg())
.from(member)
.join(member.team, team)
.groupBy(team.name) // 팀 명으로 그룹화
.having(team.name.contains("Korea")) // 팀 명이 Korea 를 포함하는 경우만 결과로 출력
.fetch();
groupBy와having모두 사용할 수 있다.
List<Member> result = queryFactory
.selectFrom(member)
.join(member.team, team)
.where(team.name.eq("teamA")) // team 과 조인하였기에, team 이름을 사용한 조건 쿼리 가능
.fetch();
.join(루트 엔티티의 연관 엔티티 필드, 연관 엔티티 Q-Type)을 통해 내부조인 가능join을leftJoin이나rightJoin으로 바꿔 외부조인도 가능하다.
List<Tuple> result = queryFactory
.select(member, team)
.from(member)
.join(team).on(member.username.eq(team.name)) // 회원의 이름과 팀의 이름이 같은 경우 조인 (PK-FK 조인 x)
.fetch();
.select(member, team)
- FK-PK 연관관계가 없으므로
member와team데이터를 모두 가져온다.
.on(조인 조건 쿼리)
- 조인 조건을 만족하는 경우 조인한다.
- 이 경우
join내부엔 엔티티가 1개만 들어간다.
->.join(조인하려는 엔티티 Q-Type)
join을leftJoin이나rightJoin으로 바꿔 외부조인도 가능하다.
페치 조인
join(...) 뒤에 fetchJoin() 을 추가하여 페치 조인을 할 수 있다. Member findMember = queryFactory
.selectFrom(member)
.join(member.team, team).fetchJoin() // 페치 조인 적용
.where(member.username.eq("member1"))
.fetchOne();
boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
assertThat(loaded).as("페치 조인 적용").isTrue();
join뒤에.fetchJoin()을 추가하여 페치 조인 가능
emf.getPersistenceUnitUtil().isLoaded(검사하려는 엔티티)
- 엔티티 매니저 팩토리에서 제공하는 기능을 통해 특정 엔티티가 프록시 엔티티인지 실제 엔티티인지 판별한다.
- 반환 값이
true인 경우 실제 엔티티이다.
JPAExpressions 내부에 서브 쿼리를 작성할 수 있다. QMember memberSub = new QMember("memberSub"); // 같은 테이블로 서브 쿼리 작성 시, 서브 쿼리의 테이블 alias가
// 달라야 하므로 다른 별칭의 Q-Type 생성
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.goe(
JPAExpressions
.select(memberSub.age.avg())
.from(memberSub) // select avg(m.age) from Member m (서브 쿼리)
))
.fetch();
List<String> result = queryFactory
.select(member.username) // 단일 프로젝션 (문자열)
.from(member)
.fetch();
프로젝션 대상이 여러개인 경우 튜플을 사용하여 가져올 수 있다.
List<Tuple> result = queryFactory
.select(member.username, member.age) // 문자열과 정수타입 프로젝션
.from(member)
.fetch();
for (Tuple tuple : result) {
String username = tuple.get(member.username); // get(프로젝션 이름) 을 통해 데이터를 가져온다.
Integer age = tuple.get(member.age);
}
@Data
public class MemberDto {
private String username;
private int age;
}
List<MemberDto> result = queryFactory
.select(Projections.bean(MemberDto.class,
member.username,
member.age))
.from(member)
.fetch();
select안에Projections.bean(DTO 클래스 타입, 프로젝션 대상들...)를 통해 데이터를 바로 DTO 에 담아 반환할 수 있다.
@Data
public class UserDto {
private String name;
private int age;
}
QMember memberSub = new QMember("memberSub");
List<UserDto> result = queryFactory
.select(Projections.bean(UserDto.class,
member.username.as("name"), // name
ExpressionUtils.as(JPAExpressions
.select(memberSub.age.max())
.from(memberSub), "age"))) // age
.from(member)
.fetch();
- DTO 클래스의 필드명과 조회해온 프로젝션 대상의 이름이 다르면 DTO 에 데이터가 들어가지 않는다.
.as(alias)를 통해 DTO 클래스의 필드명과 맞춰준다.
- 서브쿼리의 경우
.as(alias)가 불가능하고,ExpressionUtils.as(서브쿼리, alias)를 사용한다.
where 안에 조건 쿼리들을 담는다.
이때 조건 쿼리가 만약 null 이면 그냥 무시된다.
조건 쿼리가 여러개이면 and 쿼리와 함께 엮는다.
private List<Member> searchMember(String usernameCond, Integer ageCond) {
return queryFactory
.selectFrom(member)
.where(usernameEq(usernameCond), ageEq(ageCond)) // null 은 무시된다.
.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;
}
조건 쿼리를
BooleanExpression으로 반환하면.and나.or로 조립할 수 있다.private BooleanExpression allEq(String usernameCond, Integer ageCond) { return usernameEq(usernameCond).and(ageEq(ageCond)); // 조립을 통해 재사용성을 높인다. }이렇게 조립할때는
null을 주의해야 한다.
usernameEq(usernameCond)는null이어선 안된다.ageEq(ageCond)는null이어도 된다.
.execute() 를 통해 수정, 삭제 쿼리를 실행한다.
수정, 삭제된 쿼리수를 반환한다.
벌크성 수정 쿼리는 영속성 컨텍스트에 반영되지 않고 DB에 직접 날라가므로 영속성 컨텍스트와 DB간의 데이터가 불일치할 수 있다.
벌크성 쿼리를 날린 후 영속성 컨텍스트를 초기화해주는 것이 좋다.
em.flush() & em.clear() 실행
수정 벌크 연산
long count = queryFactory // 수정된 쿼리 수를 반환한다.
.update(member)
.set(member.username, "비회원")
.where(member.age.lt(28))
.execute();
long count = queryFactory
.update(member)
.set(member.age, member.age.add(1).multiply(2)) // member.age 에 1을 더한 뒤 2를 곱한다.
.execute();
long count = queryFactory
.delete(member)
.where(member.age.gt(18))
.execute();
만약 스프링 데이터 JPA 리포지토리 인터페이스 기능들을 사용하며 Querydsl 을 사용하고 싶다면 링크 의 사용자 정의 리포지토리 구현 챕터를 참고하여 리포지토리를 만든다.
주입받은 엔티티 매니저를 바탕으로 생성자에서 JPAQueryFactory 를 만든다.
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final EntityManager em;
private final JPAQueryFactory queryFactory;
public MemberRepositoryImpl(EntityManager em) {
this.em = em;
this.queryFactory = new JPAQueryFactory(em);
}
// ...
}
JPAQueryFactory 를 바로 스프링 빈으로 등록하여 외부에서 주입받아 사용해도 된다.@SpringBootApplication
public class QuerydslApplication {
public static void main(String[] args) {
SpringApplication.run(QuerydslApplication.class, args);
}
@Bean
JPAQueryFactory jpaQueryFactory(EntityManager em) { // 스프링 빈으로 등록
return new JPAQueryFactory(em);
}
}
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final EntityManager em;
private final JPAQueryFactory queryFactory;
// ...
}
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/v1/members")
public List<MemberTeamDto> searchMember(MemberSearchCondition condition) {
return memberRepository.search(condition);
}
}
- 사용자가 입력한 검색조건을 파라미터로 받는 컨트롤러
- 검색조건을 리포지토리에 전달한다.
@Data
public class MemberSearchCondition {
//회원명, 팀명, 나이(ageGoe, ageLoe)
private String username;
private String teamName;
private Integer ageGoe;
private Integer ageLoe;
}
- 사용자가 입력한 검색조건 클래스
- 만약 사용자가 입력하지 않은 검색조건은
null로 처리된다.
public class MemberRepositoryImpl implements MemberRepositoryCustom {
// ...
@Override
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())) // null 인 조건 쿼리들은 생성되지 않는다.
.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;
}
}
리포지토리 클래스
new QMemberTeamDto(프로젝션 대상들 ..)은QueryProjection을 적용하여 DTO 클래스를 Q-Type 파일로 생성한 것이다.
usernameEq(String username),teamNameEq(String teamName),ageGoe(Integer ageGoe),ageLoe(Integer ageLoe)
- 조건 쿼리를 생성하는 메서드
- 사용자가 입력한 검색조건을 담는
condition의 필드들을 조건 쿼리 메서드에 전달한다.- 만약 전달된 필드가
null이면null을 그대로 반환한다.
where메서드 안의 조건 쿼리 메서드 중 반환값이null인 값은 쿼리문을 생성하지 않고 제거된다.
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/v3/members")
public Page<MemberTeamDto> searchMember(MemberSearchCondition condition, Pageable pageable) {
return memberRepository.searchPage(condition, pageable);
}
}
사용자로부터 페이징 조건인
pageable을 파라미터로 받는 컨트롤러
public class MemberRepositoryImpl implements MemberRepositoryCustom {
// ...
@Override
public Page<MemberTeamDto> searchPage(MemberSearchCondition condition, Pageable pageable) {
List<MemberTeamDto> content = queryFactory // 페이지의 contents 를 조회하는 쿼리
.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())
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = queryFactory // total 을 구하는 count 쿼리
.select(member.count())
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
);
// return new PageImpl<>(content, pageable, countQuery.fetchOne()); // 무조건 total 을 구하는 count 쿼리를 날린다.
return PageableExecutionUtils.getPage(content, pageable, () -> countQuery.fetchOne()); // 특정 조건에선 total 을 구하는 count 쿼리를 생략한다.
}
}
리포지토리 클래스
- 사용자로부터 입력받은
pageable을 바탕으로 페이징 수행
- 페이지의 contents 를 구하는 쿼리와 데이터의 총 개수를 구하는 count 쿼리로 이루어져 있다.
PageableExecutionUtils.getPage(content, pageable, () -> countQuery.fetchOne())
Page구현체를 만들어 반환한다.- count 쿼리를 통해 총 개수를 구하는 람다를 파라미터로 전달한다.
- 항상 count 쿼리를 실행하지 않고 데이터의 총 개수를 계산할 수 있는 특정 경우엔 이를 생략한다.
- 시작 페이지면서, 페이지 사이즈가 content 사이즈보다 크면
total count=현재 페이지의 content 사이즈- 마지막 페이지면서, 페이지 사이즈가 content 사이즈보다 크면
total count=이전 페이지까지의 모든 content 크기+현재 페이지의 content 사이즈
페이지 번호를 사용하는 경우 페이징이 필요하지만 무한 스크롤과 같은 구현은 슬라이싱으로 충분하다.
컨트롤러의 구현은 페이징과 유사하다.
@Override
public Slice<MemberTeamDto> searchPage(MemberSearchCondition condition, Pageable pageable) {
List<MemberTeamDto> content = queryFactory // 페이지의 contents 를 조회하는 쿼리
.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())
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1) // page size + 1 을 가져온다.
.fetch();
return new SliceImpl<>(content, pageable, hasNextPage(products, pageable.getPageSize()));
}
private Boolean hasNextPage(List<Product> products, int pageSize) { // 다음 페이지 존재 여부를 판별한다.
if (products.size() > pageSize) {
products.remove(pageSize);
return true;
}
return false;
}
- 슬라이싱의 경우 데이터의 총 개수 대신 다음 페이지 존재 여부만 파라미터로 전달한다.
- 페이징보다 성능이 좋다.
출처
실전! Querydsl - 김영한 강사님
https://www.inflearn.com/course/querydsl-%EC%8B%A4%EC%A0%84
PageableExecutionUtils.getPage (Hugehoo 님)
https://junior-datalist.tistory.com/342
정리가 깔끔하네요 😎 강의 한 편 뚝딱.....