지난 몇 개월간 프로젝트를 진행하면서 팀 내에서 서로 다른 기술 스택을 사용하는 일이 많았습니다. 예를 들어 어떤 부분은 MyBatis 매퍼로, 또 다른 부분은 JPA로 구현하다 보니, 같은 프로젝트 안에서도 코드 스타일과 쿼리 작성 방식이 제각각이었죠. 처음에는 큰 문제가 없어 보였지만, 프로젝트가 길어질수록 유지보수와 협업에 불편함이 점점 더 커졌습니다.
그래서 우리는 데이터 접근 방식을 JPA와 QueryDSL로 통일하기로 결정했습니다.
그 이유는 단순합니다. 표준화된 방식으로 타입 안전한 쿼리를 작성할 수 있기 때문이죠.
Spring Boot 환경에서 JPA를 사용하다 보면 단순한 CRUD는 findByName, findByAgeGreaterThan 같은 메서드 네이밍 규칙만으로도 충분히 처리할 수 있습니다.
하지만 실무에서는 점점 더 복잡한 요구사항이 등장합니다.
group by, count)이럴 때 순수 JPA의 JPQL이나 Criteria API를 그대로 사용하면 다음과 같은 한계가 있습니다.
JPQL
- 문자열 기반이라 컴파일 타임에 오류를 잡을 수 없음
- IDE 자동완성 지원이 전혀 없어 오타에 취약함
Criteria API
- 타입 안전하지만 코드가 장황하고 가독성이 떨어짐
이 문제를 해결해주는 도구가 바로 QueryDSL입니다.
QueryDSL은 JPA와 결합해 SQL과 유사한 문법으로 타입 안전한 쿼리를 작성할 수 있도록 해줍니다. IDE의 자동완성 기능도 지원하기 때문에 실무에서 발생하는 다양한 조회 요구사항을 훨씬 깔끔하게 풀어낼 수 있습니다.
예를 들어, JPQL은 이렇게 작성합니다.
String qlString = "select u from User u where u.name = :name";
List<User> result = em.createQuery(qlString, User.class)
.setParameter("name", "Shayne")
.getResultList();
반면 QueryDSL은 이렇게 작성합니다.
QUser user = QUser.user;
List<User> result = queryFactory
.selectFrom(user)
.where(user.name.eq("Shayne"))
.fetch();
QueryDSL의 핵심 장점은 컴파일 타임 오류 감지 + 자동완성 지원입니다.
쿼리 오류를 애플리케이션 실행 전에 IDE가 잡아주고, 엔티티 필드를 그대로 자동완성으로 불러올 수 있습니다.
Spring Boot 3.x 기준 Gradle 설정 예시는 다음과 같습니다.
plugins {
id 'org.springframework.boot' version '3.2.3'
id 'io.spring.dependency-management' version '1.1.4'
id 'java'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
runtimeOnly 'com.h2database:h2'
}
빌드 후 ./gradlew compileJava를 실행하면 build/generated 디렉토리에 QUser 같은 Q클래스가 자동 생성됩니다.
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String name;
private int age;
}
public class QUser extends EntityPathBase<User> {
public static final QUser user = new QUser("user");
public final StringPath name = createString("name");
public final NumberPath<Integer> age = createNumber("age", Integer.class);
}
QUser.user를 통해 엔티티의 모든 필드를 타입 안전하게 사용할 수 있습니다.
QUser user = QUser.user;
List<User> result = queryFactory
.selectFrom(user)
.where(user.age.gt(20))
.fetch();
public List<User> search(String name, Integer age) {
QUser user = QUser.user;
return queryFactory
.selectFrom(user)
.where(
name != null ? user.name.eq(name) : null,
age != null ? user.age.gt(age) : null
)
.fetch();
}
public Page<User> findUsers(Pageable pageable) {
QUser user = QUser.user;
List<User> content = queryFactory
.selectFrom(user)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
long total = queryFactory
.select(user.count())
.from(user)
.fetchOne();
return new PageImpl<>(content, pageable, total);
}
QUser user = QUser.user;
QOrder order = QOrder.order;
List<User> result = queryFactory
.selectFrom(user)
.join(user.orders, order) // User -> Order 관계 매핑 기준
.where(order.price.gt(10000))
.fetch();
QueryDSL은 조인 조건도 IDE 자동완성으로 작성 가능하다는 점이 큰 장점입니다.
fetch join을 QueryDSL에서 그대로 사용 가능queryFactory
.selectFrom(user)
.join(user.orders, order).fetchJoin()
.fetch();
queryFactory
.select(Projections.constructor(UserDto.class,
user.id, user.name, user.age))
.from(user)
.fetch();
JPA와 QueryDSL을 함께 사용한다면
등 실무에서 필요한 기능을 안정적으로 구현할 수 있습니다.
특히 대규모 프로젝트에서 서비스 로직 안에 다양한 조회 조건이 필요할 때, QueryDSL은 표준처럼 자리잡은 선택지입니다. BooleanBuilder, Case 문, groupBy 같은 기능까지 익힌다면, JPQL만으로는 감당하기 힘든 복잡한 요구사항을 QueryDSL로 훨씬 깔끔하게 처리할 수 있습니다.
즉, JPA + QueryDSL은 단순한 대체제가 아니라, 실무에서 생산성과 안정성을 동시에 잡을 수 있는 툴입니다.