Home Hibernate, Spring Data JPA, QueryDSL 에서 UNION 사용하기
Post
Cancel

Hibernate, Spring Data JPA, QueryDSL 에서 UNION 사용하기

JPA 에서 union 사용하기

JPA 환경에서 집합 연산자(UNION, UNION ALL, INTERSECT, EXCEPT)를 사용할 수 있을까요? JPA의 버전이 3.1까지 올라왔어도 아직 이에대한 명세, 지원은 없는 것으로 보입니다. native sql로 작성하면 안되는 쿼리는 없긴 합니다만, 추상화된 SQL을 사용할 수 없다는 단점이 존재합니다.

Hibernate가 이에 대한 해답이 될 수 있는데요, Hibernate6 버전부터는 집합 연산자들을 지원하기 시작했으며 QueryDSL은 JPASQLQuery 클래스를 사용하면 일반적인 native sql보다는 type-safe 하게 쿼리 작성이 가능합니다. 이 포스팅에서는 간단한 예제로 JPA 환경에서 UNION을 사용하는 방법에 대해 알아보고자 합니다.

0. 문제 확인 & 예제 세팅

ERD

Order 라는 파티션 테이블이 존재해야 하는데 모종의 이유로 파티션 테이블을 사용하지 못하고 주기적으로 테이블을 직접 분리해야 하는 상황이라고 가정합니다.

요청받은 데이터의 조건은 다음과 같습니다.

  1. Order 테이블을 상속하는 모든 테이블의 주문을 합쳐 주세요.
  2. 22년 6월 데이터는 받는분의 성씨가 ‘김’씨인 주문만 조회해 주세요.
  3. 23년 1월 데이터는 상품가격이 10만원 이상인 주문만 조회해 주세요.
  4. 합쳐진 주문에서 상태값이 FAILED인 주문은 제외해 주세요.

이를 sql로 표현하면 다음과 같이 간단히 표현할 수 있습니다. (PostgreSQL 기준)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
select o.*
from (
         select *
         from order_2206
         where order_2206.toname like '김%'
         union all
         select *
         from order_2209
         union all
         select *
         from order_2301
         where order_2301.itemprice >= 100000
     ) as o
where status <> 'FAILED'

FROM 절에 서브쿼리를 써야하고 그 서브쿼리는 UNION ALL 을 사용합니다. 이제 위 sql문을 JPA로 옮겨보려고 합니다.

다음은 테스트에 사용될 라이브러리 & 엔티티 입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
dependencies {
    implementation group: 'org.postgresql', name: 'postgresql', version: '42.5.1'

    implementation 'org.hibernate.orm:hibernate-core:6.2.0.Final'

    implementation group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.19.0'

    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    implementation 'com.querydsl:querydsl-sql:5.0.0'
    annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api:3.1.0'

    implementation 'org.projectlombok:lombok:1.18.24'
    annotationProcessor 'org.projectlombok:lombok:1.18.24'

    testImplementation 'org.springframework.boot:spring-boot-starter-test:3.0.1'
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Getter
@Setter
@MappedSuperclass
public class Order {

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

    @Enumerated(EnumType.STRING)
    protected OrderStatus status;

    protected String fromAddress;
    protected String fromName;
    protected String fromPhoneNumber;

    protected String toAddress;
    protected String toName;
    protected String toPhoneNumber;

    protected String itemName;
    protected int itemPrice;

    protected LocalDateTime createdDateTime;
}

@Entity
@Getter
public class Order_2206 extends Order {
    // Order_2209 Order_2301 동일
}

1. Hibernate

Hibernate는 6 버전부터 집합 연산자들에 대한 지원을 시작하였습니다. 이로써 JPQL과 Criteria API 에서 UNION을 사용할 수 있게 되었습니다. 이게 가능한 이유는 SQM (Semantic Query Model) 때문인데요, SQM에 대해서는 이 문서를 읽어보시기 바랍니다.

Hibernate6 이전 버전들의 경우 Criteria API -> JPQL / HQL -> SQL 로 변환되었던 것에 비해 Hibernate6부터는 Criteria APi와 JPQL / HQL이 모두 SQM으로 컴파일되고 최종적으로 SQM이 SQL로 변환된다고 하네요. 쿼리 모델을 통합 함으로써 Hibernate Criteria Query가 JPA (JPQL) 스펙에서 지원하지 않는 기능들을 지원할 수 있게 되었다고 합니다. 그중 하나가 UNION인 셈이죠.

1-1. HQL

첫번째는 HQL 입니다. 이제 FROM 절에서의 서브쿼리와 UNION을 사용할 수 있기 때문에 alias만 잘 지켜서 정직하게 sql을 작성하면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Test
@DisplayName("Hibernate HQL UNION ALL")
void hibernateHQL() {
    // o.id AS id, o.status AS status, o.fromAddress AS fromAddress, o.fromName AS fromName, o.fromPhoneNumber AS fromPhoneNumber, o.toAddress AS toAddress, o.toName AS toName, o.toPhoneNumber AS toPhoneNumber, o.itemName AS itemName, o.itemPrice AS itemPrice, o.createdDateTime AS createdDateTime
    var fieldsWithAlias = Arrays.stream(Order.class.getDeclaredFields()).map(i -> "o." + i.getName() + " AS " + i.getName()).collect(Collectors.joining(", "));

    var query = String.format("""
            SELECT %1$s
            FROM (
                SELECT %1$s
                FROM Order_2206 AS o
                WHERE o.toName LIKE :name
                UNION ALL
                SELECT %1$s
                FROM Order_2209 AS o
                UNION ALL
                SELECT %1$s
                FROM Order_2301 AS o
                WHERE o.itemPrice >= :price
            ) AS o
            WHERE o.status <> :status
            """, fieldsWithAlias);

    var list = entityManager
            .createQuery(query)
            .setParameter("name", "김%")
            .setParameter("price", 100000)
            .setParameter("status", OrderStatus.FAILED)
            .getResultList();

    System.out.println(list);
}

생성되는 쿼리는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
    [DEBUG] [main] SQL -
    select
        o4_0.id,
        o4_0.status,
        o4_0.fromAddress,
        o4_0.fromName,
        o4_0.fromPhoneNumber,
        o4_0.toAddress,
        o4_0.toName,
        o4_0.toPhoneNumber,
        o4_0.itemName,
        o4_0.itemPrice,
        o4_0.createdDateTime
    from
        (select
            o1_0.id,
            o1_0.status,
            o1_0.fromAddress,
            o1_0.fromName,
            o1_0.fromPhoneNumber,
            o1_0.toAddress,
            o1_0.toName,
            o1_0.toPhoneNumber,
            o1_0.itemName,
            o1_0.itemPrice,
            o1_0.createdDateTime
        from
            Order_2206 o1_0
        where
            o1_0.toName like ? escape ''
        union
        all select
            o2_0.id,
            o2_0.status,
            o2_0.fromAddress,
            o2_0.fromName,
            o2_0.fromPhoneNumber,
            o2_0.toAddress,
            o2_0.toName,
            o2_0.toPhoneNumber,
            o2_0.itemName,
            o2_0.itemPrice,
            o2_0.createdDateTime
        from
            Order_2209 o2_0
        union
        all select
            o3_0.id,
            o3_0.status,
            o3_0.fromAddress,
            o3_0.fromName,
            o3_0.fromPhoneNumber,
            o3_0.toAddress,
            o3_0.toName,
            o3_0.toPhoneNumber,
            o3_0.itemName,
            o3_0.itemPrice,
            o3_0.createdDateTime
        from
            Order_2301 o3_0
        where
            o3_0.itemPrice>=?
    ) o4_0(id,status,fromAddress,fromName,fromPhoneNumber,toAddress,toName,toPhoneNumber,itemName,itemPrice,createdDateTime)
where
    o4_0.status!=?
[TRACE] [main] bind - binding parameter [1] as [VARCHAR] - [%]
[TRACE] [main] bind - binding parameter [2] as [INTEGER] - [100000]
[TRACE] [main] bind - binding parameter [3] as [VARCHAR] - [FAILED]

sql문이 잘 생성되는 것을 확인할 수 있습니다.

참고로 이전 버전의 Hibernate와 달리 select문에 별칭이 들어가지 않아 sql문이 조금 예뻐(?) 졌는데요, 이는 Hibernate6의 새로운 데이터 추출 방식과 관련이 있습니다. JDBC API가 제공하는 ResultSet으로 데이터를 추출하는 방법에는 read-by-nameread-by-position 이 있는데요, Hibernate6 이전 버전들에서는 read-by-name을 사용해 왔지만 이제는 read-by-position을 사용한다고 합니다. Hibernate 릴리즈 노트JDBC driver maintainer의 글에 따르면 read-by-position 방식이 더 빠르다고 하네요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 이전
select
    o1_0.id as id_1_0_0_,
    o1_0.status as status_1_0_0,
    o1_0.fromAddress as from_address_1_0_0,
    o1_0.fromName as from_name_1_0_0,
    o1_0.fromPhoneNumber as from_phone_number_1_0_0,

-- 현재
select
    o1_0.id,
    o1_0.status,
    o1_0.fromAddress,
    o1_0.fromName,
    o1_0.fromPhoneNumber,

1-2. Criteria API (Hibernate 6.2 ++)

이번에는 Criteria API를 이용하여 HQL보다 type-safe 하게 쿼리를 작성해 보겠습니다. 당연한 이야기지만 어디까지나 Hibernate의 기능들을 사용하는 것이기 때문에 일반 CriteiaBuilder을 사용하면 안되고 HibernateCriteriaBuilder을 사용해야 합니다.

union all

Hibernate 6.2 이전 버전에서는 from 절의 서브쿼리에서 union all을 사용할 수 없습니다. 왜냐하면 HibernateCriteriaBuilder 인터페이스에 위 메소드가 존재하지 않기 때문인데요, 위 메소드가 존재함으로써 union all의 결과값으로 JPASubQuery 타입을 리턴받을 수 있고 from 절의 서브쿼리에서 union all 을 사용할 수 있게 되었습니다. Hibernate 6.2 버전부터는 with cte as ( ... ) 구문을 사용할 수 있게 되었는데 아마 이에 영향을 받아 추가되지 않았나 싶습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Test
@DisplayName("Hibernate Criteria API UNION ALL")
void hibernateCriteriaAPI() {
    Function<JpaRoot<?>, Selection<?>[]> rootPath = (root) -> Arrays.stream(Order.class.getDeclaredFields()).map(field -> root.get(field.getName()).alias(field.getName())).toArray(Selection[]::new);

    var scq = hibernateCriteriaBuilder.createQuery(Order.class);
    BiFunction<Class<? extends Order>, Function<JpaRoot<? extends Order>, Expression<Boolean>>, JpaSubQuery<Tuple>> jpaCriteriaQuery = (clazz, condition) -> {
        var sq = scq.subquery(Tuple.class);
        var root = sq.from(clazz);

        sq.multiselect(rootPath.apply(root));

        if (condition != null) {
            sq.where(condition.apply(root));
        }

        return sq;
    };

    var sq1 = jpaCriteriaQuery.apply(Order_2206.class, (root) -> hibernateCriteriaBuilder.ilike(root.get("toName"), "김%"));
    var sq2 = jpaCriteriaQuery.apply(Order_2209.class, null);
    var sq3 = jpaCriteriaQuery.apply(Order_2301.class, (root) -> hibernateCriteriaBuilder.ge(root.get("itemPrice"), 100000));

    var cq = hibernateCriteriaBuilder.createQuery(Order.class);
    var root = cq.from(hibernateCriteriaBuilder.unionAll(sq1, sq2, sq3));

    cq.multiselect(rootPath.apply(root));
    cq.where(hibernateCriteriaBuilder.notEqual(root.get("status"), OrderStatus.FAILED));

    var list = entityManager.createQuery(cq).getResultList();

    System.out.println(list);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
    select
        derived1_0.id,
        derived1_0.status,
        derived1_0.fromAddress,
        derived1_0.fromName,
        derived1_0.fromPhoneNumber,
        derived1_0.toAddress,
        derived1_0.toName,
        derived1_0.toPhoneNumber,
        derived1_0.itemName,
        derived1_0.itemPrice,
        derived1_0.createdDateTime
    from
        (select
            o1_0.id,
            o1_0.status,
            o1_0.fromAddress,
            o1_0.fromName,
            o1_0.fromPhoneNumber,
            o1_0.toAddress,
            o1_0.toName,
            o1_0.toPhoneNumber,
            o1_0.itemName,
            o1_0.itemPrice,
            o1_0.createdDateTime
        from
            Order_2206 o1_0
        where
            o1_0.toName ilike ? escape ''
        union
        all select
            o2_0.id,
            o2_0.status,
            o2_0.fromAddress,
            o2_0.fromName,
            o2_0.fromPhoneNumber,
            o2_0.toAddress,
            o2_0.toName,
            o2_0.toPhoneNumber,
            o2_0.itemName,
            o2_0.itemPrice,
            o2_0.createdDateTime
        from
            Order_2209 o2_0
        union
        all select
            o3_0.id,
            o3_0.status,
            o3_0.fromAddress,
            o3_0.fromName,
            o3_0.fromPhoneNumber,
            o3_0.toAddress,
            o3_0.toName,
            o3_0.toPhoneNumber,
            o3_0.itemName,
            o3_0.itemPrice,
            o3_0.createdDateTime
        from
            Order_2301 o3_0
        where
            o3_0.itemPrice>=?
    ) derived1_0(id,status,fromAddress,fromName,fromPhoneNumber,toAddress,toName,toPhoneNumber,itemName,itemPrice,createdDateTime)
where
    derived1_0.status!=?
[TRACE] [main] bind - binding parameter [1] as [VARCHAR] - [%]
[TRACE] [main] bind - binding parameter [2] as [INTEGER] - [100000]
[TRACE] [main] bind - binding parameter [3] as [VARCHAR] - [FAILED]

HQL로 작성한 쿼리와는 테이블 별칭만 다를뿐 다른 부분은 일치하는 것을 확인할 수 있습니다.

2. Spring Data JPA

:warning: 예제 세팅에는 Spring Data JPA 디펜던시가 포함되어 있지 않습니다. 직접 테스트를 원하시는 분들은 3.x.x 버전 이상의 Spring Data JPA를 추가하여 주세요.

Spring Data JPA의 경우 3.x.x 버전부터 Hibernate 6 버전을 사용하기 시작하였습니다. 기존에는 @Query 어노테이션을 사용하여 union 쿼리를 작성했었는데요, 크게 달라진 것은 없지만 이제 Hibernate 차원에서 union을 지원하기 때문에 이제 native sql로 변환할 필요가 없게 되었습니다. 따라서 Spring Boot 어플리케이션 실행 단계에서 쿼리를 검증할 수 있어 안전한 쿼리 작성이 가능합니다.

@Query 어노테이션을 사용할 때 nativeQuery = true 옵션을 주지 않아도 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Repository
public interface OrderRepository extends JpaRepository<Order_2206, Long> {
    @Query(
            value = """
                    SELECT u.toAddress, u.toName, u.itemName, u.itemPrice
                    FROM (
                        SELECT o1.status as status, o1.toAddress as toAddress, o1.toName as toName, o1.itemName as itemName, o1.itemPrice as itemPrice FROM Order_2206 o1
                        WHERE o1.toName like :name
                        UNION ALL
                        SELECT o2.status as status, o2.toAddress as toAddress, o2.toName as toName, o2.itemName as itemName, o2.itemPrice as itemPrice FROM Order_2209 o2
                        UNION ALL
                        SELECT o3.status as status, o3.toAddress as toAddress, o3.toName as toName, o3.itemName as itemName, o3.itemPrice as itemPrice FROM Order_2301 o3
                        where o3.itemPrice >= :itemPrice
                    ) AS u
                    WHERE u.status != :status
                    """
    )
    List<Tuple> selectResult(@Param("name") String name, @Param("itemPrice") int itemPrice, @Param("status") OrderStatus orderStatus);
}

3. QueryDSL

JPQL이 union을 지원하지 않기 때문에 흔히 사용하는 JPAQuery 인터페이스로는 서브쿼리와 union을 사용할 수 없습니다. Github의 이슈와 답변에 따르면 Hibernate6가 좀 더 큰 범위에서 사용되면 QueryDSL 6.0 버전부터는 JPQL대신 HQL을 직접 사용하게 될수도 있다고 하네요.

1. querydsl-sql

방법이 없는것은 아닙니다. JPQL을 사용하는 JPAQuery 대신 바로 native sql로 변환되는 JPASQLQuery 인터페이스를 사용하면 됩니다. 인터페이스를 찾을 수 없다면 종속성에 querydsl-sql이 포함되어있는지 다시한번 확인해 보세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private static JPASQLQuery<?> query() {
    return new JPASQLQuery<>(entityManager, new PostgreSQLTemplates());
}

@Test
@DisplayName("QueryDSL UNION ALL")
void queryDSL() {
    var q = QOrder.order;
    var q1 = QOrder_2206.order_2206;
    var q2 = QOrder_2209.order_2209;
    var q3 = QOrder_2301.order_2301;

    var unionList = query().select(q.toName, q.toAddress, q.itemName, q.itemPrice).from(
            query().unionAll(
                    query()
                            .select(q1.toName.as(q.toName), q1.toAddress.as(q.toAddress), q1.itemName.as(q.itemName), q1.itemPrice.as(q.itemPrice))
                            .from(q1)
                            .where(q1.toName.like("김%"))
                    ,
                    query()
                            .select(q2.toName.as(q.toName), q2.toAddress.as(q.toAddress), q2.itemName.as(q.itemName), q2.itemPrice.as(q.itemPrice))
                            .from(q2),
                    query()
                            .select(q3.toName.as(q.toName), q3.toAddress.as(q.toAddress), q3.itemName.as(q.itemName), q3.itemPrice.as(q.itemPrice))
                            .from(q3)
                            .where(q3.itemPrice.goe(100000))
            ).as(String.valueOf(q))
    ).fetch();

    System.out.println(unionList);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
select order1.toName, order1.toAddress, order1.itemName, order1.itemPrice
from ((select order_2206.toName    as toName,
              order_2206.toAddress as toAddress,
              order_2206.itemName  as itemName,
              order_2206.itemPrice as itemPrice
       from order_2206
       where order_2206.toName like ?)
      union all
      (select order_2209.toName    as toName,
              order_2209.toAddress as toAddress,
              order_2209.itemName  as itemName,
              order_2209.itemPrice as itemPrice
       from order_2209)
      union all
      (select order_2301.toName    as toName,
              order_2301.toAddress as toAddress,
              order_2301.itemName  as itemName,
              order_2301.itemPrice as itemPrice
       from order_2301
       where order_2301.itemPrice >= ?)) as order1;

코드와 sql 입니다. 쿼리를 직접 db에 날리기 전까지 에러(예. PostgreSQL - FROM 절 내의 subquery 에는 반드시 alias를 가져야만 합니다. 에러 발생) 를 잡을수 없지만 일단 돌아가기만 하면 위의 두 방식들보다는 더 type-safe한 쿼리를 작성할 수 있다는 장점이 있습니다.

다만 코드를 보시면 앞선 쿼리들과 달리 union한 결과물에서 FAILED 상태를 제외하지 않았는데요, 만약 상태를 제외하고자 조건을 추가한다면 JPQL로 변환되지 않기 때문에 status column (varchar)을 자바 enum과 매핑할 수 없어 에러가 발생하게 됩니다. 이처럼 querydsl을 사용하는 의미가 약간은 퇴색될 수 있다는 단점이 있습니다.

2. Blaze Persistence (QueryDSL Integration)

querydsl-sql을 사용할때 발생하는 문제를 해결할 수 있는 방법이 있습니다. 바로 Blaze Persistence 를 사용하는 것입니다.

Hibernate, QueryDSL의 깃헙 이슈나 PR들을 볼때 자주 등장하는 라이브러리인데 한번 사용해 보았습니다. 이게 무엇이고 어떻게 사용하는지는 문서에 상세하게 설명되어 있으니 한번 읽어보시길 바랍니다.

Blaze Persistence는 CTE를 지원합니다. 앞선 예제들에서 사용해왔던 서브쿼리 형태의 쿼리를 CTE로 변경하면 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
with cte as (
    select *
    from order_2206
    where order_2206.toname like '김%'
    union all
    select *
    from order_2209
    union all
    select *
    from order_2301
    where order_2301.itemprice >= 100000
) select * from cte where status <> 'FAILED'

이를 자바코드로 변환하면 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
@CTE
@Entity
public class OrderCTE {

    @Id
    String id;

    @Enumerated(EnumType.STRING)
    OrderStatus status;

    String fromAddress;
    String fromName;
    String fromPhoneNumber;

    String toAddress;
    String toName;
    String toPhoneNumber;

    String itemName;
    int itemPrice;

    LocalDateTime createdDateTime;
}

private BlazeJPAQueryFactory jpaQueryFactory() {
    var config = Criteria.getDefault();
    criteriaBuilderFactory = config.createCriteriaBuilderFactory(entityManagerFactory);

    return new BlazeJPAQueryFactory(entityManager, criteriaBuilderFactory);
}

private <T extends EntityPathBase<? extends Order>> Expression<?>[] binding(T targetQ) {
    var q = QOrderCTE.orderCTE;

    return new Expression[] {
            JPQLNextExpressions.bind(q.id, Expressions.stringPath(targetQ, "id")),
            JPQLNextExpressions.bind(q.status, Expressions.path(OrderStatus.class, "status")),
            JPQLNextExpressions.bind(q.fromAddress, Expressions.stringPath(targetQ, "fromAddress")),
            JPQLNextExpressions.bind(q.fromName, Expressions.stringPath(targetQ, "fromName")),
            JPQLNextExpressions.bind(q.fromPhoneNumber, Expressions.stringPath(targetQ, "fromPhoneNumber")),
            JPQLNextExpressions.bind(q.toAddress, Expressions.stringPath(targetQ, "toAddress")),
            JPQLNextExpressions.bind(q.toName, Expressions.stringPath(targetQ, "toName")),
            JPQLNextExpressions.bind(q.toPhoneNumber, Expressions.stringPath(targetQ, "toPhoneNumber")),
            JPQLNextExpressions.bind(q.itemName, Expressions.stringPath(targetQ, "itemName")),
            JPQLNextExpressions.bind(q.itemPrice, Expressions.path(Integer.class, "itemPrice")),
            JPQLNextExpressions.bind(q.createdDateTime, Expressions.path(LocalDateTime.class, "createdDateTime"))
    };
}

@Test
@DisplayName("QueryDSL Blaze Persistence Integration")
void queryDSLBlazePersistenceIntegration() {

    var q = QOrderCTE.orderCTE;
    var q1 = QOrder_2206.order_2206;
    var q2 = QOrder_2209.order_2209;
    var q3 = QOrder_2301.order_2301;

    var list = jpaQueryFactory()
            .with(
                    q,
                    jpaQueryFactory().unionAll(
                            JPQLNextExpressions
                                    .select(binding(q1))
                                    .from(q1)
                                    .where(q1.toName.like("김%")),
                            JPQLNextExpressions
                                    .select(binding(q2))
                                    .from(q2),
                            JPQLNextExpressions
                                    .select(binding(q3))
                                    .from(q3)
                                    .where(q3.itemPrice.goe(100000))
                    )
            )
            .select(q)
            .from(q)
            .where(q.status.ne(OrderStatus.FAILED))
            .fetch();

    System.out.println(list.size());
}

OrderCTE 라는 클래스를 생성하고 @CTE 어노테이션과 QClass를 생성하기 위해 @Entity 어노테이션을 붙였습니다. OrderCTE 클래스는 일종의 임시 테이블 (db로 치면 view) 이라고 보시면 될것 같습니다.

주의해야 할 점은 A, B, C 3개의 엔티티를 union all 하고자 할 때 @Id가 붙은 컬럼의 값은 중복되지 않아야 한다는 것입니다. 만약 A id 1, B id 1, C id 1 3개의 테이블을 union all 하면 3개의 row가 리턴되는 것이 아닌 1개의 row만 리턴되게 됩니다.

JPA에서 @Id 어노테이션이 붙은 필드는 유지크해야 하기 때문에 어찌보면 당연한(?) 것이라 생각됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
select
    o1_0.id,
    o1_0.createdDateTime,
    o1_0.fromAddress,
    o1_0.fromName,
    o1_0.fromPhoneNumber,
    o1_0.itemName,
    o1_0.itemPrice,
    o1_0.status,
    o1_0.toAddress,
    o1_0.toName,
    o1_0.toPhoneNumber
from
    (select
        o1_0.id,
        o1_0.status,
        o1_0.fromAddress,
        o1_0.fromName,
        o1_0.fromPhoneNumber,
        o1_0.toAddress,
        o1_0.toName,
        o1_0.toPhoneNumber,
        o1_0.itemName,
        o1_0.itemPrice,
        o1_0.createdDateTime
    from
        Order_2206 o1_0
    where
        o1_0.toName like ? escape '!'
    union
    all select
        o2_0.id,
        o2_0.status,
        o2_0.fromAddress,
        o2_0.fromName,
        o2_0.fromPhoneNumber,
        o2_0.toAddress,
        o2_0.toName,
        o2_0.toPhoneNumber,
        o2_0.itemName,
        o2_0.itemPrice,
        o2_0.createdDateTime
    from
        Order_2209 o2_0
    union
    all select
        o3_0.id,
        o3_0.status,
        o3_0.fromAddress,
        o3_0.fromName,
        o3_0.fromPhoneNumber,
        o3_0.toAddress,
        o3_0.toName,
        o3_0.toPhoneNumber,
        o3_0.itemName,
        o3_0.itemPrice,
        o3_0.createdDateTime
    from
        Order_2301 o3_0
    where
        o3_0.itemPrice>=?
) o1_0(id,status,fromAddress,fromName,fromPhoneNumber,toAddress,toName,toPhoneNumber,itemName,itemPrice,createdDateTime)
where
    o1_0.status!=?

생성되는 sql은 위와 같습니다. 자바 코드로는 CTE를 사용했더라도 마지막에는 서브쿼리 형태의 쿼리로 변환되는 것을 확인할 수 있습니다. 실무에서 꽤 복잡한 통계 쿼리에도 적용해 보았는데 결과값이 동일하게 나오는 것으로 보아 계속 사용해도 문제없을 것이라 생각됩니다.

결론

JPA 환경에서 union을 사용하는 방법들에 대해 알아보았습니다. QueryDSL을 사용한다면 Blaze Persistence를 붙여 사용하는것이 최고의 방법이라고 생각됩니다.

Hibernate 버전이 6까지 올라오면서 기존의 JPA로는 불가능했던 일들이 어느정도 가능해진 것으로 보입니다. (EclipseLink는 오래전부터 지원했던것…) 특정 상황에서 어쩔 수 없이 사용해 왔던 native sql을 순수 자바 코드로만 표현하게 될 날도 머지 않아 보입니다.

이 포스팅에 사용된 예제 코드는 이곳 에서 확인하실 수 있습니다.

This post is licensed under CC BY 4.0 by the author.