복잡한 쿼리가 필요한 데이터 조회 환경에서 'WITH' 절을 동적으로 사용하기 위해 학습한 내용을 기록합니다.
Blaze-Persistence
JPA Criteria API 기반의 확장 라이브러리입니다.
표준 JPA나 QueryDSL-JPA에서 지원하지 않는 고급 SQL 기능을 사용할 수 있게 해줍니다.
Common Table Expression (CTE)
CTE는 쿼리가 실행되는 동안 사용할 수 있는 임시 테이블을 의미합니다.
- 공식적으로 JPA는 아직 WITH 절(CTE)을 지원하지 않습니다.
- Hibernate 6.1 이상부터 HQL에서 WITH 절을 지원하기 시작했으나, @Query 어노테이션 내부에서 정적 HQL 문법으로 작성해야 하므로 동적 쿼리 구성에는 한계가 있습니다.
주의 사항
- 임시 테이블(CTE)에는 인덱스가 적용되지 않습니다.
- 따라서 한 번에 많은 양의 데이터를 조회하는 방식은 지양해야 합니다.
build.gradle
Blaze Persistence 사용을 위해 아래 의존성을 추가합니다.
Plain Text
ext {
blazebitVersion = "1.6.11"
}
dependencies {
// JPA and QueryDSL Extensions
// 문서: https://persistence.blazebit.com/documentation/1.6/core/manual/en_US/
implementation "com.blazebit:blaze-persistence-core-impl-jakarta:${blazebitVersion}"
implementation "com.blazebit:blaze-persistence-integration-querydsl-expressions-jakarta:${blazebitVersion}"
implementation "com.blazebit:blaze-persistence-integration-hibernate-6.2:${blazebitVersion}"
}
시스템 설정 (Configuration)
CTE 기능을 사용하기 위해 CriteriaBuilderFactory를 빈(Bean)으로 등록해야 합니다.
CriteriaBuilderFactory는 애플리케이션 전역에서 하나만 생성되는 싱글톤(Singleton) 객체입니다.
스프링 컨테이너에 등록된 DB 연결 객체인 EntityManagerFactory를 주입받아 생성합니다.
Java
@Configuration
public class BlazebitConfig {
@PersistenceUnit
private EntityManagerFactory entityManagerFactory;
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_SINGLETON)
public CriteriaBuilderFactory criteriaBuilderFactory() {
CriteriaBuilderConfiguration config = Criteria.getDefault();
config.setProperty(ConfigurationProperties.INLINE_CTES, "false");
return config.createCriteriaBuilderFactory(entityManagerFactory);
}
}
INLINE_CTES 비활성화
- 기본적으로 활성화된 CTE 인라인(Inlining) 최적화 기능을 끕니다.
- 이 옵션이 켜져 있으면, 최적화 기능에 의해 WITH 절을 생성하지 않고 강제로 서브쿼리(Inline View)로 변환하여 메인 쿼리에 합쳐버릴 수 있습니다. 의도한 대로 WITH 절을 사용하려면 false로 설정하는 것이 좋습니다.
예제: 재귀적 CTE(Recursive CTE)
엔티티 설정 (Entity & CTE Entity)
Blaze Persistence를 사용하려면 실제 DB 테이블과 매핑되는 JPA 엔티티 외에, CTE 결과를 담을 CTE 전용 엔티티가 필요합니다.
기본 JPA 엔티티 (Category)
Category.class
Java
import com.blazebit.persistence.CTE;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@CTE
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class CategoryCTE {
@Id
private Long id;
private Long rowNum;
}
CTE 전용 엔티티 (CategoryCTE)
CTE 결과셋을 매핑할 클래스입니다. @CTE 어노테이션을 달아줍니다.
CategoryCTE.class
Java
import com.blazebit.persistence.CTE;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@CTE
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class CategoryCTE {
@Id
private Long id;
private Long rowNum;
}
Repository 구현
스프링 데이터 JPA의 Custom Repository 패턴을 사용합니다.
CategoryRepositoryQueryDslImpl.class
Java
import com.blazebit.persistence.CriteriaBuilderFactory;
import com.blazebit.persistence.querydsl.BlazeJPAQuery;
import com.querydsl.core.types.dsl.*;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
import static com.example.demo.entity.QCategory.category;
import static com.example.demo.entity.QCategoryCTE.categoryCTE;
@Repository
@RequiredArgsConstructor
public class CategoryRepositoryQueryDslImpl implements CategoryRepositoryCustom {
private final EntityManager em;
private final CriteriaBuilderFactory cbf;
@Override
public List<Category> findAllSubCategories(long rootId) {
return new BlazeJPAQuery<Category>(em, cbf)
// CTE 정의: 재귀 쿼리 시작
.withRecursive(categoryCTE, new BlazeJPAQuery<>()
// 1. Anchor Member (시작점)
.from(category)
.where(category.id.eq(rootId))
.bind(categoryCTE.id, category.id)
.bind(categoryCTE.rowNum, Expressions.constant(1))
.unionAll(
// 2. Recursive Member (재귀 로직)
new BlazeJPAQuery<>()
.from(category)
.join(categoryCTE).on(category.parent.id.eq(categoryCTE.id))
.bind(categoryCTE.id, category.id)
.bind(categoryCTE.rowNum, categoryCTE.rowNum.add(1))
)
)
// 2. 메인 쿼리
.select(category)
.from(category)
.join(categoryCTE).on(category.id.eq(categoryCTE.id)) // CTE 결과(ID들)와 실제 테이블 조인
.fetch();
}
}
일반 예제: 이전 글 / 다음 글 찾기 (Window Function 활용)
Java
import com.querydsl.core.types.Expression
@Override
public List<CategoryNeighborsVo> findNeighbors(Pageable pageable, long currentId, Map<CategoryFilter, String> filter) {
// 1. 정렬 기준 설정
Path<? extends Comparable> orderPath = category.id;
boolean isAscending = false; // 기본: 최신순(내림차순)
// Pageable에서 정렬 정보 추출
if (pageable.getSort().isSorted()) {
Sort.Order order = pageable.getSort().stream().findFirst().orElse(null);
if (order != null) {
isAscending = order.getDirection().isAscending();
switch (order.getProperty()) {
case "code" -> orderPath = category.code;
case "name" -> orderPath = category.name;
}
}
}
// 2. CTE용 Alias 생성 (Self-Join용)
QCategoryCTE target = new QCategoryCTE("target");
return new BlazeJPAQuery<CategoryNeighborsVo>(em, cbf)
.with(categoryCTE, new BlazeJPAQuery<>()
.from(category)
.bind(categoryCTE.id, category.id)
// 정렬 기준에 따라 ROW_NUMBER 생성
.bind(categoryCTE.rowNum, getRowNumField(orderPath, isAscending))
.where(getListCondition(filter)) // 검색 필터
.orderBy(
new OrderSpecifier<>(isAscending ? Order.ASC : Order.DESC, Expressions.asComparable(orderPath)),
isAscending ? category.id.asc() : category.id.desc() // PK로 순서 보장 (Tie-Breaker)
)
)
.select(Projections.fields(
CategoryNeighborsVo.class,
categoryCTE.id,
// target보다 rowNum이 작으면 '이전', 크면 '다음'
new CaseBuilder()
.when(categoryCTE.rowNum.lt(target.rowNum))
.then(false)
.otherwise(true).as("next")
))
.from(categoryCTE)
.join(target).on(target.id.eq(currentId)) // 현재 글(기준점) 찾기
.where(categoryCTE.rowNum.eq(target.rowNum.add(1)) // 다음
.or(categoryCTE.rowNum.eq(target.rowNum.subtract(1)))) // 이전
.orderBy(categoryCTE.rowNum.asc())
.fetch();
}
private Expression<Long> getRowNumField(Path<? extends Comparable> path, boolean isAscending) {
OrderSpecifier<?> orderSpecifier = isAscending
? new OrderSpecifier<>(Order.ASC, path)
: new OrderSpecifier<>(Order.DESC, path);
OrderSpecifier<?> idSpecifier = isAscending ? category.id.asc() : category.id.desc();
// SQL: ROW_NUMBER() OVER (ORDER BY {정렬컬럼} {방향}, id {방향})
// category.id를 보조 정렬 키로 사용하여 순서 보장
return JPQLNextExpressions.rowNumber()
.over()
.orderBy(orderSpecifier, idSpecifier);
}
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class CategoryNeighborsVo {
private Long id;
private Boolean next;
}
참고
https://persistence.blazebit.com/documentation/1.6/core/manual/en_US/
https://j-k4keye.tistory.com/68
https://sightstudio.tistory.com/53


