exists 메서드 성능 개선 - count vs exists
QueryDsl

exists 메서드 성능 개선 - count vs exists

2월 중순에 마무리 되었던 프로젝트에 대해 리팩토링을 진행하고자 코드를 찬찬히 훑던 중, QueryDsl로 exists 관련 메서드를 구현한 부분에서 개선점이 발견되어 성능을 개선해보았다.

 

일반적으로 많이 사용하는 방식이자 QueryDsl에서 구현되어있는 방식인 exists 메서드의 성능 측면 문제점을 먼저 비교해보고, 개선한 방식에 대해서 설명한다.

 

일반적인 exists 관련 쿼리는 아래와 같이 구현한다.

select exists(
    select 1
    from member
    where age = 1);

exists 내 서브쿼리를 통해 조건에 해당하는 컬럼이 존재하는지를 반환하는 형태의 쿼리를 이용한다.

 

스프링 내에서 JpaRepository를 이용해 간단한 조건에 대한 exists 메서드는 다음과 같이 Named Query를 이용해 쉽게 구현이 가능하다.

boolean existsByAge(int age);

 

그러나 복잡한 조건이 포함되는 쿼리에 대해서는 주로 @Query 어노테이션을 사용해 JPQL로 많이 구현하게 되는데, 이 때 문제가 있다.

JPQL의 경우는 exists 문법을 지원하지 않는다는 것이다.

이로 인해, 복잡한 조건이 포함되는 exists 메서드는 아래와 같이 count 쿼리를 주로 이용해 구현한다. ( > 0 )

@Query("select count(*) from member where ~~~ and ~~")
int existComplex(int age, int some, int some2);

 

복잡한 조건과 더불어 다양한 동적 조건이 붙는 경우에는 JPQL만으로는 해결이 되지 않으므로 QueryDsl을 사용하게 된다.

이 때, QueryDsl 내부에 구현되어 있는 exists 메서드의 경우도 count > 0과 같이 count 쿼리를 이용해 구현되어 있다.

QuerydslJpaRepository.java

 

여기서 문제가 발생한다.

exists 쿼리의 경우 조건에 해당하는 row가 발견되면 쿼리를 종료하는 반면,

count쿼리의 경우 조건에 해당하는 row가 발견되어도 테이블의 끝까지 모두 스캔하므로 이에 따른 성능 차이가 발생한다.

 

성능 차이를 직접 확인해보기 위해 exists 쿼리와 count쿼리에 대한 수행시간 측정을 해보았다.

(데이터 500만 건 기준)

 

count 쿼리의 경우 약 1.3초, exists 쿼리의 경우 약 0.7초로 성능 차이가 측정되었다.

이러한 성능 차이는 찾고자 하는 값이 테이블의 앞에 위치할수록 더욱 두드러지게 나타난다.

age = 1로 변경하여 테이블의 앞에 위치하는 데이터의 존재여부를 확인했을 때,

count 쿼리의 경우 약 1.3초, exists 쿼리의 경우 약 0.03초로 성능 차이가 더욱 크게 나는 것을 확인할 수 있다.

exists 쿼리는 앞에서부터 순차적으로 스캔하고 row 발견 시 쿼리가 종료되므로 앞에 위치하는 데이터일 경우 exists 쿼리의 성능은 더욱 빨라질 수 밖에 없는 것이다.

 

이러한 성능차이는 데이터의 값이 많아질수록 더욱 심하게 나타날 것이다.

 

이러한 성능차이를 개선하기 위해서는 exists 쿼리를 사용해야 하는데,

JPQL은 exists 문법을 지원하지 않고, QueryDsl 역시 내부적으로 exists 메서드를 count 쿼리를 이용해 구현하고 있다.

 

이를 해결하기 위해, QueryDsl의 selectOne (= select 1)과 fetchFirst (= limit 1)을 사용하여 조건에 해당하는 row를 찾으면 쿼리를 종료할 수 있도록 직접 구현할 수 있다.

 

exists 메서드를 개선하기 전 내가 작성한 코드는 아래와 같이 count 쿼리를 이용하는 방식으로 구현되어 있었다.

로그로 확인해 보았을 때, count 쿼리가 나가는 모습을 볼 수 있었다.

 

이를 QueryDsl의 selectOne과 fetchFirst를 사용해 개선한 코드는 아래와 같다.

fetchFirst를 통해 where절 조건에 해당하는 row가 발견되면 쿼리를 종료함으로써 exists 쿼리와 동등한 동작을 수행할 수 있다.

이 때, 조건에 해당하는 row가 발견되지 않으면 fetchFirst의 결과가 null이므로, null 여부를 통해 exists의 결과를 반환할 수 있다.

 

개선된 exists 메서드에 대한 실제 쿼리는 다음과 같이 나간다.

exists라는 키워드는 없으나, select 1로 조회 컬럼을 단일화하고 limit 1을 설정함으로써 row 발견 시 쿼리 종료를 수행할 수 있다.

실제로 해당 쿼리의 실행속도를 측정했을 때, exists 쿼리와 크게 차이가 없음을 확인할 수 있다.

 


 

출처 : (유튜브) 우아한테크 - [우아콘2020] 수십억건에서 QUERYDSL 사용하기