sol 개발 블로그 로고
Published on

QueryDSL Illegal pop() 해결 방법 with Stream

Authors
  • avatar
    Name
    Chan Sol OH
    Twitter

목차

문제 상황 정리

SOC 포털 개발 프로젝트 도중 대시보드 화면을 개발하면서 4가지의 api를 한번에 요청하도록 했다. 하지만, 백엔드 개발자들이 swagger로 테스트해서 정상적으로 동작하는 api들이 프론트에서 요청했을 때 아래 그림처럼 에러가 발생했다.

다중 api를 동시에 요청할 경우 발생하는 문제 상황

문제가 발생하는 api따로따로 요청했을 때 정상적으로 응답이 왔지만, 위 그림처럼 한번에 요청할 경우 api 정상적인 응답을 보내지 않았다. 그래서 백엔드의 로그를 확인한 결과 아래와 같은 예외가 발생했다.

문제가되는코드.java
// searchDetected 뿐만 아니라 여러 QueryDSL 메서드에서 예외가 발생했다.
    @Override
    @Transactional
    public List<QTicketDto> searchDetected(
            List<Long> userCompanyIds,
            String companyCode,
            LocalDateTime dStartDate,
            LocalDateTime dEndDate
    ){
        log.info(
                "Searching tickets with parameters - userCompanyIds: {}, companyCode: {}, dStartDate: {}, dEndDate: {}",
                userCompanyIds, companyCode, dStartDate, dEndDate);

        if (userCompanyIds == null || userCompanyIds.isEmpty()) {
            log.warn("UserCompanyIds are empty, returning empty page.");
            return Collections.emptyList();
        }

        BooleanBuilder cond = new BooleanBuilder();
        cond.and(userCompanyIdsContain(userCompanyIds))
                .and(companyCodeEq(companyCode))
                .and(isDetectedWithinDateRange(dStartDate, dEndDate));
        List<QTicketDto> content = createTicketDtoQuery()
                .where(cond)
                .orderBy(ticket.created.desc())
                .fetch();

        log.info("Found {} tickets matching the search criteria.", content.size());
        return content;
    }
org.springframework.dao.InvalidDataAccessApiUsageException:
Illegal pop() with non-matching JdbcValuesSourceProcessingState

디버깅을 돌렸을 때 예외는 **fetch()**를 지날 때 문제가 발생했었다. api를 따로따로 요청했을 때는 정상적으로 쿼리되지만, 한번에 요청하면 fetch에서 예외가 발생하는 것은 처음 보는 예외였다.

문제 원인

Stackoverflow 관련 질문과 SOC 팀장님이 찝어준 부분을 정리하면 아래와 같다.

  1. Session에서 여러 transaction이 동시에 쿼리를 날리기 때문
  2. db 드라이버 설정 이슈 (1) QueryTimeout
  3. db 드라이버 설정 이슈 (2) max_statement_time

해결 방법

Thread sleep을 이용한 임시 방편

원인 1번에 따라 동시에 쿼리하는 것을 나눠서 쿼리하도록 수정했었다.

sleepAdd.java
    @Override
    @Transactional
    public List<QTicketDto> searchDetected(
            List<Long> userCompanyIds,
            String companyCode,
            LocalDateTime dStartDate,
            LocalDateTime dEndDate
    ){
        //... 생략 ...

        BooleanBuilder cond = new BooleanBuilder();
        cond.and(userCompanyIdsContain(userCompanyIds))
                .and(companyCodeEq(companyCode))
                .and(isDetectedWithinDateRange(dStartDate, dEndDate));

        Thread.sleep(500); // fetch 전에 쓰레드를 잠시 멈춰버린다.

        List<QTicketDto> content = createTicketDtoQuery()
                .where(cond)
                .orderBy(ticket.created.desc())
                .fetch();

        //... 생략 ...
    }

위 코드처럼 fetch 전에 **Thread.sleep()**을 통해 각 thread의 fetch 타이밍을 다르게 해서 동시에 요청하지 않도록 했다. 해당 방법으로 로컬에서 테스트할 때는 잘 돌아가서 기뻐했지만, 근본적인 해결책이 아니라서 다른 방법을 찾았다.

hibernate querytimeout 설정

팀장님의 의심(?)되는 원인으로 QueryTimeout 일 수 있다고 해서 관련 내용을 찾아봤다.

Hibernatesql query의 시간 제한을 설정을 설정할 수 있다. 시간 제한을 넘겨도 쿼리의 응답이 오지 않는다면 예외가 발생하는 것이다. 아래와 같이 설정을 할 수 있다.

spring.transaction.default-timeout=20s
//spring.jpa.properties.javax.persistence.query.timeout=5000 이 설정은 deprecated

위와 같이 설정을 application.properties에 추가하고 실행했더니 똑같은 에러가 발생해서 QueryTimeout은 원인이 아니라고 판단했다. 당연할 수 있는게 테스트에 사용되는 데이터 수도 적고, 문제가되는코드가 그리 복잡하지 않기 때문에 시간이 많이 걸릴 일이 없다.

max_statement_time

mariadbmax_statement_time 설정은 위 querytimeout과 비슷한 맥락으로 mariadb가 쿼리를 실행시킬 수 있는 최대 시간이다.

GRANT USAGE ON *.* TO dbuser@'%' WITH MAX_STATEMENT_TIME 60

위 설정으로 mariadb의 쿼리 시간 제한을 늘렸지만, 동일한 예외가 여전히 발생했다. 결과적으로 hibernate querytimeout 설정과 동일한 분석이였고, 동일하게 문제의 원인이 아니었다.

뜻 밖의 해결? db stream 쿼리

여러 원인을 고민하고 적용해도 해결하지 못하자 낙담하던 도중 팀장님께서 요상한 방법으로 해결해주셨다. 바로 QueryDSL에서 fetch() 대신 stream()을 시용하는 것이었다.

QueryDSL은 fetch()로 Statement를 db에 요청하고 결과를 list로 반환한다고 알고 있다. 그러나 문제 상황 정리에서는 이 fetch()에서 예외가 발생하는 것 아닌가... fetch() 대신 stream()을 아래처럼 적용했더니 예외가 발생하지 않았다.

streamList.java
    @Override
    @Transactional
    public List<QTicketDto> searchDetected(
            List<Long> userCompanyIds,
            String companyCode,
            LocalDateTime dStartDate,
            LocalDateTime dEndDate
    ){
        //... 생략 ...

        BooleanBuilder cond = new BooleanBuilder();
        cond.and(userCompanyIdsContain(userCompanyIds))
                .and(companyCodeEq(companyCode))
                .and(isDetectedWithinDateRange(dStartDate, dEndDate));

        Thread.sleep(500); // fetch 전에 쓰레드를 잠시 멈춰버린다.

        List<QTicketDto> content = createTicketDtoQuery()
                .where(cond)
                .orderBy(ticket.created.desc())
                .stream().toList()
                // .fetch();

        //... 생략 ...
    }

그럼 어떻게 stream()은 문제를 해결할 수 있었을까?

QueryDSL, fetch와 stream 차이

java8의 stream

Java에서 stream은 다른 자료구조와 달리 데이터를 저장하고 있지 않다. 따라서 point할 수 없고, 해당 데이터 위에 동작하는 함수만 수행할 수 있다. stream은 데이터가 흐르는 파이프라인과 데이터에 대해 작동하는 함수(filter, map, reduce, collect)를 나타낸다. java stream에 대해선 [Java stream으로 DB 쿼리 수 줄이기]를 참고하면 사용법을 알 수 있다.

QueryDSL에서 Stream 자료형 출력

    @Override
    @Transactional
    public List<QTicketDto> searchDetected(
            String companyCode,
            LocalDateTime dStartDate,
            LocalDateTime dEndDate
    ){
        BooleanBuilder cond = new BooleanBuilder();
        cond.and(userCompanyIdsContain(userCompanyIds))
                .and(companyCodeEq(companyCode))
                .and(isDetectedWithinDateRange(dStartDate, dEndDate));
        List<QTicketDto> content = createTicketDtoQuery()
                .where(cond)
                .orderBy(ticket.created.desc())
                        .stream()
                        .collect(Collectors.toList());
//                .fetch(); // fetch를 사용하면 Illegal.pop() 문제가 발생하고 stream을 쓰면 발생하지 않는다.
        return content;
    }

해결 방법의 근거

fetch는 기본적으로 DB 쿼리 결과값을 List로 반환하고 stream은 DB 쿼리 결과를 Java Stream 자료형으로 반환한다.

Java Stream은 원본 데이터를 수정하지 않는 이상 기본적으로 한 데이터 소스로 다른 데이터 소스를 생성하기 때문에 Thread-safe하다고 알려져 있다. 하지만, List 같은 경우 여러 쓰레드가 한번에 수정할 수 있기 때문에 Thread-safe하지 않는다.

When executed in parallel, multiple intermediate results may be instantiated, populated, and merged so as to maintain isolation of mutable data structures. Therefore, even when executed in parallel with non-thread-safe data structures (such as ArrayList), no additional synchronization is needed for a parallel reduction. -Stream 인터페이스 주석-

또한 위 인용을 참고하면 Stream 인터페이스의 collect를 사용하면 병렬처리 상황에서 각 멀티 쓰레드의 결과값을 병합하고, 가변 데이터 구조(ArrayList)의 격리를 유지한다. 따라서 병렬 리덕션 상황에서는 추가적인 동기화가 필요없게 된다.

Illegal.pop()Hibernate가 스택을 옳바르게 처리하지 못해서 발생하는 것이고, 더 깊이는 스택이 격리되지 않고 여러 쓰레드가 pop하려고 했기 때문이다. Stream 인터페이스의 Collection을 사용하여 데이터 소스의 격리를 유지할 수 있다.