1. 시작 - JPQL vs QueryDSL
1.1. QueryDSL 설정 - Bean 등록 (⭐⭐)
실무에서는 JPAQueryFactory를 매번 수동으로 생성하지 않고, 스프링 컨테이너에 빈으로 등록하여 필요한 곳에서 주입받아 사용한다. 이는 코드의 중복을 줄이고 일관된 설정을 유지하게 한다.
@Configuration
public class QuerydslConfig {
@PersistenceContext
private EntityManager em;
/**
* JPAQueryFactory를 빈으로 등록한다.
* 이를 통해 Repository나 Service에서 @RequiredArgsConstructor 등으로 주입받아 사용할 수 있다.
*/
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(em);
}
}
1.2. 테스트 기본 코드 및 주입 방식
위에서 등록한 빈을 활용하여 테스트 환경을 설정한다. 실무에서는 @Autowired를 통해 컨테이너가 관리하는 팩토리를 직접 주입받는다.
package study.querydsl;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import study.querydsl.entity.Member;
import study.querydsl.entity.Team;
import jakarta.persistence.EntityManager;
import static org.assertj.core.api.Assertions.*;
import static study.querydsl.entity.QMember.*;
@SpringBootTest
@Transactional
public class QuerydslBasicTest {
@Autowired
EntityManager em;
@Autowired
JPAQueryFactory queryFactory; // 빈으로 등록된 객체를 주입받음
@BeforeEach
public void before() {
// 테스트 데이터 준비
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
em.persist(teamA);
em.persist(teamB);
Member member1 = new Member("member1", 10, teamA);
Member member2 = new Member("member2", 20, teamA);
Member member3 = new Member("member3", 30, teamB);
Member member4 = new Member("member4", 40, teamB);
em.persist(member1);
em.persist(member2);
em.persist(member3);
em.persist(member4);
}
}
1.3. JPQL vs QueryDSL 직접 비교
QueryDSL은 자바 코드로 쿼리를 작성하기 때문에 파라미터 바인딩이 자동화되며, 컴파일 시점에 오류를 잡아낼 수 있다.
JPQL 방식 (문자열 기반):
@Test
public void startJPQL() {
// member1을 조회한다.
String qlString = "select m from Member m where m.username = :username";
Member findMember = em.createQuery(qlString, Member.class)
.setParameter("username", "member1")
.getSingleResult();
assertThat(findMember.getUsername()).isEqualTo("member1");
}
QueryDSL 방식 (코드 기반):
@Test
public void startQuerydsl() {
// member1을 조회한다.
// 설정에서 주입받은 queryFactory를 사용하여 타입 안전한 쿼리를 작성한다.
Member findMember = queryFactory
.select(member)
.from(member)
.where(member.username.eq("member1"))
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
}
1.4. 주요 차이점 분석
| 구분 | JPQL | QueryDSL |
| 쿼리 작성 방식 | 문자열 | 코드 |
| 오류 발견 시점 | 런타임 (실행 시) | 컴파일 타임 |
| 파라미터 바인딩 | 수동 (setParameter) | 자동 |
| 타입 안전성 | 낮음 | 높음 (컴파일 시 체크) |
| 자동 완성 | IDE 지원 어려움 | IDE 지원 우수 |
동시성 문제 안전성: JPAQueryFactory를 필드로 선언하거나 빈으로 공유해도 동시성 문제는 발생하지 않는다. 스프링은 트랜잭션마다 별도의 영속성 컨텍스트(EntityManager)를 할당하기 때문에 여러 스레드에서 동시에 접근해도 각자 격리된 상태로 안전하게 동작한다.
2. 기본 Q-Type 활용
2.1. Q클래스 생성 방법 2가지 (⭐⭐)
방법 1: 별칭 직접 지정
QMember qMember = new QMember("m"); // "m"이라는 별칭 직접 지정
방법 2: 기본 인스턴스 사용 (권장)
QMember qMember = QMember.member; // 기본 인스턴스 사용
2.2. static import로 더 깔끔하게 사용
import static study.querydsl.entity.QMember.*;
@Test
public void startQuerydsl3() {
// member1을 찾는다
Member findMember = queryFactory
.select(member) // static import로 깔끔하게
.from(member)
.where(member.username.eq("member1"))
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
}
2.3. JPQL 로그 확인 설정
실행되는 JPQL을 확인하려면 application.yml에 다음 설정을 추가한다:
spring:
jpa:
properties:
hibernate:
use_sql_comments: true # JPQL 주석 출력
참고 사항
같은 테이블을 조인해야 하는 특수한 경우가 아니라면 기본 인스턴스(QMember.member)를 사용하는 것이 좋다. 기본 인스턴스를 사용하면 코드가 더 깔끔해지고 관리가 편리하다.
3. 검색 조건 쿼리
3.1. 기본 검색 쿼리 작성
@Test
public void search() {
Member findMember = queryFactory
.selectFrom(member) // select + from 합치기
.where(member.username.eq("member1")
.and(member.age.eq(10))) // and 처리는 아래 3.3.가 실무 표준임 (현재 방식 ❌)
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
}
팁) selectFrom()은 select()와 from()을 합친 편의 메서드이다.
3.2. JPQL이 제공하는 모든 검색 조건
QueryDSL은 JPQL이 제공하는 모든 검색 조건을 메서드 형태로 제공한다:
// 기본 비교 연산
member.username.eq("member1") // username = 'member1'
member.username.ne("member1") // username != 'member1'
member.username.eq("member1").not() // username != 'member1' (not() 사용)
member.username.isNotNull() // username 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) // age between 10 and 30
member.age.goe(30) // age >= 30 (Greater or Equal)
member.age.gt(30) // age > 30 (Greater Than)
member.age.loe(30) // age <= 30 (Less or Equal)
member.age.lt(30) // age < 30 (Less Than)
// 문자열 연산
member.username.like("member%") // like 'member%' 검색
member.username.contains("member") // like '%member%' 검색
member.username.startsWith("member") // like 'member%' 검색
3.3. AND 조건을 파라미터로 처리
where() 메서드에 여러 조건을 콤마(,)로 구분하여 전달하면 AND 조건으로 처리된다:
@Test
public void searchAndParam() {
List<Member> result1 = queryFactory
.selectFrom(member)
.where(
member.username.eq("member1"), // 콤마로 구분
member.age.eq(10) // ✅ 실무 표준의 and 처리 방식(',')
)
.fetch();
assertThat(result1.size()).isEqualTo(1);
}
장점
null 값이 들어오면 해당 조건은 무시된다. 동적 쿼리를 작성할 때 매우 유용하며, 메서드 추출을 활용하면 동적 쿼리를 더 깔끔하게 작성할 수 있다.
4. 결과 조회
QueryDSL은 다양한 결과 조회 메서드를 제공한다.
4.1. 기본 조회 메서드
// 1. 리스트 조회
List<Member> fetch = queryFactory
.selectFrom(member)
.fetch(); // 결과가 없으면 빈 리스트 반환
// 2. 단건 조회
Member findMember1 = queryFactory
.selectFrom(member)
.fetchOne(); // 주의: 결과가 없으면 null, 둘 이상이면 NonUniqueResultException
// 3. 첫 번째 결과 조회
Member findMember2 = queryFactory
.selectFrom(member)
.fetchFirst(); // limit(1).fetchOne()과 동일
4.2. 페이징을 위한 조회 메서드
// 4. 페이징 정보 포함 조회
QueryResults<Member> results = queryFactory
.selectFrom(member)
.fetchResults(); // total count 쿼리 추가 실행
// QueryResults에서 정보 추출
long total = results.getTotal(); // 전체 데이터 수
List<Member> content = results.getResults(); // 조회된 데이터
long offset = results.getOffset(); // 시작 위치
long limit = results.getLimit(); // 페이지 크기
// 5. Count 쿼리만 실행
long count = queryFactory
.selectFrom(member)
.fetchCount(); // count 쿼리로 변경해서 실행
⚠️ 주의사항
fetchResults()와 fetchCount()는 성능에 영향을 줄 수 있다. 복잡한 쿼리에서 count 쿼리가 조인을 포함하면 성능이 저하될 수 있다. 실무에서는 count 쿼리를 별도로 작성하는 경우가 많다.
5. 정렬
5.1. 기본 정렬
@Test
public void sort() {
// 테스트 데이터 추가
em.persist(new Member(null, 100));
em.persist(new Member("member5", 100));
em.persist(new Member("member6", 100));
/**
* 회원 정렬 순서
* 1. 회원 나이 내림차순(desc)
* 2. 회원 이름 올림차순(asc)
* 3. 회원 이름이 없으면 마지막에 출력(nulls last)
*/
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(100))
.orderBy(
member.age.desc(), // 나이 내림차순
member.username.asc().nullsLast() // 이름 오름차순, null은 마지막
)
.fetch();
// 결과 검증
Member member5 = result.get(0);
Member member6 = result.get(1);
Member memberNull = result.get(2);
assertThat(member5.getUsername()).isEqualTo("member5");
assertThat(member6.getUsername()).isEqualTo("member6");
assertThat(memberNull.getUsername()).isNull();
}
5.2. 정렬 옵션
// 기본 정렬
member.username.asc() // 오름차순
member.username.desc() // 내림차순
// null 값 처리
member.username.asc().nullsFirst() // null 값을 가장 먼저
member.username.asc().nullsLast() // null 값을 가장 나중에
6. 페이징
6.1. 간단한 페이징 (조회 건수 제한)
@Test
public void paging1() {
List<Member> result = queryFactory
.selectFrom(member)
.orderBy(member.username.desc())
.offset(1) // 0부터 시작 (zero index)
.limit(2) // 최대 2건 조회
.fetch();
assertThat(result.size()).isEqualTo(2);
}
6.2. 전체 조회 수가 필요한 페이징
@Test
public void paging2() {
QueryResults<Member> queryResults = queryFactory
.selectFrom(member)
.orderBy(member.username.desc())
.offset(1)
.limit(2)
.fetchResults(); // 페이징 정보 포함
// 검증
assertThat(queryResults.getTotal()).isEqualTo(4); // 전체 데이터 수
assertThat(queryResults.getLimit()).isEqualTo(2); // 페이지 크기
assertThat(queryResults.getOffset()).isEqualTo(1); // 시작 위치
assertThat(queryResults.getResults().size()).isEqualTo(2); // 결과 수
}
중요한 실무 팁
실무에서 페이징 쿼리를 작성할 때, 데이터를 조회하는 쿼리는 여러 테이블을 조인해야 하지만, count 쿼리는 조인이 필요 없는 경우도 있다. 자동화된 count 쿼리는 원본 쿼리와 같이 모두 조인을 해버리기 때문에 성능이 떨어질 수 있다. count 쿼리에 조인이 필요 없는 성능 최적화가 필요하다면, count 전용 쿼리를 별도로 작성해야 한다.
7. 집합 함수와 그룹화
7.1. 집합 함수 사용
/**
* JPQL
* select
* COUNT(m), //회원수
* SUM(m.age), //나이 합
* AVG(m.age), //평균 나이
* MAX(m.age), //최대 나이
* MIN(m.age) //최소 나이
* from Member m
*/
@Test
public void aggregation() {
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);
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);
}
Tuple 사용
집합 함수의 결과는 Tuple 객체로 반환된다. Tuple은 프로젝션과 결과 반환에 사용되며, 타입 안전성을 유지하면서 다양한 타입의 결과를 처리할 수 있다.
7.2. GroupBy 사용
/**
* 팀의 이름과 각 팀의 평균 연령을 구한다.
*/
@Test
public void group() {
List<Tuple> result = queryFactory
.select(team.name, member.age.avg())
.from(member)
.join(member.team, team)
.groupBy(team.name)
.fetch();
Tuple teamA = result.get(0);
Tuple teamB = result.get(1);
assertThat(teamA.get(team.name)).isEqualTo("teamA");
assertThat(teamA.get(member.age.avg())).isEqualTo(15);
assertThat(teamB.get(team.name)).isEqualTo("teamB");
assertThat(teamB.get(member.age.avg())).isEqualTo(35);
}
7.3. Having 절 사용
@Test
public void groupHaving() {
List<Tuple> result = queryFactory
.select(team.name, member.age.avg())
.from(member)
.join(member.team, team)
.groupBy(team.name)
.having(member.age.avg().gt(20)) // 평균 나이가 20보다 큰 팀만
.fetch();
}
8. 조인 - 기본 조인
8.1. 기본 조인 문법
조인의 기본 문법은 첫 번째 파라미터에 조인 대상을 지정하고, 두 번째 파라미터에 별칭으로 사용할 Q 타입을 지정한다.
join(조인 대상, 별칭으로 사용할 Q타입)
8.2. 내부 조인 예제
/**
* 팀 A에 소속된 모든 회원
*/
@Test
public void join() {
List<Member> result = queryFactory
.selectFrom(member)
.join(member.team, team) // 내부 조인
.where(team.name.eq("teamA"))
.fetch();
assertThat(result)
.extracting("username")
.containsExactly("member1", "member2");
}
8.3. 조인 종류
join(member.team, team) // 내부 조인(inner join)
innerJoin(member.team, team) // 내부 조인(inner join) - 동일
leftJoin(member.team, team) // left 외부 조인(left outer join)
rightJoin(member.team, team) // right 외부 조인(right outer join)
8.4. 세타 조인
세타 조인은 연관관계가 없는 필드로 조인하는 방식이다.
/**
* 세타 조인(연관관계가 없는 필드로 조인)
* 회원의 이름이 팀 이름과 같은 회원 조회
*/
@Test
public void theta_join() {
// 추가 테스트 데이터
em.persist(new Member("teamA"));
em.persist(new Member("teamB"));
List<Member> result = queryFactory
.select(member)
.from(member, team) // from 절에 여러 엔티티를 선택
.where(member.username.eq(team.name))
.fetch();
assertThat(result)
.extracting("username")
.containsExactly("teamA", "teamB");
}
세타 조인의 특징
- from 절에 여러 엔티티를 선택해서 세타 조인을 수행한다.
- 외부 조인은 불가능하다.
- 외부 조인이 필요하면 다음에 설명할 ON 절을 사용해야 한다.
9. 조인 - ON 절
ON 절을 활용한 조인은 JPA 2.1부터 지원된다. 주로 두 가지 경우에 사용한다:
- 조인 대상 필터링
- 연관관계 없는 엔티티 외부 조인
9.1. 조인 대상 필터링
/**
* 예) 회원과 팀을 조인하면서, 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회
* JPQL: SELECT m, t FROM Member m LEFT JOIN m.team t on t.name = 'teamA'
* SQL: SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.TEAM_ID=t.id and t.name='teamA'
*/
@Test
public void join_on_filtering() {
List<Tuple> result = queryFactory
.select(member, team)
.from(member)
.leftJoin(member.team, team).on(team.name.eq("teamA"))
.fetch();
for (Tuple tuple : result) {
System.out.println("tuple = " + tuple);
}
}
결과
t=[Member(id=3, username=member1, age=10), Team(id=1, name=teamA)]
t=[Member(id=4, username=member2, age=20), Team(id=1, name=teamA)]
t=[Member(id=5, username=member3, age=30), null]
t=[Member(id=6, username=member4, age=40), null]
중요한 주의사항
ON 절을 활용해 조인 대상을 필터링할 때, 외부조인이 아니라 내부조인을 사용하면 where 절에서 필터링하는 것과 기능이 동일하다. 따라서 ON 절을 활용한 조인 대상 필터링을 사용할 때, 내부조인 이면 익숙한 where 절로 해결하고, 정말 외부조인이 필요한 경우에만 이 기능을 사용해야 한다.
9.2. 연관관계 없는 엔티티 외부 조인
/**
* 2. 연관관계 없는 엔티티 외부 조인
* 예) 회원의 이름과 팀의 이름이 같은 대상 외부 조인
* JPQL: SELECT m, t FROM Member m LEFT JOIN Team t on m.username = t.name
* SQL: SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.username = t.name
*/
@Test
public void join_on_no_relation() {
// 추가 테스트 데이터
em.persist(new Member("teamA"));
em.persist(new Member("teamB"));
List<Tuple> result = queryFactory
.select(member, team)
.from(member)
.leftJoin(team).on(member.username.eq(team.name)) // 엔티티 하나만 들어감
.fetch();
for (Tuple tuple : result) {
System.out.println("t=" + tuple);
}
}
결과
t=[Member(id=3, username=member1, age=10), null]
t=[Member(id=4, username=member2, age=20), null]
t=[Member(id=5, username=member3, age=30), null]
t=[Member(id=6, username=member4, age=40), null]
t=[Member(id=7, username=teamA, age=0), Team(id=1, name=teamA)]
t=[Member(id=8, username=teamB, age=0), Team(id=2, name=teamB)]
주의할 점
하이버네이트 5.1부터 ON을 사용해서 서로 관계가 없는 필드로 외부 조인하는 기능이 추가되었다. 문법을 잘 봐야 한다:
- 일반조인: leftJoin(member.team, team)
- on조인: from(member).leftJoin(team).on(xxx)
10. 조인 - 페치 조인
페치 조인은 SQL에서 제공하는 기능이 아니다. SQL 조인을 활용해서 연관된 엔티티를 SQL 한번에 조회하는 기능으로, 주로 성능 최적화에 사용하는 방법이다.
10.1. 페치 조인 미적용
@PersistenceUnit
EntityManagerFactory emf;
@Test
public void fetchJoinNo() {
em.flush();
em.clear();
Member findMember = queryFactory
.selectFrom(member)
.where(member.username.eq("member1"))
.fetchOne();
// 페치 조인 적용 여부 검증
boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
assertThat(loaded).as("페치 조인 미적용").isFalse();
}
10.2. 페치 조인 적용
@Test
public void fetchJoinUse() {
em.flush();
em.clear();
Member findMember = queryFactory
.selectFrom(member)
.join(member.team, team).fetchJoin() // fetchJoin() 호출
.where(member.username.eq("member1"))
.fetchOne();
boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
assertThat(loaded).as("페치 조인 적용").isTrue();
}
페치 조인 사용방법
join(), leftJoin() 등 조인 기능 뒤에 fetchJoin() 이라고 추가하면 된다.
페치 조인 검증 방법
EntityManagerFactory의 getPersistenceUnitUtil().isLoaded() 메서드를 사용하여 페치 조인 적용 여부를 확인할 수 있다.
11. 서브 쿼리
11.1. 서브 쿼리 기본 사용법 (⭐)
서브 쿼리를 사용하기 위해 com.querydsl.jpa.JPAExpressions를 사용한다.
/**
* 나이가 가장 많은 회원 조회
*/
@Test
public void subQuery() {
QMember memberSub = new QMember("memberSub"); // 서브쿼리용 별칭
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(
JPAExpressions
.select(memberSub.age.max())
.from(memberSub)
))
.fetch();
assertThat(result).extracting("age")
.containsExactly(40);
}
11.2. 다양한 서브 쿼리 패턴
/**
* 나이가 평균 나이 이상인 회원
*/
@Test
public void subQueryGoe() {
QMember memberSub = new QMember("memberSub");
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.goe(
JPAExpressions
.select(memberSub.age.avg())
.from(memberSub)
))
.fetch();
assertThat(result).extracting("age")
.containsExactly(30, 40);
}
/**
* 서브쿼리 여러 건 처리, in 사용
*/
@Test
public void subQueryIn() {
QMember memberSub = new QMember("memberSub");
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.in(
JPAExpressions
.select(memberSub.age)
.from(memberSub)
.where(memberSub.age.gt(10))
))
.fetch();
assertThat(result).extracting("age")
.containsExactly(20, 30, 40);
}
11.3. Select 절 서브 쿼리
@Test
public void selectSubQuery() {
QMember memberSub = new QMember("memberSub");
List<Tuple> fetch = queryFactory
.select(member.username,
JPAExpressions
.select(memberSub.age.avg())
.from(memberSub)
).from(member)
.fetch();
for (Tuple tuple : fetch) {
String username = tuple.get(member.username);
Double ageAvg = tuple.get(JPAExpressions.select(memberSub.age.avg()).from(memberSub));
System.out.println("username = " + username + ", 평균나이 = " + ageAvg);
}
}
11.4. static import 활용
import static com.querydsl.jpa.JPAExpressions.select;
@Test
public void subQueryStaticImport() {
QMember memberSub = new QMember("memberSub");
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(
select(memberSub.age.max()) // static import로 깔끔하게
.from(memberSub)
))
.fetch();
}
11.5. from 절 서브 쿼리 한계
JPA JPQL 서브쿼리의 한계점으로 from 절의 서브쿼리(인라인 뷰)는 지원하지 않는다. 당연히 Querydsl도 지원하지 않는다. 하이버네이트 구현체를 사용하면 select 절의 서브쿼리는 지원한다.
from 절 서브쿼리 해결방안
- 서브쿼리를 join으로 변경한다 (가능한 상황도 있고, 불가능한 상황도 있다.)
- 애플리케이션에서 쿼리를 2번 분리해서 실행한다
- nativeSQL을 사용한다
12. Case 문
Case 문은 select, 조건절(where), order by에서 사용 가능하다.
12.1. 단순한 조건
@Test
public void simpleCase() {
List<String> result = queryFactory
.select(member.age
.when(10).then("열살")
.when(20).then("스무살")
.otherwise("기타"))
.from(member)
.fetch();
}
12.2. 복잡한 조건
@Test
public void complexCase() {
List<String> result = queryFactory
.select(new CaseBuilder()
.when(member.age.between(0, 20)).then("0~20살")
.when(member.age.between(21, 30)).then("21~30살")
.otherwise("기타"))
.from(member)
.fetch();
}
12.3. orderBy에서 Case 문 사용
/**
* 다음과 같은 임의의 순서로 회원을 출력하고 싶다면?
* 1. 0 ~ 30살이 아닌 회원을 가장 먼저 출력
* 2. 0 ~ 20살 회원 출력
* 3. 21 ~ 30살 회원 출력
*/
@Test
public void caseInOrderBy() {
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();
for (Tuple tuple : result) {
String username = tuple.get(member.username);
Integer age = tuple.get(member.age);
Integer rank = tuple.get(rankPath);
System.out.println("username = " + username + " age = " + age + " rank = " + rank);
}
}
결과
username = member4 age = 40 rank = 3
username = member1 age = 10 rank = 2
username = member2 age = 20 rank = 2
username = member3 age = 30 rank = 1
장점
QueryDSL은 자바 코드로 작성하기 때문에 rankPath처럼 복잡한 조건을 변수로 선언해서 select 절, orderBy 절에서 함께 사용할 수 있다.
13. 상수, 문자 더하기
13.1. 상수 사용
@Test
public void constant() {
Tuple result = queryFactory
.select(member.username, Expressions.constant("A"))
.from(member)
.fetchFirst();
}
최적화
위와 같이 최적화가 가능하면 SQL에 constant 값을 넘기지 않는다. 상수를 더하는 것처럼 최적화가 어려우면 SQL에 constant 값을 넘긴다.
13.2. 문자 더하기
@Test
public void concat() {
String result = queryFactory
.select(member.username.concat("_").concat(member.age.stringValue()))
.from(member)
.where(member.username.eq("member1"))
.fetchOne();
// 결과: member1_10
}
stringValue() 메서드
member.age.stringValue() 부분이 중요한데, 문자가 아닌 다른 타입들은 stringValue()로 문자로 변환할 수 있다. 이 방법은 ENUM을 처리할 때도 자주 사용한다.
14. 프로젝션과 결과 반환 - 기본
프로젝션은 select 대상(조회 대상)을 지정하는 것을 의미한다.
14.1. 단일 필드 프로젝션
프로젝션 대상이 하나일 경우 타입을 명확하게 지정할 수 있다.
@Test
public void simpleProjection() {
// 단일 필드 조회 - String 타입
List<String> result = queryFactory
.select(member.username) // username만 선택
.from(member)
.fetch();
for (String username : result) {
System.out.println("username = " + username);
}
assertThat(result).containsExactly("member1", "member2", "member3", "member4");
}
14.2. 다중 필드 프로젝션 (Tuple)
프로젝션 대상이 둘 이상일 때는 Tuple을 사용한다.
@Test
public void tupleProjection() {
// 여러 필드 조회 - Tuple 사용
List<Tuple> result = queryFactory
.select(member.username, member.age)
.from(member)
.fetch();
// Tuple에서 데이터 추출
for (Tuple tuple : result) {
String username = tuple.get(member.username);
Integer age = tuple.get(member.age);
System.out.println("username = " + username);
System.out.println("age = " + age);
System.out.println("---");
}
// 검증
assertThat(result).hasSize(4);
assertThat(result.get(0).get(member.username)).isEqualTo("member1");
assertThat(result.get(0).get(member.age)).isEqualTo(10);
}
Tuple 특징
- com.querydsl.core.Tuple 타입
- 여러 타입의 결과를 안전하게 처리 가능
- 타입 캐스팅 없이 메서드 체인으로 데이터 접근
- 주로 Repository 계층 내부에서 사용
15. 프로젝션과 결과 반환 - DTO 조회 (⭐)
실무에서는 Entity를 직접 반환하기보다 DTO로 변환하여 반환하는 것이 일반적이다.
15.1. 순수 JPA에서의 DTO 조회
// MemberDto 클래스
package study.querydsl.dto;
import lombok.Data;
@Data
public class MemberDto {
private String username;
private int age;
// JPA에서 DTO 조회를 위한 생성자
public MemberDto(String username, int age) {
this.username = username;
this.age = age;
}
}
// 순수 JPA DTO 조회
@Test
public void findDtoByJPQL() {
List<MemberDto> result = em.createQuery(
"select new study.querydsl.dto.MemberDto(m.username, m.age) " +
"from Member m", MemberDto.class)
.getResultList();
for (MemberDto memberDto : result) {
System.out.println("memberDto = " + memberDto);
}
}
순수 JPA DTO 조회의 단점
- new 명령어 필수
- DTO의 패키지 이름을 완전히 적어야 함
- 생성자 방식만 지원
- 타입 안정성이 떨어짐
15.2. QueryDSL의 DTO 조회 방법
QueryDSL은 세 가지 방식으로 DTO 조회를 지원한다.
15.2.1. 프로퍼티 접근 (Setter 방식)
@Test
public void findDtoBySetter() {
List<MemberDto> result = queryFactory
.select(Projections.bean(MemberDto.class, // Setter 사용
member.username,
member.age))
.from(member)
.fetch();
for (MemberDto memberDto : result) {
System.out.println("memberDto = " + memberDto);
}
assertThat(result).hasSize(4);
}
필요 조건
- 기본 생성자 필요
- Setter 메서드 필요
- 필드 이름과 매칭
15.2.2. 필드 직접 접근
@Test
public void findDtoByField() {
List<MemberDto> result = queryFactory
.select(Projections.fields(MemberDto.class, // 필드 직접 접근
member.username,
member.age))
.from(member)
.fetch();
for (MemberDto memberDto : result) {
System.out.println("memberDto = " + memberDto);
}
assertThat(result).hasSize(4);
}
필요 조건
- 기본 생성자 필요
- Setter 불필요
- 필드에 직접 접근 (private 필드도 리플렉션으로 접근)
15.2.3. 생성자 사용 (⭐)
@Test
public void findDtoByConstructor() {
List<MemberDto> result = queryFactory
.select(Projections.constructor(MemberDto.class, // 생성자 사용
member.username,
member.age))
.from(member)
.fetch();
for (MemberDto memberDto : result) {
System.out.println("memberDto = " + memberDto);
}
assertThat(result).hasSize(4);
}
필요 조건
- 매개변수가 일치하는 생성자 필요
- 가장 안전한 방식
15.3. 필드 이름이 다를 때의 처리
// 필드 이름이 다른 DTO
package study.querydsl.dto;
import lombok.Data;
@Data
public class UserDto {
private String name; // Member.username -> UserDto.name
private int age;
}
@Test
public void findDtoByFieldAlias() {
// 필드 이름이 다를 때 alias 사용
List<UserDto> result = queryFactory
.select(Projections.fields(UserDto.class,
member.username.as("name"), // alias 설정
member.age))
.from(member)
.fetch();
for (UserDto userDto : result) {
System.out.println("userDto = " + userDto);
}
assertThat(result).hasSize(4);
assertThat(result.get(0).getName()).isEqualTo("member1");
}
15.4. 서브쿼리 결과를 DTO에 매핑
@Test
public void findDtoBySubQuery() {
QMember memberSub = new QMember("memberSub");
List<UserDto> result = queryFactory
.select(Projections.fields(UserDto.class,
member.username.as("name"),
// 서브쿼리 결과에 alias 설정
ExpressionUtils.as(
JPAExpressions
.select(memberSub.age.avg())
.from(memberSub),
"age")))
.from(member)
.fetch();
for (UserDto userDto : result) {
System.out.println("userDto = " + userDto);
}
assertThat(result).hasSize(4);
}
ExpressionUtils.as()
- 서브쿼리나 복잡한 표현식에 alias를 적용할 때 사용
- ExpressionUtils.as(expression, alias)
16. 프로젝션과 결과 반환 - @QueryProjection
가장 타입 안전한 DTO 조회 방법이다.
16.1. @QueryProjection 적용 (⭐)
// MemberDto 클래스에 @QueryProjection 추가
package study.querydsl.dto;
import com.querydsl.core.annotations.QueryProjection;
import lombok.Data;
@Data
public class MemberDto {
private String username;
private int age;
public MemberDto() {
}
@QueryProjection // QueryDSL용 생성자
public MemberDto(String username, int age) {
this.username = username;
this.age = age;
}
}
16.2. Q클래스 생성
./gradlew compileQuerydsl 실행 후 QMemberDto가 생성된다.
@Test
public void findDtoByQueryProjection() {
List<MemberDto> result = queryFactory
.select(new QMemberDto(member.username, member.age)) // Q클래스 사용
.from(member)
.fetch();
for (MemberDto memberDto : result) {
System.out.println("memberDto = " + memberDto);
}
assertThat(result).hasSize(4);
}
16.3. 장단점 비교
장점
- 컴파일 시점에 타입 체크 가능
- 가장 안전한 방법
- IDE의 자동완성 지원
단점
- DTO가 QueryDSL에 의존적이게 됨
- DTO에도 Q클래스가 생성됨
- DTO까지 QueryDSL 설정 필요
16.4. QueryDSL 프로젝션 방식 비교
| 구분 | @QueryProjection | Projections.constructor | Projections.fields / bean |
| 타입 안정성 | 최상 (컴파일 시점 체크) | 중 (런타임 시점 체크) | 하 (런타임 시점 체크) |
| 리팩토링 편의성 | 매우 높음 (IDE 연동 가능) | 낮음 (수동 수정 필요) | 매우 낮음 |
| 의존성 (Dependency) | 있음 (DTO가 QueryDSL에 종속) | 없음 (순수 POJO 유지) | 없음 (순수 POJO 유지) |
| Q-파일 생성 | 필요함 (DTO 전용 Q파일 생성) | 불필요 | 불필요 |
| 객체 지향성 | 생성자 사용 (불변 객체 가능) | 생성자 사용 (불변 객체 가능) | 필드 직접 주입 / Setter 필요 |
| 실무 권장도 | 1순위 (추천) | 2순위 (아키텍처 중시) | 비권장 |
17. 동적 쿼리 - BooleanBuilder 사용(❌)
실무에서 검색 조건이 다양한 경우 동적 쿼리를 사용한다.
17.1. BooleanBuilder 기본 사용법
@Test
public void dynamicQuery_BooleanBuilder() {
// 동적 파라미터
String usernameParam = "member1";
Integer ageParam = 10;
List<Member> result = searchMember1(usernameParam, ageParam);
assertThat(result).hasSize(1);
assertThat(result.get(0).getUsername()).isEqualTo("member1");
}
private List<Member> searchMember1(String usernameCond, Integer ageCond) {
BooleanBuilder builder = new BooleanBuilder();
// 조건이 있으면 builder에 추가
if (usernameCond != null) {
builder.and(member.username.eq(usernameCond));
}
if (ageCond != null) {
builder.and(member.age.eq(ageCond));
}
return queryFactory
.selectFrom(member)
.where(builder) // BooleanBuilder 적용
.fetch();
}
17.2. 초기 조건 설정
@Test
public void dynamicQuery_BooleanBuilderWithInitial() {
String usernameParam = "member1";
BooleanBuilder builder = new BooleanBuilder(member.username.eq(usernameParam));
// 추가 조건
if (usernameParam != null) {
builder.and(member.age.between(10, 30));
}
List<Member> result = queryFactory
.selectFrom(member)
.where(builder)
.fetch();
assertThat(result).hasSize(1);
}
18. 동적 쿼리 - BooleanExpression 반환 메서드 사용 (⭐⭐)
BooleanBuilder보다 더 깔끔하고 재사용성이 높은 방법이다.
18.1. 기본 사용법
@Test
public void dynamicQuery_WhereParam() {
String usernameParam = "member1";
Integer ageParam = 10;
List<Member> result = searchMember2(usernameParam, ageParam);
assertThat(result).hasSize(1);
assertThat(result.get(0).getUsername()).isEqualTo("member1");
}
private List<Member> searchMember2(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;
}
18.2. 조건 조합
@Test
public void dynamicQuery_WhereParamComposition() {
String usernameParam = "member1";
Integer ageParam = 10;
List<Member> result = queryFactory
.selectFrom(member)
.where(allEq(usernameParam, ageParam)) // 조건 조합
.fetch();
assertThat(result).hasSize(1);
}
// 조건 조합 메서드
private BooleanExpression allEq(String usernameCond, Integer ageCond) {
return usernameEq(usernameCond).and(ageEq(ageCond));
}
18.3. 다양한 조건 조합 예시
// 다양한 조건 메서드들
private BooleanExpression usernameLike(String usernameCond) {
return usernameCond != null ? member.username.contains(usernameCond) : null;
}
private BooleanExpression ageGoe(Integer ageCond) {
return ageCond != null ? member.age.goe(ageCond) : null;
}
private BooleanExpression ageLoe(Integer ageCond) {
return ageCond != null ? member.age.loe(ageCond) : null;
}
private BooleanExpression ageBetween(Integer ageGoeCond, Integer ageLoeCond) {
return ageGoe(ageGoeCond).and(ageLoe(ageLoeCond));
}
Where 다중 파라미터의 장점
- 가독성 향상: 쿼리 자체의 가독성이 높아짐
- 재사용성: 조건 메서드를 다른 쿼리에서도 재사용 가능
- 유지보수성: 조건 변경 시 해당 메서드만 수정
- 조합 가능: 여러 조건을 안전하게 조합 가능
주의사항
- null을 반환하면 조건에서 제외됨
- BooleanExpression을 조합할 때 null 체크 필요
19. 수정, 삭제 벌크 연산
19.1. 수정(Update) 벌크 연산
@Test
@Commit
public void bulkUpdate() {
// 나이가 28살 미만인 회원의 이름을 "비회원"으로 수정
long count = queryFactory
.update(member)
.set(member.username, "비회원")
.where(member.age.lt(28))
.execute();
// 벌크 연산 후 영속성 컨텍스트 초기화
em.flush();
em.clear();
// 검증
List<Member> result = queryFactory
.selectFrom(member)
.where(member.username.eq("비회원"))
.fetch();
assertThat(count).isEqualTo(2); // member1(10), member2(20)
assertThat(result).hasSize(2);
}
19.2. 숫자 연산
@Test
@Commit
public void bulkAdd() {
// 모든 회원의 나이에 1 더하기
long count = queryFactory
.update(member)
.set(member.age, member.age.add(1)) // add() 메서드
.execute();
em.flush();
em.clear();
// 검증
List<Member> result = queryFactory
.selectFrom(member)
.fetch();
assertThat(count).isEqualTo(4);
assertThat(result.get(0).getAge()).isEqualTo(11); // 10 -> 11
}
@Test
@Commit
public void bulkMultiply() {
// 나이를 2배로
long count = queryFactory
.update(member)
.set(member.age, member.age.multiply(2)) // multiply() 메서드
.execute();
em.flush();
em.clear();
// 검증
List<Member> result = queryFactory
.selectFrom(member)
.fetch();
assertThat(count).isEqualTo(4);
assertThat(result.get(0).getAge()).isEqualTo(20); // 10 -> 20
}
19.3. 삭제(Delete) 벌크 연산
@Test
@Commit
public void bulkDelete() {
// 나이가 18살 초과인 회원 삭제
long count = queryFactory
.delete(member)
.where(member.age.gt(18))
.execute();
em.flush();
em.clear();
// 검증
long remainingCount = queryFactory
.selectFrom(member)
.fetchCount();
assertThat(count).isEqualTo(2); // member3(30), member4(40) 삭제
assertThat(remainingCount).isEqualTo(2);
}
19.4. 영속성 컨텍스트 주의사항
@Test
@Commit
public void bulkOperationWithPersistenceContext() {
// 1. 영속성 컨텍스트에 엔티티 로드
Member findMember = queryFactory
.selectFrom(member)
.where(member.username.eq("member1"))
.fetchOne();
// 2. 벌크 연산 실행 (영속성 컨텍스트 무시)
long count = queryFactory
.update(member)
.set(member.username, "비회원")
.where(member.age.lt(28))
.execute();
// 3. 영속성 컨텍스트에는 변경 전 데이터가 남아있음!
System.out.println("영속성 컨텍스트 username: " + findMember.getUsername()); // "member1"
// 4. 영속성 컨텍스트 초기화
em.flush();
em.clear();
// 5. DB에서 다시 조회
Member updatedMember = queryFactory
.selectFrom(member)
.where(member.id.eq(findMember.getId()))
.fetchOne();
System.out.println("DB username: " + updatedMember.getUsername()); // "비회원"
}
벌크 연산 주의사항
- 벌크 연산은 영속성 컨텍스트를 무시하고 직접 DB에 실행
- 연산 후 영속성 컨텍스트 초기화(em.clear()) 권장
- JPQL 배치와 동일한 제약사항 적용
20. SQL function 호출하기
20.1. 사용자 정의 함수 호출
SQL function은 JPA Dialect에 등록된 내용만 호출할 수 있다.
@Test
public void sqlFunction() {
// REPLACE 함수 사용: member -> M
String result = queryFactory
.select(Expressions.stringTemplate(
"function('replace', {0}, {1}, {2})",
member.username, "member", "M"))
.from(member)
.where(member.username.eq("member1"))
.fetchOne();
assertThat(result).isEqualTo("M1");
System.out.println("변경된 이름: " + result);
}
20.2. ANSI 표준 함수
@Test
public void sqlFunction2() {
// LOWER 함수 사용 (ANSI 표준)
List<String> result = queryFactory
.select(member.username)
.from(member)
.where(member.username.eq(
Expressions.stringTemplate("function('lower', {0})", member.username)))
.fetch();
assertThat(result).containsExactly("member1");
}
// QueryDSL 내장 함수 사용 (권장)
@Test
public void sqlFunction3() {
List<String> result = queryFactory
.select(member.username)
.from(member)
.where(member.username.eq(member.username.lower())) // 내장 함수
.fetch();
assertThat(result).containsExactly("member1");
}
20.3. 다양한 내장 함수
QueryDSL은 많은 ANSI 표준 함수를 내장하고 있다.
@Test
public void builtInFunctions() {
// 1. 문자열 함수
List<String> result1 = queryFactory
.select(member.username.upper()) // 대문자로
.from(member)
.fetch();
List<String> result2 = queryFactory
.select(member.username.lower()) // 소문자로
.from(member)
.fetch();
List<String> result3 = queryFactory
.select(member.username.substring(0, 3)) // 부분 문자열
.from(member)
.fetch();
List<Integer> result4 = queryFactory
.select(member.username.length()) // 문자열 길이
.from(member)
.fetch();
// 2. 수학 함수
List<Double> result5 = queryFactory
.select(member.age.sqrt()) // 제곱근
.from(member)
.where(member.age.isNotNull())
.fetch();
// 3. 날짜 함수 (날짜 타입이 있는 경우)
// member.regDate.year()
// member.regDate.month()
// member.regDate.dayOfMonth()
}
20.4. 커스텀 SQL 함수 등록
만약 사용하려는 함수가 Dialect에 등록되어 있지 않다면, 커스텀 Dialect를 생성해야 한다.
// 커스텀 Dialect 예시
public class CustomH2Dialect extends H2Dialect {
public CustomH2Dialect() {
super();
// 함수 등록
registerFunction("custom_function",
new StandardSQLFunction("custom_function", StandardBasicTypes.STRING));
}
}
// application.yml 설정
spring:
jpa:
database-platform: com.example.CustomH2Dialect
결론
QueryDSL 중급 문법을 통해 실무에서 자주 사용하는 다양한 기능을 살펴보았다.
핵심 요약
- 프로젝션과 결과 반환
- 단일 필드: 해당 타입으로 직접 반환
- 다중 필드: Tuple 또는 DTO 사용
- DTO 조회 방법: Setter, Field, Constructor, @QueryProjection
- @QueryProjection이 가장 타입 안전하지만 DTO가 QueryDSL에 의존적이게 됨
- 동적 쿼리
- BooleanBuilder: 간단한 동적 쿼리에 적합
- Where 다중 파라미터: 가독성과 재사용성이 높음, 실무에서 권장
- 벌크 연산
- update(), delete()로 대량 데이터 처리
- 영속성 컨텍스트 초기화 필요
- set() 내에서 산술 연산 가능 (add(), multiply())
- SQL 함수
- ANSI 표준 함수는 QueryDSL 내장 함수 사용
- 사용자 정의 함수는 stringTemplate()으로 호출
- Dialect에 등록된 함수만 사용 가능
실무 적용 팁
- DTO 조회 방식 선택
- 의존성 고려: @QueryProjection > Constructor > Field > Setter
- 안전성: @QueryProjection > Constructor > Field > Setter
- 유연성: Field > Setter > Constructor > @QueryProjection
- 동적 쿼리 작성 패턴
// 권장 패턴 public List<Member> searchMembers(MemberSearchCondition condition) { return queryFactory .selectFrom(member) .where( usernameEq(condition.getUsername()), teamNameEq(condition.getTeamName()), ageGoe(condition.getAgeGoe()), ageLoe(condition.getAgeLoe()) ) .fetch(); } // 조건 메서드는 private으로 숨기고 필요한 곳에서만 노출 private BooleanExpression usernameEq(String username) { return hasText(username) ? member.username.eq(username) : null; } - 벌크 연산 후 처리
@Transactional public void bulkUpdateMembers() { // 벌크 연산 실행 long count = queryFactory.update(...).execute(); // 영속성 컨텍스트 초기화 em.flush(); em.clear(); // 추가 작업... }
'Spring > JPA' 카테고리의 다른 글
| [Advanced-4] QueryDSL(3): 실무 활용 패턴 (0) | 2026.01.07 |
|---|---|
| [Advanced-2] QueryDSL(1): 기본 (0) | 2026.01.06 |
| [Advanced-1] Spring Data Jpa (0) | 2026.01.06 |
| [Basic-9] 객체지향 쿼리 언어(JPQL) (0) | 2026.01.06 |
| [Basic-8] 값 타입 (0) | 2026.01.06 |
