Querydsl 구조와 mysql 무작위 데이터 조회하기

Posted by yunki kim on February 14, 2023

  프로젝트에서 홈 모듈 도메인을 맡아서 기능을 구현하던 도중 다음과 같이 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 <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

 

참고 자료

- Querydsl Reference Guide

- Spring Boot에 QueryDSL을 사용해보자 

- 자바 ORM 표준 JPA 프로그래밍