Blaze-Persistence 사용기

Spring Boot

Language :

복잡한 쿼리가 필요한 데이터 조회 환경에서 '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

민갤

Back-End Developer

백엔드 개발자입니다.