QueryDSL 가이드 (1/8)

다음 편: [QueryDSL] 2. 기본 쿼리 작성

Spring Data JPA의 메서드 이름 기반 쿼리는 단순 조회에는 편리하지만, 조건이 복잡해지면 한계가 온다. @Query로 JPQL을 직접 쓸 수도 있지만 문자열이라 컴파일 타임에 검증이 안 된다. QueryDSL은 이 문제를 타입 안전한 쿼리 빌더로 해결한다.

왜 QueryDSL인가

JPA로 복잡한 조회를 구현하는 방법은 크게 세 가지다.

  • 메서드 이름 쿼리 : findByNameAndAgeGreaterThan 같은 방식. 조건이 3개만 넘어가도 메서드 이름이 끔찍해진다.
  • @Query + JPQL : 문자열로 쿼리를 작성한다. 필드 이름을 오타 내면 런타임에야 터진다. 리팩토링할 때 IDE가 잡아주지 않는다.
  • QueryDSL : Java 코드로 쿼리를 작성한다. 필드 이름을 잘못 쓰면 컴파일 에러가 나고, IDE 자동완성이 된다.

핵심은 컴파일 타임 검증이다. 엔티티 필드가 바뀌면 Q클래스도 함께 바뀌고, 잘못된 참조는 빌드 자체가 실패한다. 런타임에 쿼리가 터지는 사고를 원천 차단하는 셈이다.

graph TD A[JPA 조회 방식 선택] --> B{조건이
복잡한가?} B -- 아니오 --> C[메서드 이름 쿼리] B -- 예 --> D{타입 안전성이
필요한가?} D -- 아니오 --> E["@Query (JPQL)"] D -- 예 --> F[QueryDSL] style F fill:#f9f,stroke:#333,stroke-width:4px

Gradle 의존성 설정

Spring Boot 프로젝트에서 QueryDSL을 사용하려면 두 종류의 의존성이 필요하다.

dependencies {
    // QueryDSL 런타임 라이브러리
    implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"

    // Q클래스 생성용 어노테이션 프로세서
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
}

각 의존성의 역할은 다음과 같다.

  • querydsl-jpa — 실제 쿼리를 빌드하고 실행하는 런타임 라이브러리다. :jakarta classifier는 Spring Boot 3.x 이상에서 Jakarta EE 네임스페이스를 사용하기 때문에 반드시 붙여야 한다.
  • querydsl-apt — 어노테이션 프로세서. 빌드 시점에 @Entity가 붙은 클래스를 스캔해서 Q클래스를 자동 생성한다.
  • jakarta.persistence-api, jakarta.annotation-api — 어노테이션 프로세서가 @Entity, @Table 같은 JPA 어노테이션을 인식하려면 이 의존성이 classpath에 있어야 한다.
querydsl-apt 버전 관리

querydsl-apt의 버전을 ${dependencyManagement.importedProperties['querydsl.version']}으로 지정하면 Spring Boot의 의존성 관리가 자동으로 버전을 맞춰준다. 버전을 하드코딩하면 Spring Boot 업그레이드 시 버전 불일치가 생길 수 있다.

Q클래스 생성 원리

빌드하면 build/generated/sources/annotationProcessor/java/main 경로에 Q클래스가 생성된다. 엔티티 하나당 Q클래스 하나가 만들어진다.

생성 과정은 이렇다.

  1. Gradle이 컴파일을 시작한다.
  2. querydsl-apt 어노테이션 프로세서가 소스 코드에서 @Entity가 붙은 클래스를 찾는다.
  3. 각 엔티티의 필드 정보를 읽어서 Q클래스 소스 코드를 자동 생성한다.
  4. 생성된 Q클래스는 일반 Java 클래스처럼 컴파일되어 사용할 수 있다.
flowchart LR A[Entity 작성] -->|@Entity| B[Gradle Build/Compile] B --> C{APT 스캔} C -->|Q클래스 소스 생성| D[build/generated/...] D --> E[컴파일된 Q클래스 사용] subgraph "Annotation Processing" C D end

예를 들어, 다음과 같은 User 엔티티가 있다고 하자.

@Entity
public class User extends BaseEntity {
    private String name;
    private String email;
}

이 엔티티로부터 QUser 클래스가 생성된다.

public class QUser extends EntityPathBase<User> {

    public static final QUser user = new QUser("user");

    public final StringPath name = createString("name");
    public final StringPath email = createString("email");
    // ...
}

Q클래스 안에는 정적 인스턴스(QUser.user)와 엔티티 필드에 대응하는 Path 필드가 들어있다.

Path 타입

Q클래스의 각 필드는 엔티티 필드의 Java 타입에 따라 다른 Path 타입으로 생성된다. Path 타입마다 사용할 수 있는 메서드가 다르다.

  • StringStringPath
    • like(), contains(), startsWith() 등 문자열 전용 메서드를 제공한다.
  • Integer, LongNumberPath
    • add(), subtract(), avg() 등 숫자 연산 메서드를 제공한다.
  • Instant, LocalDateTimeDateTimePath
    • before(), after() 등 날짜 비교 메서드를 제공한다.
  • UUIDComparablePath
    • eq(), ne() 등 기본 비교 연산 메서드를 제공한다.
  • 연관 엔티티 (@ManyToOne 등) → 해당 엔티티의 Q타입
    • follow.follower.name처럼 중첩 접근이 가능하다.

Path 타입이 곧 타입 안전성의 핵심이다. StringPath에서는 like()를 쓸 수 있지만 NumberPath에서는 쓸 수 없다. 잘못된 연산은 컴파일 에러로 잡힌다.

classDiagram class User { +String name +Long id +LocalDateTime createdAt } class QUser { +StringPath name +NumberPath id +DateTimePath createdAt +static QUser user } User ..> QUser : Generate

static import 패턴

Q클래스를 사용할 때는 정적 인스턴스를 static import하는 것이 관례다.

import static com.example.domain.entity.QUser.user;
import static com.example.domain.entity.QFollow.follow;

이렇게 하면 쿼리를 작성할 때 user.name, follow.createdAt처럼 SQL과 비슷한 느낌으로 깔끔하게 쓸 수 있다.

같은 테이블 셀프 조인

같은 엔티티를 두 번 참조해야 하는 경우에는 static import된 기본 인스턴스를 쓸 수 없다. 별칭이 충돌하기 때문이다. 이때는 새 인스턴스를 직접 만들어야 한다.

```java

QUser userA = new QUser("userA");

QUser userB = new QUser("userB");

```

JPAQueryFactory 설정

QueryDSL로 쿼리를 실행하려면 JPAQueryFactory 빈을 등록해야 한다. 이 팩토리가 EntityManager를 감싸서 타입 안전한 쿼리 빌더를 제공한다.

@Configuration
public class QueryDslConfig {

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
        return new JPAQueryFactory(entityManager);
    }
}

JPAQueryFactory는 내부적으로 EntityManager를 사용해서 JPQL을 생성하고 실행한다. 스프링이 관리하는 EntityManager를 주입받기 때문에 트랜잭션 범위 내에서 동작한다.

동시성 안전

JPAQueryFactory를 싱글톤 빈으로 등록해도 동시성 문제가 없다. 스프링의 EntityManager는 실제로는 프록시 객체이고, 각 트랜잭션마다 다른 실제 EntityManager에 위임하기 때문이다. 여러 스레드가 같은 JPAQueryFactory를 동시에 사용해도 각 요청이 서로 간섭하지 않는다.

sequenceDiagram participant App as Application Code participant JQF as JPAQueryFactory
(Singleton Bean) participant EMP as EntityManager
(Proxy) participant EM1 as Actual EM
(Thread 1) participant EM2 as Actual EM
(Thread 2) rect rgb(240, 240, 240) Note over App, EM1: Thread 1 (T1 Context) App->>JQF: selectFrom(user)... JQF->>EMP: call method EMP->>EM1: Delegate to T1 EM end rect rgb(220, 220, 220) Note over App, EM2: Thread 2 (T2 Context) App->>JQF: selectFrom(user)... JQF->>EMP: call method EMP->>EM2: Delegate to T2 EM end
EntityManager 프록시의 동작 방식

스프링 컨테이너에서 주입받는 EntityManagerSharedEntityManagerInvocationHandler라는 프록시다. 메서드가 호출되면 현재 트랜잭션에 바인딩된 실제 EntityManager를 찾아서 위임한다. 트랜잭션별로 독립된 영속성 컨텍스트를 사용하므로, 싱글톤 빈에 주입해도 안전하다.

첫 번째 쿼리 맛보기

설정이 끝났으니, 간단한 쿼리를 하나 작성해보자.

@RequiredArgsConstructor
@Repository
public class UserQueryRepository {

    private final JPAQueryFactory queryFactory;

    public List<User> findByName(String name) {
        return queryFactory
                .selectFrom(user)
                .where(user.name.eq(name))
                .fetch();
    }
}

selectFrom(user)select(user).from(user)의 축약형이다. where에 조건을 넣고, fetch()로 결과를 가져온다. SQL로 변환하면 SELECT * FROM user WHERE name = ?와 같다.

여기서 userQUser.user를 static import한 것이다. user.nameStringPath 타입이므로 eq(), like(), contains() 같은 메서드를 쓸 수 있다. 다음 편에서 이런 기본 쿼리를 더 자세히 다룬다.

자주 하는 실수

Q클래스가 없다는 컴파일 에러

QUser를 import했는데 "cannot find symbol" 에러가 나는 경우. 빌드를 한 번도 안 했거나, 엔티티를 수정한 뒤 다시 빌드하지 않은 것이다. ./gradlew compileJava를 실행하면 Q클래스가 재생성된다.

[!DANGER] jakarta classifier 누락

querydsl-jpa:5.0.0만 쓰고 :jakarta를 빼먹으면, javax 패키지 기반 라이브러리가 들어온다. Spring Boot 3.x 이상은 jakarta 패키지를 쓰므로 런타임에 ClassNotFoundException이 발생한다. 반드시 :jakarta를 붙여야 한다.

[!DANGER] Lombok과 annotationProcessor 순서

Lombok과 QueryDSL APT를 함께 쓸 때, Lombok의 annotationProcessor가 먼저 선언되어야 한다. 순서가 뒤바뀌면 Lombok이 생성하는 getter/setter가 아직 없는 상태에서 Q클래스를 생성하려 해서 컴파일 에러가 날 수 있다.