베니고 (Vennygo)베니고 (Vennygo)

당근 좋아하는 개발자가 쓰는 블로그

📮 새 글 받아보기

새 글이 올라오면 하루에 한 번 이메일로 알려드려요.

확인 메일을 열어보면 구독이 완료돼요.

RSS

리더 앱에 붙여 넣거나 바로 구독하세요.

전체Tech
소개문의개인정보약관

© 2026 베니고 (Vennygo)

베니고 (Vennygo)vennygo
Tech▾
로그인
  1. 홈
  2. /Tech
  3. /3배 빠른 개발을 위한 JPA QueryDSL 사용법과 JPQL 차이
Backend

3배 빠른 개발을 위한 JPA QueryDSL 사용법과 JPQL 차이

2026.04.27 01:07 · 13분 읽기 · 1

3배 빠른 개발을 위한 JPA QueryDSL 사용법과 JPQL 차이

"아니, 컴파일 시점에 쿼리 오류를 못 잡는다고요?" 런타임에서 터진 오타 섞인 JPQL 때문에 고생했던적이 있었는데요🥲 그래서 이 글에서는 JPA QueryDSL 사용법과, 동적 쿼리 지옥에서 탈출하는법을 작성하려합니다.

🐰 JPA QueryDSL 사용법, 왜 JPQL만으로는 부족할까?

JPA를 쓰다 보면 필연적으로 복잡한 검색 조건을 만나게 돼요. 처음에는 @Query 어노테이션으로 해결되지만, 조건이 하나둘 늘어나면 문자열 더하기 연산이 시작됩니다. 10명 중 9명의 개발자가 여기서 실수를 하더라고요. 오타 하나 때문에 서버가 뻗는 경험, 다들 한 번쯤은 있잖아요?

QueryDSL은 쿼리를 코드로 짜게 해줘요. 즉, 오타가 나면 아예 컴파일 자체가 안 된다는 뜻이죠. 자바의 타입 안정성을 쿼리까지 확장하는 것이 QueryDSL의 핵심 가치예요. 가독성도 훨씬 좋아지고, 메서드 추출을 통해 쿼리 재사용도 가능해지거든요.

💡 QueryDSL을 도입하면 IDE의 자동 완성 기능을 쿼리 작성 시에도 100% 활용할 수 있어 개발 속도가 올라갑니다.

// JPQL 코드 예시
String jpql = "SELECT m FROM Member m WHERE m.username = :username";

Member result = em.createQuery(jpql, Member.class)
    .setParameter("username", "venny")
    .getSingleResult();
// QueryDSL 코드 예시
QMember m = QMember.member;

Member result = queryFactory
    .selectFrom(m)
    .where(m.username.eq("venny"))
    .fetchOne();

🐰 JPA QueryDSL 설정법

스프링 부트 3.x 버전으로 넘어오면서 설정 방식이 살짝 바뀌었어요. jakarta 패키지를 사용하는 최신 환경에 맞는 build.gradle 설정이 중요해요.

아래 코드는 스프링 부트 3.2.x 환경기준이에요

dependencies {
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

clean {
    delete file('src/main/generated')
}

이 설정은 QueryDSL 5.0 버전과 Jakarta API를 연동하여 Q클래스를 생성해 줍니다.

src/main/generated 폴더에 엔티티 이름 앞에 Q가 붙은 클래스들이 생겼다면 성공이에요. 설정 단계에서 가장 흔한 실수는 의존성 라이브러리의 버전을 맞추지 않는 것이니 주의해야 해요.

⚠️ build.gradle 변경 후에는 반드시 프로젝트 리로드를 수행해야 IDE가 생성된 Q클래스를 인식할 수 있습니다.

🐰 복잡한 동적 쿼리도 끄떡없는 JPA QueryDSL 사용 예제

실무에서는 보통 CustomRepository 패턴을 사용해요. 기존 JpaRepository의 기능을 유지하면서 QueryDSL의 강력함을 더하는 방식이죠. 인터페이스와 구현체를 분리하여 계층 구조를 깔끔하게 유지하는 것이 유지보수의 핵심이더라고요.

먼저 JPAQueryFactory를 빈으로 등록하는 설정이 필요해요.

@Configuration
public class QuerydslConfig {
    @PersistenceContext
    private EntityManager entityManager;

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

QueryDSL의 모든 쿼리는 이 JPAQueryFactory를 시작점으로 해서 만들어집니다.

이제 실제 사용자 이름과 나이를 조건으로 검색하는 기능을 만들어보아요

@Repository
@RequiredArgsConstructor
public class MemberTestRepository {
    private final JPAQueryFactory queryFactory;

    public List<Member> findMembers(String name, Integer age) {
        QMember member = QMember.member;

        return queryFactory
                .selectFrom(member)
                .where(
                        nameEq(name),
                        ageEq(age)
                )
                .fetch();
    }

    private BooleanExpression nameEq(String name) {
        return name != null ? QMember.member.username.eq(name) : null;
    }

    private BooleanExpression ageEq(Integer age) {
        return age != null ? QMember.member.age.eq(age) : null;
    }
}

BooleanExpression을 활용하면 조건이 null일 경우 무시되어 자연스럽게 동적 쿼리가 완성됩니다.

깔끔하죠? where 절에 콤마(,)를 사용하면 자동으로 and 조건으로 묶여요. 근데 만약 or 조건이 필요하다면 .or() 메서드를 명시적으로 호출해주면 돼요. 이런 방식은 if-else 문으로 쿼리 문자열을 덕지덕지 붙이던 과거의 방식보다 훨씬 더 안전합니다.

BooleanExpression을 활용한 동적 쿼리 구조는 재사용이 가능하고, 아래처럼 조합이 가능한 장점이 있답니다

private BooleanExpression ageBetween(Integer age) {
    return ageGoe(age).and(ageLoe(age));
}

🐰 실무에서 바로 써먹는 JPA QueryDSL 최적화 전략

단순 조회를 넘어 실무에서는 성능 최적화가 필수예요. 특히 N+1 문제를 해결하기 위한 fetchJoin()은 필수 코스죠. 하지만 모든 곳에 fetchJoin을 남발하면 데이터 전송량이 너무 많아질 수 있어요. 상황에 따라 필요한 컬럼만 뽑아내는 DTO 프로젝션을 적절히 섞어 써야 해요.

Projections.bean()이나 Projections.constructor()를 쓰면 엔티티 전체가 아닌 특정 필드만 조회할 수 있어요. 데이터가 100만 건 이상 넘어가는 대용량 테이블에서는 이런 디테일이 성능 차이를 가르더라고요.

public List<MemberDto> findMemberDtos() {
    return queryFactory
            .select(Projections.constructor(MemberDto.class,
                    QMember.member.username,
                    QMember.member.age))
            .from(QMember.member)
            .fetch();
}

대신 DTO의 생성자 파라미터 순서와 QueryDSL의 select 절 순서를 꼭 맞춰줘야해요. 🫥순서를 틀리면 컴파일은 통과하는데 런타임에서 오류나요..🥲

🐇 그래서 이거보다 더 🙋‍♀️추천하는 안전한 방법이 있어요

@QueryProjection

public class MemberDto {

    private String username;
    private int age;

    @QueryProjection
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

사용

List<MemberDto> result = queryFactory
    .select(new QMemberDto(member.username, member.age))
    .from(member)
    .fetch();

이건 컴파일 시 타입을 완전 검증하기 때문에 더 안전하게 사용이 가능해서 실무에서 추천해요

🐰! 또한, 최적화 전략으로 페이징 처리도 빼놓을 수 없죠. offset과 limit을 사용하면 되는데, 전체 카운트 쿼리를 별도로 분리해서 최적화하는 기법도 자주 쓰여요. 데이터가 많을 때는 카운트 쿼리를 생략하거나 캐싱하는 것만으로도 성능을 2배 이상 올릴 수 있거든요.

queryDSL 페이징 조회 예시

public Page<MemberDto> search(MemberSearchCond cond, Pageable pageable) {

    List<MemberDto> content = queryFactory
        .select(new QMemberDto(member.username, member.age))
        .from(member)
        .where(
            usernameEq(cond.getUsername()),
            ageGoe(cond.getAgeGoe())
        )
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();

    JPAQuery<Long> countQuery = queryFactory
        .select(member.count())
        .from(member)
        .where(
            usernameEq(cond.getUsername()),
            ageGoe(cond.getAgeGoe())
        );

    return PageableExecutionUtils.getPage(
        content,
        pageable,
        countQuery::fetchOne
    );
}

🐰 핵심 3가지 요약

  1. QueryDSL은 컴파일 시점에 쿼리 오류를 잡아주어 런타임 에러를 99% 방지해요.
  2. BooleanExpression을 사용한 메서드 추출 방식으로 가독성 높은 동적 쿼리를 작성해요.
  3. 성능 최적화를 위해 fetchJoin과 DTO 프로젝션을 상황에 맞게 활용하는 습관이 중요해요.

🐰 FAQ

Q1. Q클래스가 생성이 안 돼요. 어떻게 하죠? Gradle 탭에서 clean 태스크를 먼저 실행한 후 compileJava를 다시 실행해 보세요. 그래도 안 된다면 File > Settings > Build, Execution, Deployment > Annotation Processors에서 Enable annotation processing이 체크되어 있는지 확인이 필요해요.

Q2. BooleanBuilder와 BooleanExpression 중 무엇이 더 좋나요? 가급적 BooleanExpression을 권장해요. 메서드 체이닝이 가능하고, null을 반환하면 where 절에서 무시되기 때문에 코드가 훨씬 직관적이고 재사용하기 편하거든요.

Q3. 성능상 JPQL보다 느리지는 않나요? QueryDSL은 결국 내부적으로 JPQL을 생성해 주는 라이브러리일 뿐이에요. 따라서 실행 시점의 성능 차이는 거의 없다고 보셔도 무방합니다. 오히려 최적화된 쿼리를 짜기 쉬워서 전체적인 시스템 성능은 더 좋아지는 경우가 많아요.

#Java#JPA

이어서 읽기 좋은 글

[Java] REST API 란? 쉽게 시작하기

3가지로 끝내는 JPA 성능 최적화 방법

이전 글3가지로 끝내는 JPA 성능 최적화 방법
다음 글: 없음

댓글

불러오는 중…