다음 편: [QueryDSL] 2. 기본 쿼리 작성
01 기본
1. 환경 설정과 Q클래스 ← 현재 편
2. 기본 쿼리 작성
3. 조인과 서브쿼리
02 실전
4. 동적 쿼리
5. 프로젝션과 DTO 매핑
03 심화
7. 성능 최적화
8. 실전 패턴과 면접 대비
Spring Data JPA의 메서드 이름 기반 쿼리는 단순 조회에는 편리하지만, 조건이 복잡해지면 한계가 온다. @Query로 JPQL을 직접 쓸 수도 있지만 문자열이라 컴파일 타임에 검증이 안 된다. QueryDSL은 이 문제를 타입 안전한 쿼리 빌더로 해결한다.
왜 QueryDSL인가
JPA로 복잡한 조회를 구현하는 방법은 크게 세 가지다.
- 메서드 이름 쿼리 :
findByNameAndAgeGreaterThan같은 방식. 조건이 3개만 넘어가도 메서드 이름이 끔찍해진다. - @Query + JPQL : 문자열로 쿼리를 작성한다. 필드 이름을 오타 내면 런타임에야 터진다. 리팩토링할 때 IDE가 잡아주지 않는다.
- QueryDSL : Java 코드로 쿼리를 작성한다. 필드 이름을 잘못 쓰면 컴파일 에러가 나고, IDE 자동완성이 된다.
핵심은 컴파일 타임 검증이다. 엔티티 필드가 바뀌면 Q클래스도 함께 바뀌고, 잘못된 참조는 빌드 자체가 실패한다. 런타임에 쿼리가 터지는 사고를 원천 차단하는 셈이다.
복잡한가?} 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— 실제 쿼리를 빌드하고 실행하는 런타임 라이브러리다.:jakartaclassifier는 Spring Boot 3.x 이상에서 Jakarta EE 네임스페이스를 사용하기 때문에 반드시 붙여야 한다.querydsl-apt— 어노테이션 프로세서. 빌드 시점에@Entity가 붙은 클래스를 스캔해서 Q클래스를 자동 생성한다.jakarta.persistence-api,jakarta.annotation-api— 어노테이션 프로세서가@Entity,@Table같은 JPA 어노테이션을 인식하려면 이 의존성이 classpath에 있어야 한다.
querydsl-apt의 버전을 ${dependencyManagement.importedProperties['querydsl.version']}으로 지정하면 Spring Boot의 의존성 관리가 자동으로 버전을 맞춰준다. 버전을 하드코딩하면 Spring Boot 업그레이드 시 버전 불일치가 생길 수 있다.
Q클래스 생성 원리
빌드하면 build/generated/sources/annotationProcessor/java/main 경로에 Q클래스가 생성된다. 엔티티 하나당 Q클래스 하나가 만들어진다.
생성 과정은 이렇다.
- Gradle이 컴파일을 시작한다.
querydsl-apt어노테이션 프로세서가 소스 코드에서@Entity가 붙은 클래스를 찾는다.- 각 엔티티의 필드 정보를 읽어서 Q클래스 소스 코드를 자동 생성한다.
- 생성된 Q클래스는 일반 Java 클래스처럼 컴파일되어 사용할 수 있다.
예를 들어, 다음과 같은 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 타입마다 사용할 수 있는 메서드가 다르다.
String→StringPathlike(),contains(),startsWith()등 문자열 전용 메서드를 제공한다.
Integer,Long→NumberPathadd(),subtract(),avg()등 숫자 연산 메서드를 제공한다.
Instant,LocalDateTime→DateTimePathbefore(),after()등 날짜 비교 메서드를 제공한다.
UUID→ComparablePatheq(),ne()등 기본 비교 연산 메서드를 제공한다.
- 연관 엔티티 (
@ManyToOne등) → 해당 엔티티의 Q타입follow.follower.name처럼 중첩 접근이 가능하다.
Path 타입이 곧 타입 안전성의 핵심이다. StringPath에서는 like()를 쓸 수 있지만 NumberPath에서는 쓸 수 없다. 잘못된 연산은 컴파일 에러로 잡힌다.
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를 동시에 사용해도 각 요청이 서로 간섭하지 않는다.
(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는 SharedEntityManagerInvocationHandler라는 프록시다. 메서드가 호출되면 현재 트랜잭션에 바인딩된 실제 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 = ?와 같다.
여기서 user는 QUser.user를 static import한 것이다. user.name은 StringPath 타입이므로 eq(), like(), contains() 같은 메서드를 쓸 수 있다. 다음 편에서 이런 기본 쿼리를 더 자세히 다룬다.
자주 하는 실수
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클래스를 생성하려 해서 컴파일 에러가 날 수 있다.