01 기본
1. 환경 설정과 Q클래스
2. 기본 쿼리 작성 ← 현재 편
3. 조인과 서브쿼리
02 실전
4. 동적 쿼리
5. 프로젝션과 DTO 매핑
03 심화
7. 성능 최적화
8. 실전 패턴과 면접 대비
이전 편에서 환경 설정과 Q클래스의 구조를 파악했다. 이제 실제로 쿼리를 작성하는 법을 다룬다. SQL의 SELECT, WHERE, ORDER BY에 대응하는 QueryDSL API를 하나씩 살펴보자.
조회의 시작점
QueryDSL에서 조회 쿼리는 select와 from으로 시작한다.
// 방법 1: selectFrom — 엔티티 전체 조회 시
List<User> users = queryFactory
.selectFrom(user)
.fetch();
// 방법 2: select + from 분리 — 특정 컬럼만 조회하거나 DTO 매핑 시
List<String> names = queryFactory
.select(user.name)
.from(user)
.fetch();
selectFrom(user)는 select(user).from(user)의 축약형이다. 엔티티 전체를 가져올 때 쓴다. 특정 컬럼만 골라서 조회하려면 select와 from을 분리해야 한다. 프로젝션에 대한 자세한 내용은 프로젝션과 DTO 매핑 편에서 다룬다.
결과 반환 메서드
쿼리를 구성한 뒤 마지막에 호출하는 메서드가 결과를 반환한다.
// 리스트 반환
List<User> users = queryFactory
.selectFrom(user)
.fetch();
// 단건 반환 — 결과가 2건 이상이면 NonUniqueResultException
User singleUser = queryFactory
.selectFrom(user)
.where(user.email.eq("a@b.com"))
.fetchOne();
// 첫 번째 결과만 — 내부적으로 LIMIT 1
User firstUser = queryFactory
.selectFrom(user)
.fetchFirst();
각 메서드의 동작을 정리하면 이렇다.
0건 → 빈 리스트"] FO --> FOR1["0건 → null"] FO --> FOR2["1건 → 객체"] FO --> FOR3["2건+ → 예외 발생"] FF --> FFR["첫 번째 결과
내부: limit 1 + fetchOne"] style FOR3 fill:#f96,stroke:#333
fetch()— 결과를List로 반환한다. 결과가 없으면 빈 리스트를 반환한다.fetchOne()— 결과가 정확히 0건 또는 1건일 때 사용한다. 0건이면null, 1건이면 해당 객체, 2건 이상이면 예외가 발생한다.fetchFirst()— 결과 중 첫 번째만 반환한다. 내부적으로limit(1).fetchOne()과 같다. 결과가 없으면null이다.
QueryDSL 5.0부터 fetchResults()와 fetchCount()는 deprecated되었다. 복잡한 쿼리에서 자동 생성되는 count 쿼리가 정확하지 않은 경우가 있기 때문이다. 카운트가 필요하면 별도의 count 쿼리를 직접 작성하는 것이 안전하다.
```java
long count = queryFactory
.select(user.count())
.from(user)
.fetchOne();
```
where 조건
where에는 BooleanExpression을 전달한다. Q클래스의 Path 필드에서 조건 메서드를 호출하면 BooleanExpression이 만들어진다.
비교 연산자
user.age.eq(25) // age = 25
user.age.ne(25) // age != 25
user.age.gt(25) // age > 25
user.age.goe(25) // age >= 25 (greater or equal)
user.age.lt(25) // age < 25
user.age.loe(25) // age <= 25 (less or equal)
goe와 loe는 각각 "greater or equal", "less or equal"의 약자다. ge, le가 아니라 goe, loe인 점에 주의한다.
문자열 연산자
user.name.like("%kim%") // LIKE '%kim%'
user.name.contains("kim") // LIKE '%kim%' (contains가 %를 자동으로 감싸준다)
user.name.startsWith("kim") // LIKE 'kim%'
user.name.endsWith("kim") // LIKE '%kim'
user.name.eq("kim") // = 'kim' (정확히 일치)
user.name.equalsIgnoreCase("kim") // 대소문자 무시 비교
like()를 쓸 때는 %를 직접 넣어야 한다. contains()는 앞뒤에 %를 자동으로 붙여주므로 더 편리하다.
null 체크와 범위
user.email.isNull() // email IS NULL
user.email.isNotNull() // email IS NOT NULL
user.age.in(20, 25, 30) // age IN (20, 25, 30)
user.age.notIn(20, 25, 30) // age NOT IN (20, 25, 30)
user.age.between(20, 30) // age BETWEEN 20 AND 30
in()에는 가변인자 또는 Collection을 전달할 수 있다.
조건 조합
여러 조건을 조합하는 방법은 두 가지다.
// 방법 1: where에 콤마로 나열 — 모두 AND로 연결
queryFactory
.selectFrom(user)
.where(
user.name.eq("kim"),
user.age.gt(20)
)
.fetch();
// 방법 2: and(), or() 메서드 체이닝
queryFactory
.selectFrom(user)
.where(
user.name.eq("kim")
.and(user.age.gt(20))
.or(user.role.eq(Role.ADMIN))
)
.fetch();
where에 콤마로 조건을 나열하면 자동으로 AND로 연결된다. 이 방식이 가독성이 좋고, null인 조건은 자동으로 무시되기 때문에 동적 쿼리에서도 유용하다. 동적 쿼리의 자세한 내용은 동적 쿼리 편에서 다룬다.
OR 조건이 필요할 때는 or() 메서드를 사용한다.
정렬
orderBy에 정렬 조건을 넣는다.
queryFactory
.selectFrom(user)
.orderBy(user.createdAt.desc())
.fetch();
여러 컬럼으로 정렬할 때는 콤마로 나열한다.
queryFactory
.selectFrom(user)
.orderBy(
user.name.asc(),
user.createdAt.desc()
)
.fetch();
name 오름차순으로 먼저 정렬하고, 같은 이름이면 createdAt 내림차순으로 정렬한다.
null 값 정렬
null 값이 포함된 컬럼을 정렬할 때는 null의 위치를 지정할 수 있다.
user.name.asc().nullsLast() // null을 마지막에 배치
user.name.asc().nullsFirst() // null을 처음에 배치
페이징
offset과 limit으로 페이징을 구현한다.
List<User> users = queryFactory
.selectFrom(user)
.orderBy(user.createdAt.desc())
.offset(20) // 0부터 시작, 20번째부터
.limit(10) // 10건 조회
.fetch();
offset은 건너뛸 행 수, limit은 가져올 최대 행 수다. 페이지 번호 기반이라면 offset = (page - 1) * size, limit = size로 계산하면 된다.
offset이 커질수록 DB가 앞의 행을 모두 읽고 버리는 비용이 생긴다. 데이터가 많을 때는 커서 기반 페이지네이션이 더 효율적이다. 자세한 내용은 커서 기반 페이지네이션 시리즈를 참고한다.
집계
groupBy와 having으로 집계 쿼리를 작성할 수 있다.
List<Tuple> result = queryFactory
.select(user.role, user.count())
.from(user)
.groupBy(user.role)
.having(user.count().gt(5))
.fetch();
이 쿼리는 역할별 사용자 수를 구하되, 5명 이상인 역할만 조회한다. Tuple은 여러 컬럼을 담는 제네릭 컨테이너다.
집계에 사용할 수 있는 함수는 다음과 같다.
count()— 행 수sum()— 합계avg()— 평균max()— 최댓값min()— 최솟값
// 집계 함수 예시
Long totalCount = queryFactory
.select(user.count())
.from(user)
.fetchOne();
Integer maxAge = queryFactory
.select(user.age.max())
.from(user)
.fetchOne();
자주 하는 실수
QueryDSL의 크거나 같음은 goe(), 작거나 같음은 loe()다. ge(), le()라고 쓰면 컴파일 에러가 난다. "greater or equal", "less or equal"의 약자로 기억하면 된다.
[!DANGER] fetchOne()에 결과가 2건 이상
fetchOne()은 결과가 1건이라는 것을 보장할 때만 써야 한다. 유니크하지 않은 조건으로 조회하면 NonUniqueResultException이 발생한다. 확신이 없으면 fetchFirst()를 쓰는 것이 안전하다.
[!DANGER] like()에 와일드카드 누락
user.name.like("kim")은 LIKE 'kim'으로 변환되어 정확히 "kim"과 일치하는 행만 찾는다. 부분 일치를 원한다면 like("%kim%")으로 와일드카드를 직접 넣거나, contains("kim")을 사용해야 한다.