✅ 왜 QueryDSL 을 사용할까?
JPQL로 쿼리를 사용하면 되는데 QueryDSL을 사용하는 이유에 대해서 생각해보자.
문제점
1. JQPL은 문자열로 작성하기 때문에 런타임에서 쿼리가 실행되어야 오류를 발견할 수 있다.
2. 동적쿼리를 구현할 때 JPQL,criteria은 너무 복잡해진다. (Criteria API 가독성 매우 떨어짐)
QueryDSL을 사용하는 이유
1.QueryDSL은 컴파일 시점에 오류를 발견할 수 있다.
2.간편 하게 동적 쿼리를 구현할 수 있다. (Ex 필터 검색 구현하기.)
3. IDE 자동 원성 지원으로 몹시 편하다.
✅ QueryDSL 이란?
한마디로, SQL이나 JPQL을 Java 코드로 하게 작성할 수 있도록 도와주는 빌더 기반 오픈소스 프레임워크다.
핵심 장점으로 유형 안전의 결여와 정적 쿼리 검사의 부재를 해결해준다.
✅ QueryDSL 구조
QueryDSL은 빌더 패턴 구조로 이루어져 있다.
빌더 패턴은 객체의 데이터 세팅에 사용되는 디자인 패턴이다. (우리 개발자들도 개발할 자주 사용한다 잘 생각해보자~)
public List<Post> searchPost(PostSearchCondition condition) {
QPost qPost = QPost.post; // Q타입 클래스 객체 생성
return queryFactory
.select(qPost) // select 메소드
.from(qPost) // from 메소드
.leftJoin(qPost.author, qMember) // 게시글 작성자와 조인
.where( // where 메소드
titleContains(condition.getTitle()),
contentContains(condition.getContent()),
authorNameEq(condition.getAuthorName()),
createdAfter(condition.getCreatedAfter()),
createdBefore(condition.getCreatedBefore())
)
.fetch(); // fetch 메소드
}
private BooleanExpression titleContains(String title) {
return title != null ? qPost.title.contains(title) : null;
}
private BooleanExpression contentContains(String content) {
return content != null ? qPost.content.contains(content) : null;
}
private BooleanExpression authorNameEq(String authorName) {
return authorName != null ? qPost.author.username.eq(authorName) : null;
}
private BooleanExpression createdAfter(LocalDateTime date) {
return date != null ? qPost.createdAt.goe(date) : null;
}
private BooleanExpression createdBefore(LocalDateTime date) {
return date != null ? qPost.createdAt.loe(date) : null;
}
위 코드에는 두 가지 특징을 알아야 한다.
1. Q타입클래스
2. 메소드 체이닝 방식
- Q타입클래스
원하는 엔티티의 정보를 QueryDSL에 넘겨야 하는데, 이때 Q타입 클래스가 사용된다. (컴파일 과정에서 이미 생성된 엔티티를 토대로 생성된다.)
참고로 QueryDSL을 JPA와 분리된 프레임워크이다. 엔티티를 직접 사용하면 JPA에 종속되므로 Q타입 클래스를 사용함.
QPost qPost = QPost.post; // Q타입 클래스 객체 생성
- 메소드 체이닝 방식
메소드 체이닝은 빌더 패턴의 특징이다. '.' 연산자 로 이어져 있는거 확인할 수 있다.
더 패턴은 데이터 세팅을 select, from, leftjoin, where 순으로 할 수 있도록 제공해서 직관적이므로 가독성 향상된다.
✅ QueryDSL을 세팅 + 사용
의존성 추가 -> Config작성 -> CustomRepository, Impl클래스 작성 -> 기존 리포지토리가 CustomRepository 확장 -> 사용
Build.gradle 설정 추가
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.2'
id 'io.spring.dependency-management' version '1.1.7'
}
// 프로젝트 기본 정보
group = 'org.example'
version = '0.0.1-SNAPSHOT'
// Java 버전 설정
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
ext {
querydslVersion = "5.0.0"
}
// 프로젝트 의존성 설정
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Querydsl 설정 (JPA 지원)
implementation "com.querydsl:querydsl-jpa:${querydslVersion}:jakarta"
annotationProcessor "com.querydsl:querydsl-apt:${querydslVersion}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api:2.1.1"
annotationProcessor "jakarta.persistence:jakarta.persistence-api:3.1.0"
// 데이터 소스 데코레이터 (데이터베이스 연결 모니터링 및 로깅 지원)
implementation 'com.github.gavlyukovskiy:datasource-decorator-spring-boot-autoconfigure:1.9.0'
// Lombok (자동 Getter, Setter 등 생성)
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// SQL 쿼리 파라미터를 로깅하기 위한 p6spy 라이브러리
implementation 'p6spy:p6spy:3.9.1'
// 개발 편의성 제공 (자동 리로드 등, 필요 시 활성화)
// developmentOnly 'org.springframework.boot:spring-boot-devtools'
// OAuth2 클라이언트
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
// JWT 토큰 관련 라이브러리
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
// 내장 데이터베이스 H2 (개발 및 테스트용)
runtimeOnly 'com.h2database:h2'
// MySQL 데이터베이스 연결 드라이버
runtimeOnly 'com.mysql:mysql-connector-j'
// Swagger (API 문서 자동 생성)
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
// AWS S3 사용을 위한 라이브러리
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
// .env 파일로 환경변수 로드
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
// MongoDB 지원
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
// 테스트 관련 의존성
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
// Querydsl 설정
def generated = 'src/main/generated'
// Querydsl이 생성하는 QClass 파일 위치 지정
tasks.withType(JavaCompile) {
options.getGeneratedSourceOutputDirectory().set(file(generated))
}
// Java 소스에 Querydsl이 생성한 디렉토리를 추가
sourceSets {
main.java.srcDirs += [generated]
}
// gradle clean 시 생성된 QClass 디렉토리 삭제
clean {
delete file(generated)
}
// 테스트 설정 (JUnit 5 사용)
tasks.named('test') {
useJUnitPlatform()
}
QueryDslConfig 클래스 추가
package org.example.auctify.config.querydsl;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* QueryDSL 설정 클래스
* QueryDSL을 사용하여 데이터베이스에 타입 세이프한 쿼리를 작성할 수 있도록 도와줍니다.
*/
@Configuration
public class QueryDslConfig {
/**
* EntityManager는 엔티티를 관리하고 JPA 작업을 처리합니다.
* PersistenceContext를 사용해 스프링 컨테이너가 관리하는 EntityManager를 주입받습니다.
*/
@PersistenceContext
private EntityManager entityManager;
/**
* JPAQueryFactory를 스프링 빈으로 등록하여,
* 애플리케이션 전반에서 QueryDSL의 타입 세이프 쿼리를 간편하게 생성할 수 있도록 지원합니다.
*
* @return JPAQueryFactory 인스턴스
*/
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
Custom Repository Interface + Impl Class 작성
CustomRepository Interface
package org.example.auctify.repository.goods;
import org.example.auctify.dto.Goods.GoodsResponseSummaryDTO;
import org.example.auctify.entity.Goods.GoodsEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface GoodsRepositoryCustom {
Page<GoodsEntity> searchGoods(
String category,
Double priceRangeLow,
Double priceRangeHigh,
String goodsStatus,
String goodsProcessStatus,
String goodsName,
String sort,
Pageable pageable);
}
Impl Class 작성
package org.example.auctify.repository.goods;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.CaseBuilder;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.example.auctify.dto.Goods.GoodsCategory;
import org.example.auctify.dto.Goods.GoodsProcessStatus;
import org.example.auctify.dto.Goods.GoodsResponseSummaryDTO;
import org.example.auctify.dto.Goods.GoodsStatus;
import org.example.auctify.entity.Goods.QGoodsEntity;
import org.example.auctify.entity.bidHistory.QBidHistoryEntity;
import org.example.auctify.entity.like.QLikeEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.stream.Collectors;
@RequiredArgsConstructor
public class GoodsRepositoryCustomImpl implements GoodsRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public Page<GoodsResponseSummaryDTO> searchGoodsWithLikeStatus(
Long userId,
String category,
Double priceRangeLow,
Double priceRangeHigh,
String goodsStatus,
String goodsProcessStatus,
String goodsName,
String sort,
Pageable pageable
) {
QGoodsEntity goods = QGoodsEntity.goodsEntity;
QBidHistoryEntity bid = QBidHistoryEntity.bidHistoryEntity;
QLikeEntity like = QLikeEntity.likeEntity;
BooleanBuilder builder = new BooleanBuilder();
if (category != null && !category.isEmpty()) {
builder.and(goods.category.eq(GoodsCategory.valueOf(category)));
}
if (priceRangeLow != null) {
builder.and(goods.buyNowPrice.goe(priceRangeLow));
}
if (priceRangeHigh != null) {
builder.and(goods.buyNowPrice.loe(priceRangeHigh));
}
if (goodsStatus != null && !goodsStatus.isEmpty()) {
builder.and(goods.goodsStatus.eq(GoodsStatus.valueOf(goodsStatus)));
}
if (goodsProcessStatus != null && !goodsProcessStatus.isEmpty()) {
builder.and(goods.goodsProcessStatus.eq(GoodsProcessStatus.valueOf(goodsProcessStatus)));
}
if (goodsName != null && !goodsName.isEmpty()) {
builder.and(goods.goodsName.containsIgnoreCase(goodsName));
}
OrderSpecifier<?> orderSpecifier = getSort(goods, bid, sort);
List<org.example.auctify.entity.Goods.GoodsEntity> entityList = queryFactory
.selectFrom(goods)
.leftJoin(like).on(
like.goods.goodsId.eq(goods.goodsId)
.and(like.user.userId.eq(userId))
).fetchJoin()
.where(builder)
.orderBy(orderSpecifier)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long total = queryFactory
.select(goods.count())
.from(goods)
.where(builder)
.fetchOne();
List<GoodsResponseSummaryDTO> dtoList = entityList.stream().map(g -> GoodsResponseSummaryDTO.builder()
.goodsId(g.getGoodsId())
.goodsName(g.getGoodsName())
.goodsProcessStatus(g.getGoodsProcessStatus())
.currentBidPrice(g.getCurrentBidPrice())
.imageUrls(g.getFirstImage())
.endTime(g.getActionEndTime())
.category(g.getCategory())
.goodsStatus(g.getGoodsStatus())
.currentBidCount((long) g.getBidHistories().size())
.isLiked(
g.getLike().stream().anyMatch(l -> l.getUser().getUserId().equals(userId))
)
.build()
).collect(Collectors.toList());
return new PageImpl<>(dtoList, pageable, total);
}
private OrderSpecifier<?> getSort(QGoodsEntity goods, QBidHistoryEntity bid, String sort) {
if ("priceAsc".equalsIgnoreCase(sort)) {
return goods.buyNowPrice.asc();
} else if ("priceDesc".equalsIgnoreCase(sort)) {
return goods.buyNowPrice.desc();
} else if ("currentPriceAsc".equalsIgnoreCase(sort)) {
return currentBidPriceExpr(goods, bid).asc();
} else if ("currentPriceDesc".equalsIgnoreCase(sort)) {
return currentBidPriceExpr(goods, bid).desc();
} else if ("latest".equalsIgnoreCase(sort)) {
return goods.createdAt.desc();
}
return goods.createdAt.desc();
}
private NumberExpression<Long> currentBidPriceExpr(QGoodsEntity goods, QBidHistoryEntity bid) {
NumberExpression<Long> maxBidPrice = Expressions.numberTemplate(Long.class,
"(select coalesce(max(b.bidPrice), 0) from BidHistoryEntity b where b.goods.goodsId = {0})",
goods.goodsId
);
return new CaseBuilder()
.when(maxBidPrice.gt(goods.minimumBidAmount)).then(maxBidPrice)
.otherwise(goods.minimumBidAmount);
}
}
기존 Repository는 Custom Repository 상속
package org.example.auctify.repository.goods;
import org.example.auctify.entity.Goods.GoodsEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface GoodsRepository extends JpaRepository<GoodsEntity, Long>,GoodsRepositoryCustom {
//JPQL에서 JOIN FETCH를 사용할 경우 COUNT(*) 같은
// 카운트 쿼리를 자동으로 생성할 수 없기 때문에 쿼리 2개 사용하거나 또는
// EntityGraph 사용 필수
@EntityGraph(attributePaths = {
"user"
,"image"
,"bidHistories"})
@Query("SELECT g FROM GoodsEntity g WHERE g.user.userId = :userId" +
" Order BY g.goodsId DESC")
Page<GoodsEntity> findGoodsByUserId(@Param("userId") Long userId, Pageable pageable);
@EntityGraph(attributePaths = {
"image"
,"bidHistories"
})
@Query("SELECT g FROM GoodsEntity g where g.goodsId = :goodsId")
Optional<GoodsEntity> findGoodsImageBidHistoryByGoodsId(Long goodsId);
}
Service에서 편하게 이제 기존 리포지토리를 통해서 호출 ~!~!~!
✅ QueryDSL의 단점과 해결책
아래 글들을 읽어 보는걸 추천한다.
아래 내용을 정리하지 않는 이유는 이 글의 핵심에서 멀어지기 때문.
https://velog.io/@cws0718/Spring-JPA-QueryDsl%EC%9D%B4%EB%9E%80
https://velog.io/@yangsijun528/dont-use-querydsl
https://juyoungit.tistory.com/673
✅ 참고
https://lordofkangs.tistory.com/464
https://velog.io/@soyeon207/QueryDSL-%EC%9D%B4%EB%9E%80
✅ 결론
핵심 부분에 대해서 정리해봤다. 추가 적인 정보는 찾아가면서 활용하면 될 듯하다.
'JPA' 카테고리의 다른 글
실전! 스프링 데이터 JPA 강의 정리 (0) | 2025.02.12 |
---|---|
실전! 스프링 부트와 JPA 활용1 강의 정리 (0) | 2025.02.05 |
[에러정리] TransientObjectException : save the transient instance before flushing 에러 (0) | 2025.02.04 |
JPA 양방향 연관관계의 주인 (0) | 2025.02.01 |
JPA (2) | 2025.02.01 |