프로젝트에서 홈 모듈 도메인을 맡아서 기능을 구현하던 도중 다음과 같이 native query를 사용한 코드를 발견했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
@Query(value =
"select i.id,"
+ " i.listImageUrl,"
+ " i.title,"
+ " i.exhibitionType,"
+ " i.feeType,"
+ " i.pickCount,"
+ " i.startDate,"
+ " i.endDate,"
+ " p.isHearted,"
+ " count(a.id) as archiveCount,"
+ " AVG(a.starRating) as grade "
+ "FROM item as i"
+ " LEFT JOIN pick p on i.id = p.pickedItem"
+ " LEFT JOIN archive a on i.id = a.item and a.isVisibleAtItem=true "
+ "WHERE"
+ " i.feeType = 'FREE'"
+ " AND i.exposureType = 'ON'"
+ " AND DATE_FORMAT(i.startDate, '%Y-%m') = DATE_FORMAT(:requestTime, '%Y-%m') "
+ "GROUP BY i.id "
+ "ORDER BY RAND() "
+ "LIMIT 5", nativeQuery = true)
List<MonthlyFreeTicket> findRand5ByUserIdAndRequestTime(@Param("pickedUser")Long userId, @Param("requestTime")LocalDateTime requestTime);
|
cs |
Native query를 사용한 코드도 동작에는 문제가 없지만, 가독성이 떨어지고 컴파일 단계에서 문법적 오류를 걸러내지 못한다는 문제를 가지고 있습니다. 따라서 이 문제들을 개선하기 위해 QueryDSL로 native query를 대체하기로 했습니다. QueryDSL에 대한 간단한 소개와 이점은 다음과 같습니다.
Querydsl과 Querydsl의 이점
Querydsl은 정적 타입을 이용해 쿼리를 생성할 수 있게 해주는 프레임워크입니다. Querydsl이 제공하는 Fluent API를 이용해 쿼리를 작성할 수 있으며 JPA, JDBC 등을 지원합니다. QueryDSL을 사용하면 다음과 같은 이점을 누릴 수 있습니다.
- IDE의 코드 자동 완성 기능을 사용할 수 있다..
- 문법적으로 잘못된 쿼리를 허용하지 않는다.
- 도메인 타입과 프로퍼티를 안전하게 참조할 수 있다.
- 도메인 타입의 리팩터링에 더 유연하게 대처한다.
Querydsl이 위와 같은 이점을 가지는 이유는 String을 사용하지 않고 Q클래스를 사용해 쿼리가 type-safe 하기 때문입니다.. String을 사용한다면 오타 등의 문제로 잘못된 참조를 하게 됩니다. 또 한, 도메인 변경이 직접적으로 쿼리에 반영되지 않고, 자동완성도 되지 않습니다.
QueryDSL로 native query 대체하기
다시 본론으로 돌아가서, 위에서 설명했던 native query를 querydsl로 바꾸었습니다.
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
|
public class ItemRepositoryCustomImpl implements ItemRepositoryCustom {
...
private final JPAQueryFactory queryFactory;
...
public ItemRepositoryCustomImpl(EntityManager entityManager) {
this.queryFactory = new JPAQueryFactory(entityManager);
}
...
@Override
public List<MonthlyFreeTicketDto> findTopRandomFiveMonthlyFreeTicket(LocalDate now, Long userId) {
return queryFactory.select(
Projections.fields(MonthlyFreeTicketDto.class,
itemEntity.id.as("itemId"),
itemEntity.thumbnailImageUrl,
itemEntity.title,
itemEntity.exhibitionType,
itemEntity.feeType,
itemEntity.pickCount,
itemEntity.exposureType,
pick.isHearted,
archive.id.count().as("archiveCount"),
archive.starRating.avg().as("grade")
)
)
.from(itemEntity)
.leftJoin(pick)
.on(pick.pickedItem.eq(itemEntity), pick.pickedUser.id.eq(userId))
.leftJoin(archive)
.on(archive.item.eq(itemEntity))
.where(
itemEntity.feeType.eq(FeeType.FREE),
itemEntity.exposureType.eq(ExposureType.ON),
itemEntity.startDate.loe(now).and(itemEntity.endDate.gt(now)),
archive.isVisibleAtItem.isTrue()
)
.groupBy(itemEntity.id)
.orderBy(NumberExpression.random().asc())
.limit(MAX_MONTLY_FREE_TICKETS)
.fetch();
}
...
}
|
cs |
그리고 테스트를 돌려보니 다음과 같은 쿼리가 나갔습니다.
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
|
select
itementity0_.id as col_0_0_,
itementity0_.thumbnailImageUrl as col_1_0_,
itementity0_.title as col_2_0_,
itementity0_.exhibitionType as col_3_0_,
itementity0_.feeType as col_4_0_,
itementity0_.pickCount as col_5_0_,
itementity0_.exposureType as col_6_0_,
pick1_.isHearted as col_7_0_,
count(archiveent2_.id) as col_8_0_,
avg(cast(archiveent2_.starRating as double)) as col_9_0_
from
item itementity0_
left outer join
pick pick1_
on (
pick1_.pickedItem=itementity0_.id
and pick1_.pickedUser=?
)
left outer join
archive archiveent2_
on (
archiveent2_.item=itementity0_.id
)
where
(
itementity0_.isDeleted=0
)
and itementity0_.feeType=?
and itementity0_.exposureType=?
and itementity0_.startDate<=?
and itementity0_.endDate>?
and archiveent2_.isVisibleAtItem=?
group by
itementity0_.id
order by
random() asc limit ?
|
cs |
위 쿼리에는 아무런 문제가 없어 보입니다. 하지만 현재 MySQL을 사용하고 있다는 것을 생각해 본다면 "order by random()"에 문제가 있을 것을 알 수 있습니다. MySQL에서 랜덤 한 레코드를 액세스 하기 위해선 random()이 아닌 rand()가 사용됩니다. 따라서 위 쿼리는 이동한 동작을 하지 않습니다.
Querydsl 동작 과정
위와 같은 문제가 발생한 이유를 파악하기 위해선 QueryDSL의 구조를 알아야 합니다.
우선 JPAQueryFactory의 내부를 보면 update(), insert(), query() 메서드를 사용하는 것을 알 수 있습니다.
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
|
/**
* Factory class for query and DML clause creation
*
* @author tiwe
*
*/
public class JPAQueryFactory implements JPQLQueryFactory {
@Nullable
private final JPQLTemplates templates;
private final Provider<EntityManager> entityManager;
public JPAQueryFactory(final EntityManager entityManager) {
this.entityManager = new Provider<EntityManager>() {
@Override
public EntityManager get() {
return entityManager;
}
};
this.templates = null;
}
public JPAQueryFactory(JPQLTemplates templates, final EntityManager entityManager) {
this.entityManager = new Provider<EntityManager>() {
@Override
public EntityManager get() {
return entityManager;
}
};
this.templates = templates;
}
...
@Override
public JPAUpdateClause update(EntityPath<?> path) {
if (templates != null) {
return new JPAUpdateClause(entityManager.get(), path, templates);
} else {
return new JPAUpdateClause(entityManager.get(), path);
}
}
@Override
public JPAInsertClause insert(EntityPath<?> path) {
if (templates != null) {
return new JPAInsertClause(entityManager.get(), path, templates);
} else {
return new JPAInsertClause(entityManager.get(), path);
}
}
@Override
public JPAQuery<?> query() {
if (templates != null) {
return new JPAQuery<Void>(entityManager.get(), templates);
} else {
return new JPAQuery<Void>(entityManager.get());
}
}
}
|
cs |
여기서 update()는 수정, insert()는 삽입, query()는 조회에 사용되는 객체를 생성합니다. 또 한, 이 세 메서드는 공통적으로 templates가 null인지를 체크하고, null이 아니라면 지정된 template을 인자로 넘기고, template이 없다면 넘기지 않는 것을 알 수 있습니다. 지정한 template이 없다면 내부에서 default로 지정된 template을 사용합니다. 여기서 말하는 template은 서로 다른 RDBMS의 SQL 직렬화를 지원하기 위한 템플릿입니다.
위 코드에서 60번째 줄에서 사용한 JpaQuery의 생성자 구현을 보면 다음과 같이 JPAProvider에서 기본으로 지정된 템플릿을 가져오는 것을 알 수 있습니다. 이는 insert(), update()도 마찬가지입니다.
1
2
3
|
public JPAQuery(EntityManager em) {
super(em, JPAProvider.getTemplates(em), new DefaultQueryMetadata());
}
|
cs |
따라서 Querydsl을 사용하기 위해 JPAQueryFactory를 사용한다면, 다음과 같은 호출 구조를 갖게 됩니다.
이제 JPAProvider 내부를 보면 다음과 같이 하이버네이트용 default template이 존재하는 것을 볼 수 있습니다.
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
|
/**
* {@code JPAProvider} provides detection of the JPA provider based on the EntityManager instance
*
* @author tiwe
*
*/
public final class JPAProvider {
private static final Map<Class<?>, JPQLTemplates> mappings = Maps.newHashMap();
private static final Map<String, JPQLTemplates> templatesByName = Maps.newHashMap();
...
static {
boolean hibernate5;
try {
String version = Class.forName("org.hibernate.Session").getPackage().getImplementationVersion();
String[] versionParts = version.split("\\.");
int major = Integer.parseInt(versionParts[0]);
hibernate5 = major >= 5;
} catch (ClassNotFoundException e) {
hibernate5 = false;
}
JPQLTemplates hibernateTemplates = hibernate5 ? Hibernate5Templates.DEFAULT : HQLTemplates.DEFAULT;
...
addMapping("org.hibernate.Session", hibernateTemplates);
addMapping("org.hibernate.ejb.HibernateEntityManager", hibernateTemplates);
addMapping("org.hibernate.jpa.HibernateEntityManager", hibernateTemplates);
...
...
templatesByName.put("hibernate", HQLTemplates.DEFAULT);
templatesByName.put("hibernate5", Hibernate5Templates.DEFAULT);
...
}
public static JPQLTemplates getTemplates(EntityManager em) {
...
}
}
|
cs |
여기서 하이버네이트용 템플릿인 HQLTemplates(Hibernate5Templates는 HQLTemplates를 extends 한다.)를 보면 JPQLTemplates를 extends 하는 것을 알 수 있습니다. 그리고 JPQLTemplates가 extends 하고 있는 Template이라는 클래스에 Random이 지정된 있는 것을 볼 수 있습니다.
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
|
/**
* HQLTemplates extends {@link JPQLTemplates} with Hibernate specific extensions
*
* @author tiwe
*/
public class HQLTemplates extends JPQLTemplates {
...
}
/**
* {@code JPQLTemplates} extends {@link Templates} to provide operator patterns for JPQL
* serialization
*
* @author tiwe
* @see HQLTemplates
* @see EclipseLinkTemplates
*/
public class JPQLTemplates extends Templates {
...
}
/**
* {@code Templates} provides operator patterns for query expression serialization
*
* @author tiwe
*/
public class Templates {
...
add(Ops.MathOps.RANDOM, "random()");
add(Ops.MathOps.RANDOM2, "random({0})");
...
}
|
cs |
따라서 MySQL에서 무작위 데이터를 조회하기 위해선 별도의 템플릿을 지정해야 합니다.
커스텀 템플릿 정의하기
위 구조 설명에서 언급했듯 하이버네이트를 이용하기 위해선 HQLTemplates를 사용해야 합니다. 따라서 HQLTemplates를 extends하는 커스텀 템플릿을 다음과 같이 만들면 됩니다.
1
2
3
4
5
6
7
8
|
public class MysqlTemplates extends HQLTemplates {
public MysqlTemplates() {
super();
add(MathOps.RANDOM, "rand()");
add(MathOps.RANDOM2, "rand({0})");
}
}
|
cs |
이제 ItemRepositoryCustomImpl 가 해당 템플릿을 사용하게 지정하고 테스트를 돌리면 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
public class ItemRepositoryCustomImpl implements ItemRepositoryCustom {
...
private final JPAQueryFactory queryFactory;
...
public ItemRepositoryCustomImpl(EntityManager entityManager) {
this.queryFactory = new JPAQueryFactory(new MysqlTemplates(), entityManager);
}
...
@Override
public List<MonthlyFreeTicketDto> findTopRandomFiveMonthlyFreeTicket(LocalDate now, Long userId) {
...
}
...
}
|
cs |
그러면 다음과 같이 올마른 쿼리가 나가는 것을 확인할 수 있습니다
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
|
select
itementity0_.id as col_0_0_,
itementity0_.thumbnailImageUrl as col_1_0_,
itementity0_.title as col_2_0_,
itementity0_.exhibitionType as col_3_0_,
itementity0_.feeType as col_4_0_,
itementity0_.pickCount as col_5_0_,
itementity0_.exposureType as col_6_0_,
pick1_.isHearted as col_7_0_,
count(archiveent2_.id) as col_8_0_,
avg(cast(archiveent2_.starRating as double)) as col_9_0_
from
item itementity0_
left outer join
pick pick1_
on (
pick1_.pickedItem=itementity0_.id
and pick1_.pickedUser=?
)
left outer join
archive archiveent2_
on (
archiveent2_.item=itementity0_.id
)
where
(
itementity0_.isDeleted=0
)
and itementity0_.feeType=?
and itementity0_.exposureType=?
and itementity0_.startDate<=?
and itementity0_.endDate>?
and archiveent2_.isVisibleAtItem=?
group by
itementity0_.id
order by
rand() asc limit ?
|
cs |
만약 별도로 클래스를 정의하는 것이 귀찮다면, 또는 당장은 한 곳에서만 커스텀한 내용이 필요하다면, 다음과 같이 Expressions를 사용하는 방법도 있습니다.
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
|
javaqueryFactory.select(
Projections.fields(MonthlyFreeTicketDto.class,
itemEntity.id.as("itemId"),
itemEntity.thumbnailImageUrl,
itemEntity.title,
itemEntity.exhibitionType,
itemEntity.feeType,
itemEntity.pickCount,
itemEntity.exposureType,
pick.isHearted,
archive.id.count().as("archiveCount"),
archive.starRating.avg().as("grade")
)
)
.from(itemEntity)
.leftJoin(pick)
.on(pick.pickedItem.eq(itemEntity), pick.pickedUser.id.eq(userId))
.leftJoin(archive)
.on(archive.item.eq(itemEntity))
.where(
itemEntity.feeType.eq(FeeType.FREE),
itemEntity.exposureType.eq(ExposureType.ON),
itemEntity.startDate.loe(now).and(itemEntity.endDate.gt(now)),
archive.isVisibleAtItem.isTrue()
)
.groupBy(itemEntity.id)
.orderBy(Expressions.numberTemplate(Long.class, "function('rand')").asc())
.limit(MAX_MONTLY_FREE_TICKETS)
.fetch();
|
cs |
Expressions를 사용하면 Querydsl에 구현돼 있는 로직을 통해 템플릿을 생성할 수 있습니다. Expressions.numberTemplate()의 경우 다음과 같이 템플릿을 생성하고 있습니다.
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
|
/**
* Factory class for {@link Expression} creation.
*
* @author tiwe
*
*/
public final class Expressions {
...
/**
* Create a new Template expression
*
* @param cl type of expression
* @param template template
* @param args template parameters
* @return template expression
*/
public static <T extends Number & Comparable<?>> NumberTemplate<T> numberTemplate(Class<? extends T> cl,
String template, Object... args) {
return numberTemplate(cl, createTemplate(template), ImmutableList.copyOf(args));
}
...
private static Template createTemplate(String template) {
return TemplateFactory.DEFAULT.create(template);
}
...
}
|
cs |
참고 자료
- Spring Boot에 QueryDSL을 사용해보자