IT/개발로그

Spring Data JPA @Query 사용시 주의점(JPA 버그)

김솔샤르 2022. 4. 24. 23:52

이전 회사에서 새벽에 돌아야할 배치가 안돌아서 난리가 났던 적이 한번 있었는데 알고보니 쿼리 하나가 몇 시간째 돌고 있었다. 어쨌든 원인을 빨리 찾아서 고치긴 했는데 분석을 해보니 Spring Data JPA(2.6.2 기준)에 문제가 있었다. @Query annotaion을 쓰면서 Paging 처리를 할 때 발생할 수 있는 이슈인데, 다음 번에 깊게 파볼 생각으로 남겨두었던게 갑자기 생각이 나서 좀 들여다보았다.

상황을 재현해보면 아래와 같다.

public interface ProductJpaRepository extends JpaRepository<Product, Long> {

    @Query(value = "select id, name from product where name = :productName", nativeQuery = true)
    Page<Product> findAllByNameWithPagination(String productName, Pageable pageable);
    
}

Native Query를 사용하면서 동시에 페이징 처리를 하고 있다. 굳이 네이티브 쿼리가 필요하냐 싶겠지만 예컨대 JPA의 영속성 컨텍스트는 이용하되 조회 성능을 위해 Native hint(Index 사용 유도)를 쓰고 싶을 수 있다. 실제로 그런 상황에서 발생한 장애였다. 아무튼 위 코드를 실행해보면 먼저 페이징 처리를 위해 JPA가 count 쿼리를 만들 것이다. 테스트를 짜서 돌려보자.

    @Test
    public void test_findAllByNameWithPagination(){
        Page<Product> products = productJpaRepository.findAllByNameWithPagination(PRODUCT_NAME, PageRequest.of(0, 1));
        assertThat(products.getContent().size()).isGreaterThan(0);
    }

실행하면 에러가 발생한다.


근데 쿼리를 보니까 count안에 where가 들어가 있는 괴랄한 쿼리다. 실제 장애 상황에서도 대충 아래처럼 힌트가 이상하게 적용된 count 쿼리가 생성되었었다. 때문에 힌트 적용이 제대로 되지 않아 slow query가 발생한 것이다. 더구나 잘못 적용된 힌트가 일반적인 주석으로 인식되어 구문 오류도 발생하지 않았기 때문에 테스트에서 잡히지 않고 그대로 상용 배포되는 불상사가 일어났다.

select count(/*+ index(product IDX01)*/ *) --자세히보면 이상하다
  from product

아무튼 JPA에서 count 쿼리를 이상하게 만들어주고 있는 것 같아서 소스를 한번 까보기로 했다. JPA 소스에서 쿼리를 만들어주는 것 같은 소스를 찾아보니 repository.quer 패키지 하위에 JpaQueryFactory라는 클래스가 있었다. 소스를 찾아가는 방법은 보통 이렇게 패키지, 클래스명으로 짐작해서 때려맞추거나 해당 Annotion를 AOP로 처리하는 지점을 찾는다.


NativeQuery인지 여부에 따라서 각각 다른 방식으로 쿼리를 만들어낸다. 그리고 countQueryString이라는 파라미터를 넘겨주는데, 이것은 annotation에서 property로 세팅할 수 있다. 위에서는 세팅해주지 않았으니 null 넘겨질 것이다. 아래로 계속 들어가보면 count 쿼리를 생성해주는 코드가 있을 것 같아서 좀 더 타고 내려가보니 StringQuery의 deriveCountQuery라는 메서드를 호출하고 있었다.


내용을 보니 미리 세팅해준 countQuery가 있으면 그것을 리턴하고 아니면 직접 만들어준다! 맞게 찾아온 것 같다. 여기서 확인할 수 있는 해결방법 하나는 countQuery를 세팅해주는 것이다. 이따가 테스트해보기로 하고 우선 쿼리 생성 방식을 알기 위해 createCountQuery에 진입해보았다.


@Deprecated가 달려있지만 어쨌든 아직 의존성이 있다. 대충 로직을 보니까 Matcher라는 객체에서 countQuery 템플릿을 기반으로 originalQuery를 조합해서 count 쿼리를 만들어내는 모양인데 디버깅해보니까 확실히 이 부분이 이상한 쿼리를 만들어내는 문제의 구간이었다. 여기서 또 알 수 있는 두 가지 사실이 있었는데 우선 count 대상이 되는 컬럼을 countProjection에 세팅해서 넘겨받으면 문제없이 countQuery가 만들어진다.

또, variable이라는 변수에는 결국 원본 쿼리에서 조회 대상 컬럼을 추출해서 넣는데, 여기서 컬럼이 하나면 정상적인 countQuery가 만들어진다. 근데 조회 대상 컬럼이 둘 이상이면 또 이상한 쿼리가 생성되는 것이다. 예컨대 원본 쿼리가 "select id from product"이라면 문제가 없지만 "select id, name from product"면 count 쿼리가 "count(where) from product"와 같이 괴랄하게 생성이 된다.

로직에서 count 쿼리를 생성해내는 방식을 간단히 표현해보자면


위 처럼 Macher 객체에서 정규표현식을 통해 쿼리로 부터 적절한 문자열을 뽑아내고, 해당 문자열을 대상 컬럼으로 count 쿼리를 조합해낸다. 문제는 조회 대상 컬럼이 여러개일 때 왜 이상한 문자열을 가지고 오냐는 것인데 Matcher 내부의 로직을 뜯어봐야 알겠지만 대충 봐서는 원작자의 의도를 알기 어렵다. 아무튼 이 로직에 대해 좀 알고 나면 직접 수정해서 Pull Request를 해볼 수도 있지 않을까 싶다.

아무튼 중요한건 소스를 파보면서 count 쿼리 생성 이슈를 방지할 3가지 해법을 알아냈다는 것이다.

1. countQuery를 직접 세팅한다.

    @Query(value = "select id,name from product a where name = :productName", nativeQuery = true
            ,countQuery = "select count(*) from product where name = :productName ")
    Page<Product> findAllByNameWithPagination(String productName, Pageable pageable);

2. projection을 세팅한다(가장 나은 해결책으로 보인다).

   @Query(value = "select id,name from product a where name = :productName", nativeQuery = true
            ,countProjection = "id")
    Page<Product> findAllByNameWithPagination(String productName, Pageable pageable);

3. 원본 쿼리에 조회 대상 컬럼을 하나만 세팅한다.

    @Query(value = "select id from product a where name = :productName", nativeQuery = true)
    Page<Product> findAllByNameWithPagination(String productName, Pageable pageable);


뭐가 됐든 셋 중 하나의 방법만 택하면 된다.

SpringDataJpa의 github 공식 레파지토리에 들어가보면 같은 이슈가 올라와있고 (https://github.com/spring-projects/spring-data-jpa/issues/2140) 아직 처리되지 않은 것 같다. 이 정도면 제법 큰 이슈가 아닌가.. 아무튼 유우명한 오픈 소스라고 해서 덮어놓고 쓸 수는 없다는걸 체감했던 경험이었다.

반응형