OpenFeign QueryDSL 사용하는 방법과 5.0.0 이후 변경점

개요

요즘 QueryDSL을 찾다 보면 OpenFeign QueryDSL이라는 표현을 자주 보게 됩니다.

정확히 말하면, 기존 QueryDSL 프로젝트의 유지보수 체감이 한동안 약했고, 그 흐름 속에서 OpenFeign 조직 아래의 querydsl 포크가 더 활발하게 릴리즈를 이어가고 있는 상태입니다.

즉:

  • OpenFeign이 QueryDSL을 공식 승계했다고 단정하기보다는
  • 실무에서는 OpenFeign 포크를 적극 유지보수 중인 QueryDSL 배포판처럼 사용한다

고 이해하는 편이 더 정확합니다.

먼저 결론

Spring Boot + JPA 기준으로 보면 보통 아래처럼 정리할 수 있습니다.

환경 추천 방향
Spring Boot 2.x / javax 구 QueryDSL 설정과 더 가까움
Spring Boot 3.x / jakarta io.github.openfeign.querydsl + jakarta classifier
Hibernate 6.x OpenFeign QueryDSL 6.x
Hibernate 7.x / JPA 3.2 QueryDSL 7.x 검토

지금 실무에서 가장 많이 쓰는 조합은 대체로 아래입니다.

  • Spring Boot 3.x
  • JDK 17
  • Hibernate 6.x
  • OpenFeign QueryDSL 6.x

핵심 차이

실무에서 말하는 “OpenFeign QueryDSL”은 보통 아래를 의미합니다.

  • GitHub 저장소: OpenFeign/querydsl
  • Maven 좌표: io.github.openfeign.querydsl:*

기존 좌표와 차이는 이것입니다.

  • 예전: com.querydsl
  • 포크: io.github.openfeign.querydsl

즉, 업그레이드할 때는 버전만이 아니라 groupId도 같이 확인해야 합니다.

Spring Boot 3에서 사용하는 방법

여기서는 Spring Boot 3 + JPA + Hibernate 6 + Jakarta 기준으로 정리합니다.

Gradle

dependencies {
implementation "io.github.openfeign.querydsl:querydsl-jpa:6.12:jakarta"

annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:6.12:jakarta"
annotationProcessor "jakarta.persistence:jakarta.persistence-api:3.1.0"
annotationProcessor "jakarta.annotation:jakarta.annotation-api:2.1.1"
}

sourceSets {
main.java.srcDirs += ["build/generated/sources/annotationProcessor/java/main"]
}

Maven

<dependencies>
<dependency>
<groupId>io.github.openfeign.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
<version>6.12</version>
<classifier>jakarta</classifier>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<generatedSourcesDirectory>
target/generated-sources/java
</generatedSourcesDirectory>
<annotationProcessorPaths>
<path>
<groupId>io.github.openfeign.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
<version>6.12</version>
<classifier>jakarta</classifier>
</path>
<path>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
<version>3.1.0</version>
</path>
<path>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
<version>2.1.1</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

여기서 핵심은 두 가지입니다.

  • querydsl-jpajakarta classifier 사용
  • querydsl-apt에도 jakarta classifier 사용

Spring Boot 3는 javax.persistence가 아니라 jakarta.persistence 기반이므로, classifier를 잘못 고르면 QClass 생성이나 import가 꼬이기 쉽습니다.

기본 설정

엔티티가 아래처럼 있다고 가정해보겠습니다.

@Entity
public class Member {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String username;
private Integer age;
}

빌드가 정상적으로 끝나면 QMember가 생성됩니다.

QMember member = QMember.member;

Spring Boot에서는 보통 JPAQueryFactory를 Bean으로 등록해서 사용합니다.

@Configuration
public class QuerydslConfig {

@PersistenceContext
private EntityManager entityManager;

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

실무 예제

1. 동적 검색

가장 기본적인 사용 패턴입니다.

import static com.example.domain.QMember.member;

public List<Member> search(String username, Integer ageLoe) {
return queryFactory
.selectFrom(member)
.where(
usernameEq(username),
ageLoe(ageLoe)
)
.orderBy(member.username.asc())
.fetch();
}

private BooleanExpression usernameEq(String username) {
return username != null && !username.isBlank()
? member.username.eq(username)
: null;
}

private BooleanExpression ageLoe(Integer ageLoe) {
return ageLoe != null
? member.age.loe(ageLoe)
: null;
}

이 패턴의 장점은 아래와 같습니다.

  • 문자열 기반 JPQL보다 안전함
  • null 조건 조합이 자연스러움
  • 정렬, 페이징, join으로 확장하기 쉬움

2. @QueryProjection 기반 DTO 조회

DTO 생성자를 QueryDSL projection 대상으로 쓰고 싶다면 @QueryProjection을 사용할 수 있습니다.

import com.querydsl.core.annotations.QueryProjection;

public class MemberDto {

private final Long id;
private final String username;
private final Integer age;

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

public Long getId() { return id; }
public String getUsername() { return username; }
public Integer getAge() { return age; }
}

빌드 후에는 QMemberDto가 생성됩니다.

import static com.example.domain.QMember.member;

public List<MemberDto> findMemberDtos() {
return queryFactory
.select(new QMemberDto(
member.id,
member.username,
member.age
))
.from(member)
.fetch();
}

장점은 생성자 파라미터 타입이 컴파일 타임에 검증된다는 점입니다. 반면 DTO가 QueryDSL에 의존하게 된다는 단점도 있습니다.

3. Pageable + projection

목록 API에서는 content querycount query를 분리하는 방식이 가장 실용적입니다.

public class MemberPageDto {

private final Long id;
private final String username;
private final Integer age;

public MemberPageDto(Long id, String username, Integer age) {
this.id = id;
this.username = username;
this.age = age;
}

public Long getId() { return id; }
public String getUsername() { return username; }
public Integer getAge() { return age; }
}
public class MemberSearchCondition {
private String username;
private Integer ageGoe;
private Integer ageLoe;

public String getUsername() { return username; }
public Integer getAgeGoe() { return ageGoe; }
public Integer getAgeLoe() { return ageLoe; }
}
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQuery;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;

import java.util.List;

import static com.example.domain.QMember.member;

public Page<MemberPageDto> searchPage(MemberSearchCondition condition, Pageable pageable) {
List<MemberPageDto> content = queryFactory
.select(Projections.constructor(
MemberPageDto.class,
member.id,
member.username,
member.age
))
.from(member)
.where(
usernameEq(condition.getUsername()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(member.id.desc())
.fetch();

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

Long total = countQuery.fetchOne();

return new PageImpl<>(content, pageable, total == null ? 0 : total);
}

이 패턴이 많이 쓰이는 이유는 분명합니다.

  • 조회용 projection과 count query를 분리해 성능 제어가 쉬움
  • 복잡한 join이 들어가도 content query와 count query를 따로 최적화 가능
  • Spring Data Pageable과 자연스럽게 연결 가능

5.0.0 이후 변경점 정리

5.0.0

2021-07-22 공개된 QueryDSL 5.0.0은 큰 전환점이었습니다.

  • 최소 Java 8 기준으로 정리
  • joda-time, guava, jsr305 필수 런타임 의존성 제거
  • Fetchable#stream() 추가
  • Java 8 date/time API 사용 기본화
  • jakarta.* 지원 추가
  • Kotlin code generation 지원
  • Java Record 지원

즉, 5.0.0은 현대 Java/Jakarta 환경에서 다시 쓰기 좋게 정리된 메이저 릴리즈라고 볼 수 있습니다.

6.0

2024-01-25 공개된 OpenFeign QueryDSL 6.0의 핵심은 아래입니다.

  • Hibernate 6.4 fully integrated
  • Querydsl JPA <-> Spring Data repository integration
  • CGLIB 제거, ByteBuddy 사용
  • Java record projection 생성 개선
  • native query 처리 정리

즉, Spring Boot 3 + Hibernate 6 사용자들이 OpenFeign 포크를 더 강하게 보게 된 이유가 여기서 분명해졌습니다.

6.9

2024-11-22 공개된 6.9에서는 Kotlin 진영에 의미 있는 변화가 들어왔습니다.

  • KSP module 추가
  • KSP example 추가
  • incremental processing support 추가

즉, QueryDSL code generation을 KAPT 대신 KSP 기반으로 가져갈 수 있는 길이 열렸습니다.

6.12

2025-06-09 공개된 6.12에서는 실무 체감이 큰 기능 보강이 들어갔습니다.

  • @QueryProjectionbuilder-based Q class generation 지원
  • querydsl-ksp-codegen에서 @JdbcTypeCode 지원
  • @Converter로 basic type에 매핑된 JPA collection의 contains() 처리 개선
  • Querydsl Aggregations에 TypeWrapperFactoryExpression 추가

여기서 중요한 건 표현입니다.

  • QClass Builder가 새로 생겼다라고 넓게 쓰기보다는
  • @QueryProjection의 builder 기반 Q class generation 지원이 추가됐다

라고 쓰는 편이 더 정확합니다.

추가로 릴리즈 노트에 있는 아래 두 항목은 이름만 보면 조금 추상적으로 느껴질 수 있습니다.

  • TypeWrapperFactoryExpression
  • likeToRegex / regexToLike 처리 보정

TypeWrapperFactoryExpression은 무슨 의미인가

이 항목은 Querydsl Aggregations에서 집계 결과를 커스텀 숫자 타입으로 더 타입 안전하게 감싸기 위한 기능으로 이해하면 됩니다.

예를 들어 집계 결과는 보통 아래처럼 기본 숫자 타입으로 많이 나옵니다.

  • Long
  • Integer
  • BigDecimal

하지만 도메인에서는 아래처럼 래퍼 타입으로 다루고 싶을 수 있습니다.

  • Money
  • Score
  • Quantity

즉, 예전에는 집계 결과를 먼저 기본 숫자 타입으로 받은 뒤, 애플리케이션 코드에서 다시 값 객체로 감싸는 코드가 필요했다면, 이 영역을 더 타입 안전하게 다루기 위한 기반 기능이 추가된 것으로 보면 됩니다.

실무적으로는 아래 같은 경우에 의미가 있습니다.

  • 통계/리포트 조회 결과를 도메인 값 객체로 감싸고 싶은 경우
  • 집계 결과의 타입 변환을 DTO 생성 시점에 더 명확하게 가져가고 싶은 경우
  • 숫자 타입 변환 실수를 줄이고 싶은 경우

예시

예를 들어 집계 결과를 단순 Long으로 받는 대신, Score라는 값 객체로 감싸고 싶다고 가정해보겠습니다.

public class Score {

private final long value;

public Score(long value) {
this.value = value;
}

public long getValue() {
return value;
}
}

집계 결과를 그냥 받으면 보통 아래처럼 처리하게 됩니다.

Long totalScore = queryFactory
.select(member.score.sum())
.from(member)
.fetchOne();

Score score = new Score(totalScore == null ? 0L : totalScore);

이런 종류의 코드는 단순해 보이지만, 집계 결과가 많아질수록 매번 기본 숫자 타입을 받아서 다시 값 객체로 감싸는 코드가 반복됩니다.

즉, TypeWrapperFactoryExpression이 의미 있는 지점은 아래처럼 볼 수 있습니다.

  • 집계 결과를 단순 숫자가 아니라 도메인 타입으로 다루고 싶을 때
  • 숫자 타입 변환을 더 타입 안전하게 가져가고 싶을 때
  • Aggregation 결과 projection을 더 깔끔하게 만들고 싶을 때

likeToRegex / regexToLike 처리 보정은 무슨 의미인가

이 항목은 새 기능이라기보다 문자열 패턴 변환의 정확도를 높이는 수정에 가깝습니다.

이름 그대로 아래 변환과 관련 있습니다.

  • SQL LIKE 패턴 -> 정규식 변환
  • 정규식 패턴 -> LIKE 패턴 변환

문제가 생기기 쉬운 부분은 보통 아래입니다.

  • %, _ 같은 LIKE 와일드카드
  • \ escape 문자
  • ^, $ 같은 regex anchor

이 처리가 잘못되면 아래 같은 일이 생길 수 있습니다.

  • 의도보다 더 많은 문자열이 매칭됨
  • 반대로 매칭돼야 할 문자열이 빠짐
  • escape가 깨져서 검색 결과가 달라짐

즉, 이 변경은 QueryDSL 내부의 패턴 변환 정확도를 보정한 것으로 보면 됩니다.

예시

예를 들어 사용자가 검색어 패턴을 입력한다고 가정해보겠습니다.

String keyword = "admin_%";

이 값을 LIKE 검색 조건에 그대로 넣으면 _%가 와일드카드로 해석될 수 있습니다.

queryFactory
.selectFrom(member)
.where(member.username.like(keyword))
.fetch();

이 경우 의도는 "admin_%"라는 문자열 자체를 찾는 것인데, 실제로는 아래처럼 동작이 달라질 수 있습니다.

  • %는 임의 길이 문자열
  • _는 한 글자 와일드카드

반대로 내부적으로 LIKE 패턴을 regex로 바꾸거나, regex 성격의 패턴을 LIKE로 바꾸는 과정에서 escape와 anchor 처리가 잘못되면 아래 같은 문제가 생깁니다.

  • "admin_1"이 매칭돼야 하는데 안 됨
  • "adminXYZ"가 의도치 않게 매칭됨
  • 시작/끝 anchor가 잘못 적용되어 범위가 달라짐

즉, likeToRegex / regexToLike 보정은 이런 문자열 패턴 변환의 미세한 오류를 줄여주는 변경이라고 보면 됩니다.

6.12 관련 예제

6.12에서 많이 이야기되는 부분은 아래 3가지입니다.

  • @QueryProjection의 builder-based Q class generation
  • KSP codegen 개선
  • JPA collection contains() 처리 개선

1. builder 기반 projection 예시

아래처럼 builder 패턴을 쓰는 DTO가 있다고 가정해보겠습니다.

public class MemberSummaryDto {

private final Long id;
private final String username;
private final Integer age;

private MemberSummaryDto(Builder builder) {
this.id = builder.id;
this.username = builder.username;
this.age = builder.age;
}

public static Builder builder() {
return new Builder();
}

public static class Builder {
private Long id;
private String username;
private Integer age;

public Builder id(Long id) {
this.id = id;
return this;
}

public Builder username(String username) {
this.username = username;
return this;
}

public Builder age(Integer age) {
this.age = age;
return this;
}

public MemberSummaryDto build() {
return new MemberSummaryDto(this);
}
}
}

실무에서는 여전히 아래처럼 Projections.constructor()를 많이 사용합니다.

public List<MemberSummaryDto> findMemberSummaries() {
return queryFactory
.select(Projections.constructor(
MemberSummaryDto.class,
member.id,
member.username,
member.age
))
.from(member)
.fetch();
}

또는 조회 후 builder로 직접 변환할 수도 있습니다.

public List<MemberSummaryDto> findMemberSummaries() {
return queryFactory
.select(member.id, member.username, member.age)
.from(member)
.fetch()
.stream()
.map(tuple -> MemberSummaryDto.builder()
.id(tuple.get(member.id))
.username(tuple.get(member.username))
.age(tuple.get(member.age))
.build()
)
.toList();
}

즉, 6.12의 의미는 “builder 스타일 DTO와 QueryProjection/code generation 쪽 결합이 더 좋아졌다”는 데 있습니다.

2. KSP codegen 예시

Kotlin 프로젝트라면 6.9부터 KSP 기반 codegen을 사용할 수 있고, 6.12에서는 KSP 쪽 기능이 더 보강되었습니다.

예를 들어 Kotlin + JPA 엔티티가 아래처럼 있다고 가정해보겠습니다.

@Entity
class Member(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,

val username: String,
val age: Int
)

KSP 설정은 보통 이런 식으로 가져갑니다.

dependencies {
implementation("io.github.openfeign.querydsl:querydsl-jpa:6.12:jakarta")
ksp("io.github.openfeign.querydsl:querydsl-ksp-codegen:6.12")
}

즉, Java 프로젝트가 annotationProcessor를 쓴다면 Kotlin 프로젝트는 ksp(...)로 code generation을 붙이는 흐름으로 이해하면 됩니다.

3. JPA collection contains() 예시

6.12 릴리즈 노트에는 @Converter로 basic type에 매핑된 JPA collection의 contains() 처리 개선도 포함되어 있습니다.

예를 들어 태그 리스트를 converter로 저장하는 엔티티가 있다고 가정해보겠습니다.

@Entity
public class Article {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String title;

@Convert(converter = StringListConverter.class)
private List<String> tags;
}

이런 경우 QueryDSL에서 아래처럼 조건을 구성할 수 있습니다.

import static com.example.domain.QArticle.article;

public List<Article> findByTag(String tag) {
return queryFactory
.selectFrom(article)
.where(article.tags.contains(tag))
.fetch();
}

이 부분은 JPA provider와 매핑 방식에 따라 차이가 있을 수 있는데, 6.12에서는 이런 contains() 처리 쪽이 개선됐다는 점이 실무적으로 의미가 있습니다.

5.6.1 / 6.10.1

2024-12-15 공개된 5.6.1과 6.10.1에서는 CVE-2024-49203 수정이 들어갔습니다.

OpenFeign 보안 공지 기준 영향 범위는 아래였습니다.

  • affected: <= 6.8.0
  • patched: 5.6.1, 6.10.1, 7.0

즉, 오래된 5.x / 6.x를 계속 쓰고 있다면 최소한 이 패치 버전 이상으로는 올리는 것이 안전합니다.

7.0

2025-06-09 공개된 7.0은 또 하나의 메이저 전환점입니다.

  • JPA 3.2.0
  • Hibernate 7.0
  • EclipseLink 5.0
  • Java 17 중심 정리
  • mysema.lang dependency 제거
  • obsolete code / dropped feature 정리

즉, 7.0은 Hibernate 7 + Java 17 시대를 겨냥한 메이저 릴리즈입니다.

선택 가이드

실무적으로는 아래만 기억해도 충분합니다.

  1. Spring Boot 3라면 jakarta classifier를 쓴다
  2. 좌표는 io.github.openfeign.querydsl로 본다
  3. Hibernate 6이면 QueryDSL 6.x를 우선 고려한다
  4. 보안 때문에라도 5.6.1 / 6.10.1 / 7.0 미만의 오래된 버전은 피한다
  5. QueryDSL을 올릴 때는 Hibernate / Spring Boot / JPA 버전을 같이 본다

한 줄 요약

OpenFeign QueryDSL은 기존 QueryDSL의 적극 유지보수 포크로 보는 것이 맞고, Spring Boot 3 환경에서는 보통 io.github.openfeign.querydsl + jakarta classifier + 6.x 계열 조합이 가장 현실적인 선택입니다.

참고

Share