QueryDSL 가이드 (2/8)

이전 편: [QueryDSL] 1. 환경 설정과 Q클래스

다음 편: [QueryDSL] 3. 조인과 서브쿼리

이전 편에서 환경 설정과 Q클래스의 구조를 파악했다. 이제 실제로 쿼리를 작성하는 법을 다룬다. SQL의 SELECT, WHERE, ORDER BY에 대응하는 QueryDSL API를 하나씩 살펴보자.

graph LR A[selectFrom / select] --> B[from] B --> C[join] C --> D[where] D --> E[orderBy] E --> F[offset / limit] F --> G["fetch() / fetchOne() / fetchFirst()"] style G fill:#f9f,stroke:#333,stroke-width:2px

조회의 시작점

QueryDSL에서 조회 쿼리는 selectfrom으로 시작한다.

// 방법 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)의 축약형이다. 엔티티 전체를 가져올 때 쓴다. 특정 컬럼만 골라서 조회하려면 selectfrom을 분리해야 한다. 프로젝션에 대한 자세한 내용은 프로젝션과 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();

각 메서드의 동작을 정리하면 이렇다.

graph TD Q[쿼리 실행] --> F["fetch()"] Q --> FO["fetchOne()"] Q --> FF["fetchFirst()"] F --> FR["List 반환
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이다.
fetchResults()와 fetchCount()의 폐기

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)

goeloe는 각각 "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();
graph LR subgraph "where(조건1, 조건2, 조건3)" C1[조건1] --- AND1(AND) AND1 --- C2[조건2] C2 --- AND2(AND) AND2 --- C3[조건3] end

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을 처음에 배치

페이징

offsetlimit으로 페이징을 구현한다.

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 페이징의 한계

offset이 커질수록 DB가 앞의 행을 모두 읽고 버리는 비용이 생긴다. 데이터가 많을 때는 커서 기반 페이지네이션이 더 효율적이다. 자세한 내용은 커서 기반 페이지네이션 시리즈를 참고한다.

집계

groupByhaving으로 집계 쿼리를 작성할 수 있다.

List<Tuple> result = queryFactory
        .select(user.role, user.count())
        .from(user)
        .groupBy(user.role)
        .having(user.count().gt(5))
        .fetch();

이 쿼리는 역할별 사용자 수를 구하되, 5명 이상인 역할만 조회한다. Tuple은 여러 컬럼을 담는 제네릭 컨테이너다.

graph LR D[데이터] --> G["groupBy(기준)"] G --> H["having(조건)"] H --> R[집계 결과] subgraph "집계 함수" count --- sum sum --- avg avg --- max max --- min end

집계에 사용할 수 있는 함수는 다음과 같다.

  • count() — 행 수
  • sum() — 합계
  • avg() — 평균
  • max() — 최댓값
  • min() — 최솟값
// 집계 함수 예시
Long totalCount = queryFactory
        .select(user.count())
        .from(user)
        .fetchOne();

Integer maxAge = queryFactory
        .select(user.age.max())
        .from(user)
        .fetchOne();

자주 하는 실수

goe/loe를 ge/le로 착각

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")을 사용해야 한다.