JPA와 QueryDSL로 타입 안전한 쿼리 작성하기

ShaynePark·2025년 9월 27일

SpringBoot

목록 보기
1/4

지난 몇 개월간 프로젝트를 진행하면서 팀 내에서 서로 다른 기술 스택을 사용하는 일이 많았습니다. 예를 들어 어떤 부분은 MyBatis 매퍼로, 또 다른 부분은 JPA로 구현하다 보니, 같은 프로젝트 안에서도 코드 스타일과 쿼리 작성 방식이 제각각이었죠. 처음에는 큰 문제가 없어 보였지만, 프로젝트가 길어질수록 유지보수와 협업에 불편함이 점점 더 커졌습니다.

그래서 우리는 데이터 접근 방식을 JPA와 QueryDSL로 통일하기로 결정했습니다.
그 이유는 단순합니다. 표준화된 방식으로 타입 안전한 쿼리를 작성할 수 있기 때문이죠.


왜 JPA + QueryDSL을 선택했나?

Spring Boot 환경에서 JPA를 사용하다 보면 단순한 CRUD는 findByName, findByAgeGreaterThan 같은 메서드 네이밍 규칙만으로도 충분히 처리할 수 있습니다.
하지만 실무에서는 점점 더 복잡한 요구사항이 등장합니다.

  • 여러 조건을 조합해야 하는 동적 검색
  • 통계성 데이터 조회 (예: group by, count)
  • 복잡한 조인(join)
  • 페이징 처리

이럴 때 순수 JPA의 JPQL이나 Criteria API를 그대로 사용하면 다음과 같은 한계가 있습니다.

  • JPQL
    - 문자열 기반이라 컴파일 타임에 오류를 잡을 수 없음
    - IDE 자동완성 지원이 전혀 없어 오타에 취약함

  • Criteria API
    - 타입 안전하지만 코드가 장황하고 가독성이 떨어짐

이 문제를 해결해주는 도구가 바로 QueryDSL입니다.

QueryDSL은 JPA와 결합해 SQL과 유사한 문법으로 타입 안전한 쿼리를 작성할 수 있도록 해줍니다. IDE의 자동완성 기능도 지원하기 때문에 실무에서 발생하는 다양한 조회 요구사항을 훨씬 깔끔하게 풀어낼 수 있습니다.


QueryDSL이란?

예를 들어, 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클래스가 자동 생성됩니다.


엔티티와 Q클래스

엔티티 예시

@Entity
public class User {
    @Id @GeneratedValue
    private Long id;

    private String name;
    private int age;
}

생성된 Q클래스

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를 통해 엔티티의 모든 필드를 타입 안전하게 사용할 수 있습니다.


QueryDSL 기본 문법

단순 조회

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 자동완성으로 작성 가능하다는 점이 큰 장점입니다.


성능 최적화

fetchJoin 활용

  • N+1 문제 해결을 위해 JPA의 fetch join을 QueryDSL에서 그대로 사용 가능
queryFactory
    .selectFrom(user)
    .join(user.orders, order).fetchJoin()
    .fetch();

DTO 직접 매핑

  • JPQL에서는 DTO 생성이 불편했지만, QueryDSL은 생성자/필드 기반 매핑을 지원
queryFactory
    .select(Projections.constructor(UserDto.class,
            user.id, user.name, user.age))
    .from(user)
    .fetch();

JPA와 QueryDSL을 함께 사용한다면

  • 타입 안전한 쿼리 작성
  • IDE 자동완성 활용
  • 동적 쿼리의 간결한 처리
  • 복잡한 조인/페이징/DTO 매핑 지원

등 실무에서 필요한 기능을 안정적으로 구현할 수 있습니다.

특히 대규모 프로젝트에서 서비스 로직 안에 다양한 조회 조건이 필요할 때, QueryDSL은 표준처럼 자리잡은 선택지입니다. BooleanBuilder, Case 문, groupBy 같은 기능까지 익힌다면, JPQL만으로는 감당하기 힘든 복잡한 요구사항을 QueryDSL로 훨씬 깔끔하게 처리할 수 있습니다.

즉, JPA + QueryDSL은 단순한 대체제가 아니라, 실무에서 생산성과 안정성을 동시에 잡을 수 있는 툴입니다.

0개의 댓글