JPA MySql Random 조회  (1) - order by rand()

JPA MySql Random 조회 (1) - order by rand()

서문

요구사항 개발 중 데이터를 랜덤으로 조회할 필요가 생겼다.

DB는 MySql을 사용하고 있다. Mysql은 rand()를 통해 랜덤 기능을 제공하며 데이터를 랜덤하게 조회하기 위해선 공식문서에도 나와있듯이 SELECT * FROM tbl_name ORDER BY RAND(); 와 같이 사용하면 된다.

애플리케이션 서버에서 이를 어떻게 구현할 지가 고민이었고 Spring Boot 2.7.5, JPA, QueryDsl 을 사용중이다.

JPQL 작성

Mysql의 rand()는 jpql이나 hql에서 지원해주는 함수가 아니다. (Oracle 문서, Hibernate 문서)

하지만 Hibernate 문서에도 나와있듯이 각 DB의 Dialect가 각자의 네이티브 함수들을 많이 등록해놨다. logging.level.org.hibernate.HQL_FUNCTIONS=debug 로 로그 레벨을 설정하고 스프링을 실행시키면 rand()가 그 중에 하나임을 확인 할 수 있다.

구현

# Spring Data Jpa @Query 사용
@Query("select member from Member member order by rand()")
List<Member> getRandomMembers();

# Entity Manager 사용
List<Member> resultList = entityManager.createQuery(
                            "SELECT m " +
                                "FROM Member m " +
                                "ORDER BY rand()", Member.class)
                        .getResultList();

rand()를 직접 호출해주면 된다.

Hibernate 개발에 참여했던 Vlad Mihalcea도 블로그에서 이 방법을 언급했다. (링크)

QueryDsl

QueryDsl에서도 NumberExpressions.random() 을 통해 랜덤 함수 호출이 가능하지만 Mysql을 사용할 경우random()이란 함수를 찾을 수 없다고 예외가 던져진다. Querydsl 깃허브를 가보면 해당 이슈를 볼 수 있다. (링크)

의아했던 점은 SQLTemplates 에 이미 rand()가 정의되었고 MySQLTemplatesSQLTemplates 을 상속받고 있기 때문에 rand() 가 동작해야 할 것 같은데 그렇지 않다는 점이다.

// QueryDsl 코드 일부
package com.querydsl.jpa.impl;
public class JPAQueryFactory implements JPQLQueryFactory  {

    public JPAQueryFactory(final EntityManager entityManager) {
        this.entityManager = () -> entityManager;
        this.templates = null;
    }

    @Override
    public JPAQuery<?> query() {
        if (templates != null) {
            return new JPAQuery<Void>(entityManager.get(), templates);
        } else {
            return new JPAQuery<Void>(entityManager.get());
        }
    }
}

package com.querydsl.jpa.impl;
public class JPAQuery<T> extends AbstractJPAQuery<T, JPAQuery<T>> {

    public JPAQuery(EntityManager em) {
        super(em, JPAProvider.getTemplates(em), new DefaultQueryMetadata());
    }
}

package com.querydsl.jpa;
public class JPQLTemplates extends Templates {

}

이는 JPAQueryFactory를 생성할 때 JPQLTemplates을 파라미터로 넘기지 않으면 JPAProviderEntityManager를 통해 JPQLTemplates가 설정되기 때문이다.

JPQLTemplatesTemplates를 상속받고 있기 때문에 위에서 볼 수 있듯이 랜덤 함수가random()으로 설정되며 JPQLTemplates를 상속받은 여타 클래스들도 랜덤과 관련된 설정은 없기에 기본 설정이 유지된다. JPAQueryFactory은 JPQL을 위한 클래스이고 MySql 전용이 아니기에 어찌보면 당연한 것이라는 생각도 든다. 내 프로젝트에선 Hibernate5Templates가 사용되었다.

public class MySqlJpqlTemplates extends Hibernate5Templates {
    protected MySqlJpqlTemplates() {
        super();
        add(Ops.MathOps.RANDOM, "rand()");
    }
}

// 사용
QMember memberPath = new QMember("member");
List<Member> fetch1 = new JPASQLQuery<>(entityManager, MySQLTemplates.DEFAULT)
                .select(memberPath)
                .from(memberPath)
                .orderBy(NumberExpression.random().asc())
                .fetch();

List<Member> fetch2 = new JPAQueryFactory(new MySqlJpqlTemplates(), entityManager)
                .select(member)
                .from(member)
                .orderBy(NumberExpression.random().asc())
                .fetch();

List<Member> fetch3 = new JPAQuery<>(entityManager, new MySqlJpqlTemplates())
                .select(member)
                .from(member)
                .orderBy(NumberExpression.random().asc())
                .fetch();

// 생성 쿼리
Hibernate: select m1_0.id from member m1_0 order by rand()

JPAQueryFactoryJPAQuery 생성자 중에 JPQLTemplates를 받는 생성자가 있으므로 위와 같이 JPQLTemplates를 상속받아 새 클래스를 정의해 사용하면 rand()가 호출되는걸 확인할 수 있다. 혹은 JPASQLQueryMySQLTemplates.DEFAULT를 넘겨줘도 된다.

package com.querydsl.core.types.dsl;
public final class Expressions {
    // 생략
    public static <T extends Number & Comparable<?>> NumberTemplate<T> numberTemplate(Class<? extends T> cl,
            String template, Object... args) {
            return numberTemplate(cl, createTemplate(template), Arrays.asList(args));
        }
   // 생략
    private static Template createTemplate(String template) {
            return TemplateFactory.DEFAULT.create(template);
        }
   // 생략
}

// 사용
List<Member> members = repository.query(query -> query
                .select(member)
                .from(member)
                .orderBy(Expressions.numberTemplate(Double.class, "function('rand')").asc())
                .fetch());

아니면 더 간단하게 orderBy()

Expressions.numberTemplate(Double.class, "function('rand')") 를 넘겨 호출해도 된다.

NumberExpressions.random()와 뭐가 다르길래 다르게 동작할까

함수명에서도 알 수 있듯이 Expressions.numberTemplate()는 새 템플릿을 등록하는 함수다. 주석에도 Create a new Template expression 로 적혀있다. 내부 구현을 보면TemplateFactory.DEFAULT.create(template) 를 호출해 함수를 템플릿에 등록하는걸 알 수 있다.

원래 방언에 등록된 함수만 호출할 수 있는지 알았는데 Hibernate 6으로 오면서 변경된 것 같다고 한다.

(관련 링크)

order by rand()의 성능 이슈

Mysql의 order by rand()는 인덱스를 타지 않고 테이블 풀스캔을 한 후 랜덤으로 정렬을 하기 때문에 다량의 데이터를 다룰 때 성능 이슈가 있을 수 있다.

대량의 데이터가 아니라면 order by rand() 사용도 괜찮다고 생각한다.

현재의 요구사항은 소수의 데이터를 위한 임시적인 기능이니 해당 방식을 채택했지만 만약 대량의 데이터를 다뤄야 한다면 다른 방법을 찾아야 할 것이다.

다른 방법들은 추후 글을 통해 정리해볼 예정이다