업무 중 QueryDSL관련 성능 튜닝을 진행하는 중 알게된 내용에 관해 기술합니다.
예제 코드는 github에 업로드 되어 있으며 repository내 테스트 코드로 확인 가능합니다.
https://github.com/cobiyu/querydsl-subquery
GitHub - cobiyu/querydsl-subquery
Contribute to cobiyu/querydsl-subquery development by creating an account on GitHub.
github.com
QueryDSL에서 from subquery가 필요한 이유
커버링 인덱스
querydsl-jpa
에서는 커버링 인덱스를 통한 조회 성능 튜닝의 경우에도 from절의 subquery가 불가능하기 때문에 아래와 같은 방식으로 튜닝하게 됩니다.
1. 검색 조건으로 primary key만을 검색하는 쿼리 실행
2. 실제 필요한 데이터는 1번에서 반환한 primary key로 in() 검색으로 다시 쿼리 실행
쿼리를 2번으로 나눠도 힘든 상황이....
- 업무 중 검색한 primary key의 개수가 많아지는 상황에 마주하게 되었습니다. (
전체 데이터를 excel로 내려줘야 한다던가… 등의…) - 결국 데이터를 뽑아내는 쿼리의
IN()
절에 수많은 값이 오게 되고 → 이는 DB의 설정값에 따라 오류가 발생하게 되었습니다. (mysql은max allowed packet
, oracle은ORA-01795
)
그럼… 결국.. JDBCTemplate…?
JPA가 만능은 아니기 때문에 상황에 따라서 JDBCTemplate
을 사용하기는 하지만....
조회 필터에 따라 움직여야 하는 복잡한 쿼리를 type safety하지 않은 단순 string으로 다시 구현하기에는 너무나 많은 리소스가 필요합니다.
복잡한 필터 조회를 구현할 때 querydsl의 강력함을 경험했는데 다시… string을 조작하는 세계로 돌아가야 한다니… 자신이 없었습니다..
JPAQueryFactory와 SQLQueryFactory
QueryDSL이 jpa에서만 사용할 수 있는 것처럼 보이지만 일반적으로 QueryDSL이라 부르는것은 querydsl-jpa
를 칭합니다. 하지만 querydsl-sql
도 있습니다.
querydsl-jpa의 JPAQueryFactory
- 일반적으로 querydsl이라 하면 대부분 querydsl-jpa을 뜻합니다. 그리고 이 querydsl-jpa는
JPAQueryFactory
을 제공합니다. - 우리는 이
JPAQueryFactory
로 쿼리와 비슷한 형태의 로직을 작성하게 되는데 내부적으로는 아래와 같은 형태로 실행됩니다.- JPAQueryFactory로 질의 관련 로직 작성
- querydsl이 1번의 로직을 JPQL로 변환
- JPQL은 DB에 맞는 Native SQL로 변환
- Native SQL이 실제 DB에 실행됨
- 결국
JPQL
로 변환되는 과정을 거치기 때문에 JPQL에서 from subquery를 지원해야 하는 것인데 일단 JPQL은 from subquery를 지원되지 않습니다. - 그러므로 정확한 표현은 JPQL에서 from subquery가 불가하기 때문에 QueryDSL도 from subquery가 불가한 것입니다.
querydsl-sql의 SQLQuery
SQLQuery 사용해보기
querydsl-jpa
가 jpql로 변환해준다면 querydsl-sql
은 native query로 변환해줍니다.
그래서 QueryFactory 생성 방식도 JPAQueryFactory와는 다른 형태입니다.
// JPAQueryFactory는 EntityManager만을 필요
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
// SQLQueryFactory은 어떤 DB를 사용하는지에 대한 SQLTemplates 정보가 필요
@Bean
public SQLQueryFactory sqlQueryFactory() {
SQLTemplates sqlTemplates = MySQLTemplates.builder().build();
Configuration configuration = new com.querydsl.sql.Configuration(sqlTemplates);
return new SQLQueryFactory(configuration, dataSource);
}
이렇게 생성된 SQLQueryFactory
로 원하는 쿼리 로직을 작성하면 됩니다.
하지만 SQLQuery를 사용하지 않은 이유
querydsl-jpa에서 사용하는 Q클래스를 그대로 사용하지 못합니다. SQLQuery를 위한 별도의 Q클래스를 따로 관리해야하고 생성하는 방법이나 여러가지 제약사항도 querydsl-jpa보다 까다롭습니다.
처음부터 SQLQuery를 고려한 설계가 되었다면 사용해볼 수 있었겠지만 추후 유지보수를 생각했을 때는 오히려 JdbcTemplate이 더 나은 선택일 것 같다는 생각이 들었습니다.
JPASQLQuery
JPASQLQuery
는 JPA의 Entity를 JPQL이 아닌 → Natvie SQL 형태로 사용 가능하도록 하는 기능입니다.
JPQL을 위해서 만들어진 JPA Entity를 NativeSQL처럼 사용 가능하게 하다 보니 2가지 의존성이 필요합니다.
JPASQLQuery의 의존성
- com.querydsl:querydsl-jpa
JPASQLQuery
가 포함되어있는 의존성- 하지만
JPASQLQuery
는com.querydsl.sql.SQLTemplates
이 있어야만 동작할 수 있습니다.
- com.querydsl:querydsl-sql
- 위의
com.querydsl.sql.SQLTemplates
는querydsl-sql
에 있습니다.
- 위의
JPASQLQuery 사용법
1. 먼저 사용하려는 datasource의 DB 종류에 따른 SQLTemplates
를 Bean으로 등록합니다.
→ 이 과정은 코드레벨에서부터 특정 DB에 의존적이게 되는 단점을 수반하게 됩니다.
@Configuration
public class QuerydslConfig {
// mysql
@Bean
public SQLTemplates mysqlTemplates() {
return MySQLTemplates.builder().build();
}
}
2. QueryDSL을 사용하는 클래스에서 SQLTemplate
과 EntityManager
를 주입받아 JPASQLQuery
를 생성합니다.
@RequiredArgsConstructor
@Repository
public class OrderQueryRepository {
private final SQLTemplates sqlTemplates;
@PersistenceContext
private EntityManager entityManager;
@Transactional(readOnly = true)
public List<OrderDto> findOrderList() {
JPASQLQuery<?> jpaSqlQuery = new JPASQLQuery<>(entityManager, sqlTemplates);
// .... some logic
}
}
JPASQLQuery를 사용한 from subquery 예시
예시의 이해를 위해 간단한 주문서 Entity를 이용하도록 하겠습니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Table(name = "orders")
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "order_id")
private long orderId;
@Enumerated(EnumType.STRING)
@Column(name = "payment_type")
private PaymentType paymentType;
@Column(name = "order_code")
private String orderCode;
@Column(name = "price")
private long price;
@Embedded
private AddressInfo addressInfo;
@Builder.Default
@OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST)
private List<OrderDetail> orderDetails = new ArrayList<>();
public void addOrderDetail(OrderDetail orderDetail) {
orderDetail.setOrder(this);
orderDetails.add(orderDetail);
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Table(name = "order_detail")
@Entity
public class OrderDetail {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "order_detail_id")
private long orderDetailId;
@Column(name = "goods_name")
private String goodsName;
@ManyToOne
@JoinColumn(name = "order_id")
private Order order;
public void setOrder(Order order) {
this.order = order;
}
}
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@AllArgsConstructor
@Getter
@Embeddable
public class AddressInfo {
@Column(name="address")
private String address;
@Column(name="post_code")
private String postCode;
}
- 주문서(Order)와 주문 상세(OrderDetail)은 1:N 관계입니다.
- 주문서 상세(OrderDetail)에는 주문서에 담긴 상품 정보가 있습니다.
- 주문서(Order)에 address, postCode는 AddressInfo라는 클래스의
@Embedded
로 되어있습니다.
주문(Order) 중 주문상세(OrderDetail)에 특정 상품명이 포함된 주문(Order) 목록 조회
검색 조건은 OrderDetail에 있지만 실제 필요한 정보는 Order만이 필요한 요구사항입니다.
이런 경우에는 쿼리 성능 문제로 인해 커버링 인덱스를 이용한 쿼리를 사용하게 되고 자연스레 from에 서브쿼리가 필요하게 되며 그 형태는 다음과 같습니다.
SELECT odr.order_id,
odr.address,
odr.post_code,
odr.order_code,
odr.payment_type,
odr.price
FROM orders odr -- 실제 조회할 from
INNER JOIN (
SELECT DISTINCT sub_odr.order_id AS order_id -- 검색 조건을 실행할 sub query
FROM orders sub_odr
INNER JOIN order_detail odr_detail
ON odr_detail.order_detail_id = sub_odr.order_id
WHERE odr_detail.goods_name LIKE 'someGoodsName%' LIMIT 10
) AS sub_query_order
ON odr.order_id = sub_query_order.order_id;
위 쿼리를 QueryDSL을 이용하여 구현하는 과정은 아래와 같습니다.
JPASQLQuery를 이용한 조회
JPAExpressions
를 이용해서 sub query를 생성한 뒤 main 쿼리의 join에 위치시킵니다.
다만, querydsl-sql의 sub query를 이용하는 것이기 때문에 sub query 외부에서는 QClass이외의 단순 문자열에 의존해야 하는 불편함이 수반됩니다.
// OrderQueryRepository.java
public class OrderQueryRepository {
//...
public List<OrderDto> findOrderList() {
JPASQLQuery<?> jpaSqlQuery = new JPASQLQuery<>(entityManager, sqlTemplates);
StringPath subQueryAlias = Expressions.stringPath("sub_query_order");
StringPath address = Expressions.stringPath(order, "address"); // Embedded 오류로 인한 별도 path 생성
StringPath postCode = Expressions.stringPath(order, "post_code"); // Embedded 오류로 인한 별도 path 생성
List<OrderDto> orderList = jpaSqlQuery
.select(
Projections.constructor(
OrderDto.class,
order.orderId,
order.paymentType,
order.orderCode,
order.price,
address,
postCode
)
)
.from(order)
.innerJoin(
JPAExpressions.select(order.orderId).distinct()
.from(order)
.innerJoin(orderDetail).on(orderDetail.orderDetailId.eq(order.orderId))
.where(orderDetail.goodsName.like("someGoodsName%")),
subQueryAlias
).on(order.orderId.eq(
Expressions.numberPath(Long.class, subQueryAlias, "order_id")
))
.fetch();
return orderList;
}
}
위와 같이 QClass는 그대로 사용하면서 from절에 subquery가 사용이 가능한 → querydsl-jpq
+ querydsl-sql
이 섞인 형태로 쿼리 로직을 작성할 수 있게 되었습니다.
쿼리 결과는 아래와 같이 sub query가 join된 형태로 쿼리가 실행됩니다.
JPASQLQuery의 단점
sub query외부에서는 QClass의 형태로 참조가 불가능합니다.
sub query 자체는 querydsl-sql의 기능이기 때문에 외부에서는 QClass가 아닌 string 기반의 Path
를 기반으로만 참조가 가능합니다.
sub query 로직이라도 QClass로 작성할 수 있다는 점에서 trade off가 필요한 부분입니다.
@Embedded가 명확하지 않게 동작합니다.
@Embedded
가 동작하긴 하지만 정확하게(?) 동작하지는 않습니다.
1. select(order).from(..)
처럼 QClass 그대로 select하면 동작하지만 order.addressInfo.address
형태로 참조하게 되면 동작하지 않습니다.
2. QClass를 그대로 select하는 경우를 제외하고는 Expressions.stringPath(order, "address")
로 감싼 Path
형태로 참조해서 사용해야 합니다.
JPASQLQuery<?> jpaSqlQuery = new JPASQLQuery<>(entityManager, sqlTemplates);
StringPath subQueryAlias = Expressions.stringPath("sub_query_order");
StringPath address = Expressions.stringPath(order, "address"); // Embedded 오류로 인한 별도 path 생성
StringPath postCode = Expressions.stringPath(order, "post_code"); // Embedded 오류로 인한 별도 path 생성
List<OrderDto> orderList = jpaSqlQuery
.select(
Projections.constructor(
OrderDto.class,
order.orderId,
order.paymentType,
order.orderCode,
order.price,
address,
postCode
)
)
.from(order)
.innerJoin(
JPAExpressions.select(order.orderId).distinct()
.from(order)
.innerJoin(orderDetail).on(orderDetail.orderDetailId.eq(order.orderId))
.where(orderDetail.goodsName.like("someGoodsName%")),
subQueryAlias
).on(order.orderId.eq(
Expressions.numberPath(Long.class, subQueryAlias, "order_id")
))
.fetch();
결론
- JPASQLQuery는 완벽하게 QClass를 이용할 수 있는 형태는 아니다보니 사용 과정에서 trade off는 필요해 보이고 적절한 곳에만 사용해야한다고 생각됩니다.
- 저의 경우는 대부분의 쿼리 튜닝은 위의 방법(JPASQLQuery)을 사용하기보다는 id검색 쿼리, 조회쿼리를 나눠서 실행하는 형태로 대응합니다.
- 쿼리를 나눠서 실행할 수 없는 대용량 쿼리에 대한 성능 개선이 필요한 경우 한정해서만 JPASQLQuery를 사용하고 있습니다.
- 기존 이런 경우에는 JDBCTemplate을 이용해서 성능 개선을 했었는데 type safety하게 IntelliJ의 도움을 받아 가며 쿼리튜닝을 할 수 있다는 점에서 많은 생산성 향상을 가지고 올 수 있었습니다.
- 제가 생각한 JPASQLQuery의 사용 기준은 JPA나 QueryDSL-jpa를 사용하지 못해서 JDBCTemplate을 사용해야 할 때 사용해볼 만하다는 정도가 기준이 되지 않을까 생각됩니다.
'JPA' 카테고리의 다른 글
@Transactional에 관한 고찰 part 2 (2) | 2022.08.02 |
---|---|
@Transactional에 관한 고찰 (or 반성) (0) | 2022.05.08 |
[JPA] 일반 Join과 Fetch Join의 차이 (8) | 2021.06.30 |
Proxy형태로 동작하는 JPA @Transactional (8) | 2021.02.10 |
[JPA] OneToOne 성능 튜닝 사례 1 (1) | 2020.08.18 |