JPA Specification -> QueryDSL

하루·2025년 10월 14일

JAVA

목록 보기
6/8

배경

학교 외부 팀플을 진행하는데, 나는 항상 하던대로, 한쪽에는 AI(코딩은 GEMINI가 더 잘하더라)를 켜두고 뭣도 모르고 개발을 했었다.

나는 게시과 관련된 기능들을 개발했었는데, 이 과정에서 JPA Specification을 사용해서 검색 필터 기능을 개발했다.

그러던 중, 팀원 중 한명이 "왜 QueryDSL"을 사용하지 않았냐 물어서, 그때 처음으로 QueryDSL을 찾아봤다.

그렇다... QueryDSL을 사용했어야 하는 것이었다.

개념

JPA Specification

JPA는 여러번 나왔다. Java Persistence API로 데이터베이스와 어플리케이션을 연결해주는 프레임워크, API이다. 그러면 그 기능 중 하나가 JPA SPecification이다. JPA Specification은 Criteria API기반으로 생성된 JPA 제공 인터페이스이기에 일단 Criteria API에 대해서 보겠다.

Criteria API는 CriteriaBuilder를 사용하여 조건식을 객체 지향적으로 구성할 수 있다.
두 개 이상의 조건은 cb.and()나 cb.or() 메서드를 통해 조합할 수 있으며, 복잡한 SQL WHERE 절을 자바 코드로 안전하게 표현한다.
주로 단순한 AND/OR 검색 필터나 동적 조건 조합에 활용된다.

이러한 Criteria API를 조금 더 복잡하고 다양한 동적 검색에 사용하기 위해서 객체 지향적으로 쉽게 구현한 것이 바로 JPA Specification이다.

JPA Specification은 진짜 where 조건만 딱딱 붙여서 동적 쿼리 만들 때 쓰는 거다. Criteria API 기반이라 코드가 좀 지저분해지고, 복잡한 join이나 group by, projection 같은 건 거의 못 한다.

fetch joinHibernate에서 공식적으로 지원 안 해서, 실무에서는 한계가 명확하다. 그래서 진짜 단순한 검색, 필터링 아니면 잘 안 쓴다.

  • 실제 사용 예시
public class PostSpec {
    public static Specification<Post> titleContains(String keyword) {
        return (root, query, builder) ->
                builder.like(root.get("title"), "%" + keyword + "%");
    }
    // etc.
}

postRepository.findAll(
    Specification.where(PostSpec.titleContains("검색어"))
        .and(PostSpec.categoryEq("카테고리"))
);

사용은 가능하지만 실무에서는 한계가 존재한다.

  • 단순 Where 조합에는 유리하지만
  • 복잡한 조인, 서브쿼리, 프로젝션, 그룹핑 등은 구현이 불편하거나 안된다.
  • fetch join 쿼리와의 호환(특히 Spring Data JPA와 Hibernate)은 오늘날 제한적이고, 공식적으로 중단된 경우도 있다.

JPA Specification 사용 방법

  1. JpaSpecificationExecutor<T>를 Repository에 상속시킨다.
  2. Specification 구현체를 만든다
  3. findAll, count 등을 전달해서 쿼리를 조합한다.

아직 쓰는 경우가 많지 않지만, 정말 단순한 and/or 필터 정도에는 쓸 수 있다. 하지만, 실무에서는 거의 QueryDSL 선호한다.

QueryDSL

QueryDSL은 Java 코드로 (타입 안전하게) SQL-like 쿼리를 조립하는 DSL(Domain Specific Language)이다.
이 덕분에 compile time 안전하게 복잡한 조건, 조인, group by, order by, projection(필드 일부만 매핑), 서브쿼리 등등 거의 모든 쿼리 요구를 코드로 표현할 수 있다.

외부 모듈로서, Spring Data JPA와 쉽게 연동 가능하다.

Q-File 생성 → JPAQueryFactory → 코드로 selectFrom, where 등을 쌓아 조인/필터/프로젝션 쿼리 구현.

장점

  • 복잡한 쿼리, join, projection, group by, order by, 서브 쿼리 등 지원 폭이 넓다.

  • 컴파일러가 자동으로 필드, 엔티티 타입을 체크해주므로(오타 방지, 타입 안정성) 런타임 에러 위험이 크게 줄어든다.

  • 코드로 쿼리 작성해 가독성과 생산성이 좋다.

  • BooleanExpression 등 조건 쿼리의 동적 조립이 매우 쉽다.

단점

  • Q파일 자동 생성이 번거로울 수 있고, 빌드 차이/환경 이슈 등 사소한 세팅이 필요하다.

  • 간단한 where만 필요하다면 살짝 오버스펙일 수 있음.

QueryDSL에서 쿼리를 생성하는 방법

JPAQueryFactory는 쿼리 생성 공장이다.
EntityManager를 받아서, selectFrom, where, orderBy, groupBy 등 SQL문을 메서드 체이닝으로 구현할 수 있게 해준다.
즉, 쿼리의 시작점이자, 쿼리 빌더 역할을 한다.

QFile(Q클래스)은 각 엔티티마다 자동 생성되는 메타데이터 클래스다.
예를 들어 User 엔티티가 있으면 QUser가 만들어진다.
QUser에는 User의 모든 필드가 타입 안전하게 변수로 들어가 있어서, 쿼리 조건을 코드로 작성할 때 사용한다.

이 두 가지가 합쳐져서, 아래처럼 쿼리를 만든다.

JPAQueryFactory queryFactory = new JPAQueryFactory(em); // 쿼리 공장
QUser user = QUser.user; // QFile(메타데이터)

User findUser = queryFactory
    .selectFrom(user)
    .where(user.username.eq("홍길동")) // Q클래스의 필드로 조건 작성
    .fetchOne();

이렇게 하면 실제로는

SELECT * FROM user WHERE username = '홍길동'

이런 SQL이 만들어진다.

Q클래스 덕분에 필드 오타, 타입 오류 없이 IDE 자동완성으로 쿼리 조건을 안전하게 작성할 수 있다.
JPAQueryFactory는 쿼리의 시작점, Q클래스는 조건/필드/조인 등 쿼리의 재료 역할을 한다.

QueryDSL 사용 방법

여기서부터는 내 프로젝트에서 사용된 코드를 첨부해서 설명하겠다.

  1. 의존성 및 빌드 설정

build.gradle


dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-web'
    /** QueryDSL 관련 의존성 추가 **/
	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"
	annotationProcessor 'org.projectlombok:lombok'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	runtimeOnly 'com.mysql:mysql-connector-j'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
  1. Q-클래스 자동 생성

이 과정을 통해 각 Entity마다 QEntity 파일이 src/main/generated에 자동 생성됨.

build.gradle


// QueryDSL 관련 설정

// QueryDSL Q파일이 생성될 폴더를 지정합니다.
def generated = 'src/main/generated'

tasks.withType(JavaCompile) {
	options.getGeneratedSourceOutputDirectory().set(file(generated))
}

// 3. 생성된 폴더를 소스 폴더로 인식시킵니다.
sourceSets {
	main {
		java {
			srcDirs += [generated]
		}
	}
}

// 4. clean 작업 시 생성된 폴더도 함께 삭제되도록 설정합니다.
clean {
	delete file(generated)
}

이렇게 되면 querydsl에서 사용해야 하는 QFILE이 자동으로 각 entity에 대해서 생성이 된다.

  1. Repository Custom(인터페이스 선언) & Impl(구현) 분리

3-1. repository 파일 이름 + custom을 사용해서 interface 선언하고, 여기에서 QueryDsl 구현 기능 추가

package com.gonggoo.gonggoo.coopost.repository;

import com.gonggoo.gonggoo.coopost.domain.Coopost;
import com.gonggoo.gonggoo.coopost.domain.CoopostCategory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

import java.time.LocalDateTime;
import java.util.UUID;

public interface CoopostRepositoryCustom {
    Page<Coopost> search(String keyword, CoopostCategory category, String location, LocalDateTime cursor, Pageable pageable);
    Page<Coopost> findMyPosts(UUID authorId, LocalDateTime cursor, Pageable pageable);
    Page<Coopost> findPopular(Long viewCountCursor, UUID idCursor, Pageable pageable);
}

3-2. Custom interface를 구현한 Impl 클래스 생성

package com.gonggoo.gonggoo.coopost.repository;

import com.gonggoo.gonggoo.coopost.domain.Coopost;
import com.gonggoo.gonggoo.coopost.domain.CoopostCategory;
import com.gonggoo.gonggoo.coopost.domain.CoopostStatus;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.support.PageableExecutionUtils;

import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;

import static com.gonggoo.gonggoo.coopost.domain.QCoopost.coopost; // QCoopost import

public class CoopostRepositoryImpl implements CoopostRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    public CoopostRepositoryImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    @Override
    public Page<Coopost> search(String keyword, CoopostCategory category, String location, LocalDateTime cursor, Pageable pageable) {
        List<Coopost> content = queryFactory
                .selectFrom(coopost)
                .where(
                        ltCursor(cursor),
                        coopost.status.ne(CoopostStatus.DELETED),
                        keywordContains(keyword),
                        categoryEq(category),
                        locationEq(location)
                )
                .orderBy(coopost.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Long> countQuery = queryFactory
                .select(coopost.count())
                .from(coopost)
                .where(
                        coopost.status.ne(CoopostStatus.DELETED),
                        keywordContains(keyword),
                        categoryEq(category),
                        locationEq(location)
                );

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

    @Override
    public Page<Coopost> findMyPosts(UUID authorId, LocalDateTime cursor, Pageable pageable) {
        List<Coopost> content = queryFactory
                .selectFrom(coopost)
                .where(
                        ltCursor(cursor),
                        coopost.status.ne(CoopostStatus.DELETED),
                        coopost.authorId.eq(authorId)
                )
                .orderBy(coopost.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Long> countQuery = queryFactory
                .select(coopost.count())
                .from(coopost)
                .where(
                        coopost.status.ne(CoopostStatus.DELETED),
                        coopost.authorId.eq(authorId)
                );

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

    @Override
    public Page<Coopost> findPopular(Long viewCountCursor, UUID idCursor, Pageable pageable) {
        List<Coopost> content = queryFactory
                .selectFrom(coopost)
                .where(
                        coopost.status.ne(CoopostStatus.DELETED),
                        popularCursor(viewCountCursor, idCursor) // 커서 조건
                )
                .orderBy(coopost.viewCount.desc(), coopost.coopostId.desc())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Long> countQuery = queryFactory
                .select(coopost.count())
                .from(coopost)
                .where(coopost.status.ne(CoopostStatus.DELETED));

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

    // === BooleanExpression을 사용한 동적 쿼리 메서드들 ===

    private BooleanExpression ltCursor(LocalDateTime cursor) {
        return cursor != null ? coopost.createdAt.before(cursor) : null;
    }
    
    

    private BooleanExpression popularCursor(Long viewCountCursor, UUID idCursor) {
        if (viewCountCursor == null || idCursor == null) {
            return null; // 커서가 없으면 조건 없음 (첫 페이지)
        }
        // (viewCount < cursor) OR (viewCount = cursor AND coopostId < cursor)
        return coopost.viewCount.lt(viewCountCursor)
                .or(coopost.viewCount.eq(viewCountCursor).and(coopost.coopostId.lt(idCursor)));
    }

    private BooleanExpression keywordContains(String keyword) {
        if (keyword == null || keyword.trim().isEmpty()) {
            return null;
        }
        return coopost.title.containsIgnoreCase(keyword)
                .or(coopost.content.containsIgnoreCase(keyword));
    }

    private BooleanExpression categoryEq(CoopostCategory category) {
        return category != null ? coopost.category.eq(category) : null;
    }

    private BooleanExpression locationEq(String location) {
        return location != null && !location.trim().isEmpty() ? coopost.location.eq(location) : null;
    }
}
  1. Repository에서 사용
// 서비스에서 호출
Page<Coopost> posts = coopostRepository.search(
    "검색어", 
    CoopostCategory.STUDY, 
    "서울", 
    LocalDateTime.now(), 
    PageRequest.of(0, 10)
);

결론

QueryDSL이 훨씬 강력하고 유지보수도 쉽기 때문이다.
JPA Specification은 정말 단순한 where 조건만 필요할 때만 쓰고,
대부분의 실무에서는 QueryDSL을 쓰는 게 맞다.

0개의 댓글