이 글은 QueryDSL의 이점 중 하나인 코드 재사용성을 활용하는 방법을 소개합니다.
com.querydsl:querydsl-jpa:5.0.0
, org.springframework.boot:spring-boot-starter-data-jpa:2.6.6
버전에서 작성된 글입니다.
이 글의 목표는 QueryDSL을 사용하여 쿼리 작성 시 다양한 기법을 사용하여 코드 품질을 높이는 방법을 얻는 것입니다.
예시를 위하여 많이 사용되는 사용자, 주문, 상품으로 구성된 주문 서비스의 간단한 모델을 사용합니다.
@Entity
public class User {
@Id
@GeneratedValue
private Long userId;
private String name;
// getters and setters
}
@Entity
public class Product {
@Id
@GeneratedValue
private Long productId;
private String productName;
private Integer price;
// getters and setters
}
@Entity
public class Orders {
@Id
@GeneratedValue
private Long orderId;
private LocalDateTime orderDate;
private Long productId;
@ManyToOne
private User user;
// getters and setters
}
JPAQueryFactory
를 사용하여 주문정보를 조회하는 쿼리를 작성합니다.
queryfactory.select(
order.orderId,
order.orderDate,
product.productId,
product.productName,
user.userId,
user.name)
.from(order)
.join(product).on(order.productId.eq(product.productId))
.join(order.user, user)
.fetch()
JPAQueryFactory
에 정의된 메소드를 사용하여 Java코드로 쿼리를 작성할 수 있다. 어떻게 가능한걸까?
내부 코드를 파해쳐보자.
다음은 JPAQueryFactory
내부 코드 중 일부이다.
실제 구현 코드는 없고 JPAQuery
클래스의 빌더 역할만 하고 있다. JPAQuery
를 살펴보자.
다음은 JPAQuery
의 클래스 다이어그램이다.
관계가 매우 복잡한데 이때 제레릭 변수 T
는 select
로 반환되는 타입이고 Q
는 현재 클래스의 구체적인 하위 타입이다. 예를 들면 JPAQueryBase
의 Q
타입으로 그 하위 클래스들이 대상이 될 수 있다. (AbstractJPAQuery
, JPAQuery
)
빌더 패턴으로 작성한 쿼리를 누적시키기위해 메소드의 리턴 타입을 Q
타입으로 설계를 한 것으로 보인다. 그럼 어떤 필드에서 쿼리가 누적되고 있을까? 한번 찾아보자.
JPAQuery
의 select()
JPAQueryBase
의 from()
및 join()
이 외에 쿼리를 작성하는 메소드에서 필드queryMixin
의 메소드를 실행시켜 쿼리를 작성하는 것을 확인할 수 있다.
필드 queryMixin
의 타입은 JPAQueryMixin
인데 여기까지 파해치진 않겠다. 이미 우리는 JPAQuery
내부에서 JPAQueryMixin
에 쿼리를 담아 생성한다는 것을 유추해냈다.
이와 같은 패턴을 봤을 때 우리도 빌더 패턴을 이용해 우리가 원하는 형식으로 빌더 클래스를 만들고 쿼리를 만들어낼 수 있을 것이다.
위에서 작성한 주문정보 조회 메소드를 다시 살펴보자.
queryfactory.select(
order.orderId,
order.orderDate,
product.productId,
product.productName,
user.userId,
user.name)
.from(order)
.join(product).on(order.productId.eq(product.productId))
.join(order.user, user)
.fetch()
여기서 다음 부분을 개선할 수 있다.
queryfactory.select(
order.orderId,
order.orderDate,
product.productId,
product.productName,
user.userId,
user.name)
...
비지니스 요구사항을 충족하기 위해 여러 도메인에서 필요한 컬럼을 추출해야한다. 하지만 때때로 비슷한 컬럼을 추출하는 경우도 있다. 예를들면 주문정보를 포함하는 데이터는 주문식별자나 주문일시가 필수라던지, 회원 데이터는 회원명이 필수라던지...
이런 경우 동일한 코드가 늘어날 것이고 갑자기 요구사항으로 '회원 데이터를 포함하는 API에 회원 가입일시를 모두 넣어주세요'라는 요청을 받으면 모든 API를 탐색해서 일일이 회원 가입일시를 넣어주어야 한다.
또 현재는 한 객체에 도메인 구분없이 필드를 나열하고 있다. 물론 개발하는데 지장은 없지만 도메인별로 관련 필드를 묶어주면 클라이언트가 도메인을 좀 더 명확하게 구분할 수 있으므로 서로간의 의사소통이 원활해질 수 있다.
위 개선사항들을 요약하면
그렇담 개선책은 도메인별 필수 컬럼을 포함하는 DTO를 생성하고 그 DTO를 노출시키면 개선사항을 만족할 수 있다.
다음과 같이 DTO를 생성해보자.
public class SimpleUser {
private Long userId;
private String name;
// getters and setters
}
public class SimpleProduct {
private Long productId;
private String productName;
// getters and setters
}
public class SimpleOrder {
private Long orderId;
private LocalDateTime orderDate;
// getters and setters
}
각 도메인별 최소 필요 컬럼을 나열했다. 꼭 필수 컬럼의 관점이 아닌 다른 비지니스의 관점으로 바라보아 DTO를 작성해도 좋다.
DTO를 작성했으니 해당 DTO를 QueryDSL에 매핑해주어야한다. 여러번 재사용될 DTO이니 Util클래스를 작성해주자.
public class UserProjection { // 1
public static QBean<?> simpleUser(QUser user) { // 2
return Projection.bean(SimpleUser.class,
user.userId,
user.name
);
}
}
// ... 나머지 DTO도 동일하게 유틸 클래스를 작성한다.
public class UserProjection {
public static QBean<?> simpleUser(QUser user) {
return Projection.bean(SimpleUser.class,
user.userId,
upperCaseName(user)
);
}
public static StringExpression upperCaseName(QUser user) {
return user.name.toUpperCase();
}
}
어디까지나 예시이며 이런식으로 표현식을 메소드로 분리해 재사용할 수 있다는 점만 이해해두자.
SimpleUser
를 Projection
을 활용하여 매핑해주었다. JpaQueryFactory.select()
의 인자는 Expression
이므로 무리없이 매핑될 것이다.이런식으로 작성한다면 다음과 같이 개선된다.
queryfactory.select(Projection.bean(OrderResponse.class,
OrderProjection.simpleOrder(order).as("order"), // alias를 지정해주지 않으면 내부에서 인식을 하지 못한다.
ProductProjection.simpleProduct(product).as("product"),
UserProjection.simpleUser(user).as("user")))
...
이렇게 공통 클래스로 묶으면 공통 클래스 수정 시 사용처 전체에 영향이 가므로 생성/수정을 신중히 해야한다. 물론 경우에 따라 컬럼 노출 개수가 민감하거나 그렇지 않은 경우도 있으니 이 점은 유지보수가 우선인지 성능이 우선인지 판단하여 사용하기 바란다.
...중략
.from(order)
.join(product).on(order.productId.eq(product.productId))
.join(order.user, user)
.fetch()
테이블간에 연관된 데이터를 조회하기 위해 조인은 굉장히 많이 사용한다.
단순 내부,외부 조인뿐 아니라 셀프조인, 자연조인 등 종류도 다양하며 테이블 설계에 따라 항시 조인해야하는 경우도 있다.
그리고 한 도메인에서 조인 대상이 되는 테이블은 대부분 정해져있다.
이럴 경우 매번 같은 조인문을 작성하는 것이 대부분이며 중복코드가 늘어난다.
이러한 중복코드를 줄이기 위해 우리는 미리 join문을 정의하여 재사용하도록 할 수 있다.
방법은 도메인별로 from절을 정의할 수 있는 팩토리 클래스를 만드는 것이다.
위에서 살펴본 JPAQueryFactory
의 구현 방식을 참고하여 다음과 같이 팩토리 클래스를 작성해보자.
public abstract class AbstractQueryFactory { // 1
protected final JPAQuery<?> queryMixin; // 2
protected AbstractQueryFactory(JPAQuery<?> query) { // 3
this.queryMixin = query;
}
public JPAQuery<?> build() { // 4
return queryMixin;
}
}
JPAQuery
로 인해 작성되니 final로 지정해 변동될 수 없도록 하고 하위 클래스에서 접근 가능하도록 한다.build()
를 호출해 JPAQuery
를 반환하도록 한다.이제 AbstractQueryFactory
를 사용하여 각 도메인별 팩토리 클래스를 만들 수 있다.
주문 테이블의 팩토리 클래스를 만들어보자.
public class OrderQueryFactory extends AbstractQueryFactory { // 1
protected OrderQueryFactory(JPAQuery<?> query) {
super(query);
}
public static OrderQueryFactory query(JPAQueryFactory query) { // 2
return new OrderQueryFactory(query.query());
}
public OrderQueryFactory from(QOrders order) { // 3
queryMixin.from(order);
return this;
}
public OrderQueryFactory join(QOrders order, QProduct product) { // 4
queryMixin.join(product).on(order.productId.eq(product.productId));
return this;
}
public OrderQueryFactory join(QOrders order, QUser user) {
queryMixin.join(order.user, user);
return this;
}
}
AbstractQueryFactory
를 상속하여 클래스를 정의해분다.JPAQueryFactory
의 query()
메소드는 우리가 사용할 수 있는 JPAQuery
를 생성해준다. 따라서 JPAQueryFactory
를 인자로 받아 OrderQueryFactory
를 생성해준다.JPAQuery
에 쿼리를 쌓아주고 메소드 체이닝으로 작성을 계속 이어나가도록 하는 것이다. 여기서는 from()
을 정의하여 실제 구현을 감추었다.OrderQueryFactory
를 사용할때 Q클래스만 넘기고 실제 구현은 OrderQueryFactory
에 맡기면 된다.이런직으로 작성하면 다음과 같이 개선된다.
OrderQueryFactory.query(queryFactory)
.from(order)
.join(order, product)
.join(order, user)
.build() // 1
.select(...)
OrderQueryFactory
로 from절을 모두 작성한 후 build()
를 호출하여 나머지 쿼리들을 JPAQuery
를 이용해 작성하도록 한다.이 방법의 장점은 다음과 같다.
// OrderQueryFactory.java
// 예시로만 봐주세요.
public static OrderQueryFactory leftJoinIsNull(QOrders orders, QUser user) {
queryMixin.leftJoin(user).on(orders.user.eq(user), orders.user.isNull());
return this;
}
User까지만 연결하고 User로 부터 연결할 수 있는 엔티티는 연결하지 않는다.
이런 공통 클래스의 사용은 팀 내부에서 어떻게 사용할 것인지 상세한 논의를 거쳐 사용하는 것이 좋습니다. 작성법, 제약사항, 사용법에 대한 규칙을 정하여 활용 시 막강한 생산성을 가져다 줄 것입니다.
이 글에서 제시한 패턴에 대한 이해를 완벽히 했다면 다양한 방식으로도 응용할 수 있을 것 입니다.
잘보고 가용~