[JPA, QueryDSL] JPA와 QueryDSL의 exists 성능 테스트
JPA와 QueryDSL의 exists 성능 테스트
이번에 회사 업무를 진행하면서 exists 사용할 일이 생김 겸, 성능이 어떻게 다른지, 어떻게 사용하는 것이 올바른 방법인지 테스트를 진행했습니다. 우선 아시다시피 QueryDSL에서 제공하는 exists는 성능이 좋지 않다는 글들을 많이 보셨을 겁니다. 저 또한 향로님이 작성하신 글을 보고 QueryDSL에서 exists를 사용할 일이 생길 경우 직접 쿼리를 만들어 사용해야 한다고 알고 있습니다. 다만 저는 직접 눈으로 보고 확인해 봐야 직성이 풀리는 성격이라 몇 가지 쿼리들을 직접 테스트를 진행해 봤습니다. 코드는 깃허브에서 확인하실 수 있습니다.
총 5가지의 테스트를 진행했으며 그에 따른 실행 시간을 측정하였습니다.
- QueryDSL의 exists
- JpaRepository에서 제공하는 existsById
- JpaRepository에서 제공하는 커스텀 exists
- QueryDSL의 limit + selectOne or fetchFirst 사용
- QueryDSL의 selectOne과 fetchFirst 사용
모든 테스트는 Member 테이블에 500만 개 저장한 후 진행하였으며 DB는 MySQL을 사용했습니다.
(JDBC bulk insert를 사용할까 하다가 단발성 테스트라 jpa의 save를 사용했습니다...ㅎ)
@Test
@Rollback(value = false)
@Order(1)
public void set() {
for (int i = 0; i < 5000000; i++) {
memberRepository.save(new Member("test" + i));
}
entityManager.flush();
entityManager.clear();
}
Test1. QueryDSL의 exists - 결과 : 5297ms
우선 성능이 좋지 않다는 QueryDSL의 exists를 테스트를 진행하였습니다. 기본적으로 QueryDSL에서 제공하는 exists는 클래스명에서 볼 수 있듯이 서브쿼리로 사용되기 때문에 다음과 같이 구현해야 합니다.
(QueryDSL에서 제공하는 exists의 구현 코드는 FetchableSubQueryBase.class
에서 확인할 수 있습니다.)
@Test
@DisplayName("exists 성능 테스트 case6")
public void exists_test6() {
long start = System.currentTimeMillis();
Integer isExists = queryFactory
.selectOne()
.from(QMember.member)
.where(JPAExpressions
.select(QMember.member)
.from(QMember.member)
.where(QMember.member.name.eq("test3500000"))
.exists()
)
.fetchOne();
log.info("isExists = {}", isExists);
long end = System.currentTimeMillis();
log.info("case6 = {}", end - start);
}
Hibernate: select 1 from member m1_0 where exists(select 1 from member m2_0 where m2_0.name=?)
c.m.jpa.exist_test.QuerydslExistsTest : isExists = 1
c.m.jpa.exist_test.QuerydslExistsTest : case6 = 4636
select절에서 exists 메서드를 사용하지 않는 이유?
JPQL은 from절 없이는 쿼리를 생성할 수 없기 때문에 select절에서 exists 메서드를 사용할 수 없습니다. 따라서 where절에서 exists 메서드를 사용해야 합니다.
또한 향로님이 지적하신 exists의 실행 방식을 보면 fetchCount > 0
으로 구현이 되어있어 성능이 안 좋다고 하셨는데 현재는 fetchCount()
가 Deprecated 되어 다음과 같은 코드로 변경되어 개선이 되었습니다.
@Override
public BooleanExpression exists() {
QueryMetadata metadata = getMetadata();
if (metadata.getProjection() == null) {
queryMixin.setProjection(Expressions.ONE);
}
return Expressions.predicate(Ops.EXISTS, this);
}
Expressions.ONE
을 사용하여 성능이 향상되었지만 아직까진 서브쿼리로 사용해야 한다는 점과 where절에서 사용해야 한다는 점과 같은 이유로 성능 이슈가 해결되지 않았기 때문에 exists를 사용하지 않는 것을 권장합니다.
Test2. JpaRepository에서 제공하는 existsById - 결과 : 263ms
두 번째는 JpaRepository에서 제공하는 existsById
메서드를 이용하여 테스트를 진행하였습니다. 실행 시간을 보면 263ms로 성능이 매우 좋은 것을 알 수 있습니다. 미리 말씀드리면 해당 테스트가 성능이 제일 좋은데 다른 쿼리들보다 성능이 좋은 이유는 아래에서 설명하겠습니다.
@Test
@DisplayName("exists 성능 테스트 case2")
public void exists_test2() {
long start = System.currentTimeMillis();
boolean isExists = memberRepository.existsById(3500000L);
log.info("isExists = {}", isExists);
long end = System.currentTimeMillis();
log.info("case2 = {}", end - start);
}
Hibernate: select count(*) from member m1_0 where m1_0.id=?
c.m.jpa.exist_test.QuerydslExistsTest : isExists = true
c.m.jpa.exist_test.QuerydslExistsTest : case2 = 263
Test3. JpaRepository에서 제공하는 커스텀 exists - 결과 : 1497ms
JpaRepository에서 exists
메서드를 만들어 테스트를 진행하였습니다.
@Test
@DisplayName("exists 성능 테스트 case1")
public void exists_test1() {
long start = System.currentTimeMillis();
boolean isExists = memberRepository.existsByName("test3500000"); // 커스텀 exists
log.info("isExists = {}", isExists);
long end = System.currentTimeMillis();
log.info("case1 = {}", end - start);
}
Hibernate: select m1_0.id from member m1_0 where m1_0.name=? limit ?
c.m.jpa.exist_test.QuerydslExistsTest : isExists = true
c.m.jpa.exist_test.QuerydslExistsTest : case1 = 1497
결과는 1497ms로 QueryDSL의 exists
보다는 성능이 좋다는 것을 알 수 있지만 JpaRepositoryh의 existsById
보다는 성능이 좋지 않다는 것을 볼 수 있습니다.
이와 같은 결과가 나온 이유가 무엇일까요?
데이터베이스의 기초적인 내용입니다. 해당 내용은 Real MySQL에서도 찾을 수 있습니다.
9.2.1 풀 테이블 스캔과 풀 인덱스 스캔
...
풀 테이블 스캔은 인덱스를 사용하지 않고 테이블의 데이터를 처음부터 끝까지 읽어서 요청된 작업을 처리하는 것을 의미한다.
...
풀 인덱스 스캔은 인덱스를 처음부터 끝까지 스캔하는 것을 의미한다.
...
단순히 레코드의 건수만 필요로 하는 쿼리라면 용량이 작은 인덱스를 선택하는 것이 디스크 읽기 횟수를 줄일 수 있기 때문이다. 일반적으로 인덱스는 테이블의 2~3개 컬럼으로만 구성되기 때문에 테이블 자체보다는 용량이 적어 훨씬 빠른 처리가 가능하다.
즉, JPA에서 식별자는 자동으로 인덱싱을 해주기 때문에 Test2의 경우 풀 인덱스 스캔을 하여 성능이 좋은 결과가 나타났고, Test3의 경우는 풀 테이블 스캔을 했기 때문에 성능이 Test2보다는 좋지 않은 결과가 나온 것입니다.
Test4. QueryDSL의 limit + selectOne or fetchFirst 사용 결과 : 1800ms
limit
+ selectOne
과 fetchFirst
는 내부적으로 프로젝션(select)을 제외하고 쿼리가 똑같기 때문에 성능이 비슷하여 같이 진행하였습니다. limit
+ selectOne
를 사용한 쿼리는 향로님이 QueryDSL의 exists를 대신 사용하는 방법으로 알려준 쿼리입니다.
@Test
@DisplayName("exists 성능 테스트 case4")
public void exists_test4() {
long start = System.currentTimeMillis();
Integer isExists = Optional.ofNullable(queryFactory.selectOne()
.from(QMember.member)
.where(QMember.member.name.eq("test3500000"))
.limit(1)
.fetchOne()).orElse(0);
log.info("isExists = {}", isExists);
long end = System.currentTimeMillis();
log.info("case4 = {}", end - start);
}
@Test
@DisplayName("exists 성능 테스트 case5")
public void exists_test5() {
long start = System.currentTimeMillis();
Long isExists = Optional.ofNullable(queryFactory.select(QMember.member.id)
.from(QMember.member)
.where(QMember.member.name.eq("test3500000"))
.fetchFirst()).orElse(0L);
log.info("isExists = {}", isExists);
long end = System.currentTimeMillis();
log.info("case5 = {}", end - start);
}
Hibernate: select m1_0.id from member m1_0 where m1_0.name=? limit ?
c.m.jpa.exist_test.QuerydslExistsTest : isExists = 3500001
c.m.jpa.exist_test.QuerydslExistsTest : case3 = 1846
-----------------------------------------------------------------
Hibernate: select 1 from member m1_0 where m1_0.name=? limit ?
c.m.jpa.exist_test.QuerydslExistsTest : isExists = 1
c.m.jpa.exist_test.QuerydslExistsTest : case5 = 1728
위 쿼리에서 from절의 조건을 식별자(id)로 변경하여 실행하면 위 Test2와 비슷한 성능인 것을 확인하실 수 있습니다.
결론
- 특정 데이터를 조회 시 가능하다면 인덱싱 된 속성을 사용(ex-PK)
- QueryDSL의 exists는 성능이 좋지 않으므로 직접 쿼리를 구현할 것