
학교 외부 팀플을 진행하는데, 나는 항상 하던대로, 한쪽에는 AI(코딩은 GEMINI가 더 잘하더라)를 켜두고 뭣도 모르고 개발을 했었다.
나는 게시과 관련된 기능들을 개발했었는데, 이 과정에서 JPA Specification을 사용해서 검색 필터 기능을 개발했다.
그러던 중, 팀원 중 한명이 "왜 QueryDSL"을 사용하지 않았냐 물어서, 그때 처음으로 QueryDSL을 찾아봤다.
그렇다... QueryDSL을 사용했어야 하는 것이었다.
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 join도 Hibernate에서 공식적으로 지원 안 해서, 실무에서는 한계가 명확하다. 그래서 진짜 단순한 검색, 필터링 아니면 잘 안 쓴다.
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("카테고리"))
);
사용은 가능하지만 실무에서는 한계가 존재한다.
JpaSpecificationExecutor<T>를 Repository에 상속시킨다.findAll, count 등을 전달해서 쿼리를 조합한다.아직 쓰는 경우가 많지 않지만, 정말 단순한 and/or 필터 정도에는 쓸 수 있다. 하지만, 실무에서는 거의 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만 필요하다면 살짝 오버스펙일 수 있음.
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클래스는 조건/필드/조인 등 쿼리의 재료 역할을 한다.
여기서부터는 내 프로젝트에서 사용된 코드를 첨부해서 설명하겠다.
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'
}
이 과정을 통해 각 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에 대해서 생성이 된다.
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;
}
}
// 서비스에서 호출
Page<Coopost> posts = coopostRepository.search(
"검색어",
CoopostCategory.STUDY,
"서울",
LocalDateTime.now(),
PageRequest.of(0, 10)
);
QueryDSL이 훨씬 강력하고 유지보수도 쉽기 때문이다.
JPA Specification은 정말 단순한 where 조건만 필요할 때만 쓰고,
대부분의 실무에서는 QueryDSL을 쓰는 게 맞다.