TIL - QueryDSL 설정부터 구현까지 (25.05.09)

kb·2025년 5월 9일

Spring

목록 보기
19/21

QueryDSL (백지 정리)

QueryDSL 이란?

  • 이전까지 JpaRepository를 상속받은 Repository Interface에서 JPA로 쿼리를 활용하거나, @Query Annotation 방식의 JPQL을 활용해 쿼리를 작성하는 방법을 사용했다.
  • QueryDSL은 Qentity를 생성하고 Qentity를 기반으로 Query를 직접 구현하는 동적 쿼리 방식이다.

Dr.G 피드백

QueryDSL 장/단점

  • 쿼리가 복잡해지거나, 동적 쿼리 구성에 효과적이다. join이나 where, group by Having 등 조건이 많이 붙을 때 JPA나 JPQL을 활용하면 쿼리가 복잡해지고 로직 구현이 까다로울 수 있다. 동적 쿼리 구성에 효과적이란 말은, 변화하는 조건에 쉽게 반응형으로 쿼리를 조정할 수 있다고 이해했음.
  • 처음 세팅이 조금 어려울 수 있다. build.gradle, QEntity 등.. 설정이 잘 안 되어 해메느라 2-3시간 가량 소모했다. 문법도 처음 사용하면 조금은 배워야 한다. SQL과 완전히 동일하지 않아, QueryDSL만의 문법을 배울 필요가 있다. ... (근데 이건 JPA나 JPQL도 마찬가지이긴 함)

Dr.G피드백

QueryDSL 환경 설정

  • 1) build.gradle 설정 / 2) RepositoryCustom interface / 3) RepositoryCustomImpl 클래스 / 4) Repository interface 상속 추가. 네 가지 과정이 필요함.
  • build.gradle에 QueryDSL 사용을 위한 dependency 설정, 디렉토리 설정, QEntity(Q클래스) 생성을 위한 설정이 필요하다! ... 여기서 진짜 애 많이 먹었다... 환경변수 설정이 안 되고, JDK 버전이 다르고.. 이런 문제 때문에...
  • build.gradle의 QueryDSL 설정은 아래와 같다.
  • Q클래스 생성을 위해 디렉토리 설정. 이 설정 이후에도 환경변수의 Path 경로와 JDK 버전 등을 맞추는 작업이 필요했음. 그래서 더 복잡하게 느껴진 듯. 그 과정만 스무스했다면 어렵진 않았을 듯.
  • Custom interface를 생성하고, 기존의 repository가 JPA뿐만 아니라, custom interface를 함께 상속하도록 해야한다. Repository implements JPARepository<T,T>, CustomRepository 이런식으로 CustomRepository를 상속받도록 했다.
  • 그리고 Custom Repository interface에 QueryDSL 메서드명을 정의하고, CustomRepositoryImpl에 QueryDSL을 작성했다.
  • 기존 Repository Interface에서 상속한 방식은 아래와 같다.
  • CustomRepository Interface는 아래와 같다.
  • TodoRepositoryImpl 구현 클래스는 아래와 같다.

Dr.G 피드백

  • 아! Spring Data는 Impl 접미사를 가진 구현체를 자동 주입한다고!?
  • 와! 이런 과정이 있었구나! 그래서 Repository Interface만 있어도 자동으로 쿼리를 만들어 실행해줬는데, QueryDSL을 사용하려면, Custom Interface + Impl 구현체로 등록해줘야 Impl에 있는 QueryDSL 실행값을 찾아서 자동으로 실행해준다는 개념인 듯! (아래 참고)
  • 와! 이렇게 이해하니까 원리를 이해하는 느낌이 든다! 좋다!

QueryDSL 활용 1

  • QueryDSL을 작성하는 구체적인 방법을 알아보자.
  • 먼저 아래와 같이 RepositoryCustom Interface에 메서드명과 반환타입, 입력 변수들을 정의해줌
  • 이후, RepositoryCustomImpl 클래스에서 QueryDSL을 작성해줌
    1) JPAQueryFactory 필드 추가 및 생성자 주입

    2) RepositoryCustom Interface에서 상속받은 메서드 구현하기
  • 쿼리를 짜기 전, 필요한 테이블을 Q클래스로 생성한다. 기존에 있던 테이블(Entity)명 앞에 Q를 붙이면 된다.
  • Q클래스로 생성한 인스턴스들을 활용해 QueryDSL을 완성한다.
    1. 필드값인 queryFactory를 활용해 쿼리를 작성하는데, .selectFrom(todo)는 todo Q클래스에서 데이터를 가져온다는 말이다.
    2. .leftJoin(todo.user, user).fetchJoin()은 todo에 있는 user와 QUser로 생성한 user 테이블을 leftJoin하는데, fetchJoin 하겠다는 뜻이다.(N+1문제 방지/ user가 필요하지 않을 때는 user 테이블을 조회하지 않는다)
    3. .where(todo.id.eq(todoId))는 todo Q클래스의 id가 입력값인 todoId와 같은 데이터만 추출하겠다는 뜻이고
    4. fetchOne();은 쿼리를 마지막에 한 번만 날려 중복 쿼리를 없애겠다는 뜻이다.
    5. return Optional.ofNullable(result); 는 쿼리 결과가 null일 수 있으니, Optional로 결과를 받아오겠다는 뜻이다.

Dr.G 피드백

  • Q클래스는 Gradle 빌드할 때 QueryDSL annotation processor가 자동 생성하는구나! build.gradle에 추가한 의존성을 살펴보면 아래와 같이 annotationProcessor가 있다!
  • fetchJoin의 역할도 좀 모호했는데, 아래와 같이 피드백을 해주었다. 반대로 이해하고 있었다. fetchJoin의 역할은 user가 필요하지 않을 때 조회하지 않는 것이 아니라, fetchJoin을 써야 user를 함께 가져온다고 한다. fetchJoin을 사용하지 않으면 LAZY 전략에 의해 user를 조회할 때 추가 쿼리(N+1 문제)가 발생한다고!
  • fetchOne과 Optional.ofNullable(result) 또한 아래와 같이 정정해주었다. fetchOne은 조회 결과가 정확히 하나일 때 사용하는 것이고, 결과가 없으면 null, 2개 이상이면 NonUniqueResultException이 발생할 수 있다고 한다.
  • 또한 Optional.ofNullable()은 null 안정성을 보장한다는데, 안정성을 보장한다는 것은 null이 발생해도 Error를 던지지 않고 결과를 Optional.empty()로 반환한다. 즉, null 객체를 직접 다루지 않고, Optional로 안전하게 처리할 수 있도록 도와주는 개념이라고 한다.

QueryDSL 활용 2

  • 좀 더 복잡한 쿼리를 살펴보자.
  • 검색 기능을 가진 searchTodo 메서드다. 입력값으로 title, start, end, nickname, pageable을 받는다. 타이틀이나 닉네임, 기간 및 페이지 번호로 조회할 수 있는 검색 기능이다.
  • 먼저 Q클래스의 인스턴스를 생성한다. todo entity를 조회하므로, todo에 연결 되어 있는 다른 클래스들도 Q클래스로 인스턴스화 한다.
  • 조회 반환 타입은 List< TodoSummaryResponseDto >이다. 검색 시 todo 테이블의 모든 정보를 보여주는 것이 아닌, title, manager 수, 댓글 수를 보여주도록 todoSummaryResponseDto 클래스를 생성했고, QueryDSL에서는 Projections.constructor를 활용해, TodoSummaryResponseDto 클래스로 생성할 것이며, 위에 생성한 인스턴스들에서 title, manager.id.countDistinct() (manager.id의 고유값 개수), comment.id.countDistinct() (comment.id의 고유값 개수)를 각각 TodoSummaryResponseDto의 인자로 넣어주겠다는 의미인 것으로 보인다.
  • 다음은 조회하는 테이블과 조건을 담고 있는 구문이다. todo 테이블에서 가져오는데, todo테이블의 managers 값과(todo.managers) 위에서 Q클래스로 인스턴스화 한 manager 테이블을 leftJoin하고, todo 테이블의 comments 값과(todo.comments) 위에서 Q클래스로 인스턴스화 한 comment 테이블을 leftJoin 하고, todo 테이블의 user 값과 위에서 Q클래스로 인스턴스화 한 user 테이블을 leftJoin한다.
  • 그리고 조회 조건을 .where절로 넣어주는데, 삼항연산자로 구현했다.
    1) title이 null이 아니면 todo 테이블의 title(todo.title)이 입력값인 title을 포함하는 조건. 입력 title이 null(title 입력이 없으면)이면 title 조건도 null로 처리했다.
    2) nickname이 null이 아니면 user 테이블의 nickname이 입력값인 nickname을 포함하는 조건. nickname 입력이 null이면 이 조건도 null로 처리했다.
    3) start, end가 null이 아니면 생성일이 start,end 사이에 있는 값을 조회하고, 하나라도 null이면 이 조건은 없앴다. end는 없을 경우 현재 시간으로 넣어줬으니, 실제로는 start가 있어야 동작하는 로직이라고 할 수 있다.
  • 이제 집계/정렬 함수가 들어간다. manager.id.countDistinct()에서 count 집계 함수가 들어간다. 그래서 groupBy(todo.id)로 처리해줬다.
  • 정렬은 생성일 기준 내림차순. orderBy(todo.createdAt.desc())로 처리해줬다.
  • .offset은 페이지 번호. 입력으로 받은 pageable에서 Offset()값을 받아(pageable.getOff()) 설정해준다.
  • .limit은 한 페이지에 담을 데이터의 개수를 의미한다. pageable에서 PageSize()값을 받아(pageable.getPageSize()) 설정해준다.
  • 이후 전체 개수 쿼리도 함께 날려준다. 위의 쿼리는 해당하는 페이지/ 개수의 데이터만 추출하고, 아래의 쿼리는 해당하는 데이터 전체 개수를 반환하는 쿼리이다. todo테이블에서(from) todo.count()를 반환(select)하고, 조건 중 nickname을 조회하는 조건이 있으므로 todo.user와 QUser로 생성한 user를 leftJoin하고, 조회 조건은 위의 쿼리와 동일하게 한다.

Dr.G 피드백

  • Projections.constructor는 DTO 클래스에 정의된 생성자를 기준으로 쿼리 결과를 매핑한다고 한다. "이 방식은 생성자의 파라미터 순서와 타입이 정확히 일치해야 하므로 주의가 필요하지만, 가독성이 높고 불변 객체에도 사용하기 좋다"고
  • 동적 where 조건 처리에서, 삼항연산자를 BooleanBuilder로 사용하는 것을 권장했다.
  • Pageable의 Offset과 PageSize는 잘못 이해한 듯! offset()은 조회 시작 시점의 row index. pageNumber * pageSize로 계산
  • Pageable은 PageRequest.of(page-1, size) 이렇게 정의하는데, (page-1) * size가 pageable.getOffset()의 값이 됨. 이 row 부터 limit개의 데이터를 갖고 오겠다는 의미! size값이 limit이 됨.
  • count 쿼리를 분리한 이유는, 복잡한 집계 쿼리에서 fetch()와 count()를 동시에 처리하면 성능 저하와 SQL 오류 가능성이 있다고 한다.
  • 그래서 데이터 쿼리와 카운트 쿼리를 별도로 작성해 PageImpl로 묶음 처리한다고!

정리

  • QueryDSL은
    • Custom Repository Interface와 Impl 구현 클래스를 활용해 가상 엔티티(Q클래스) 기반의 쿼리를 작성하는 기술이다.
  • 장점은
    • 정적 메서드로 쿼리 컴파일 시 오류를 발견하기 쉽다.
    • 동적 쿼리를 구성하기에도 용이하다.
    • 복잡한 쿼리를 구성하는 데 있어 JPA나 JPQL과 비교해 직관적이고 효율적이다.
  • 설정은
    • build.gradle에서 QueryDSL을 사용하기 위한 의존성과, Q클래스 생성을 위한 generated 경로 설정이 필요하다. 이때 JDK 버전과 시스템 환경변수 설정이 필요하다. 한 번 설정하면 이후에는 설정하지 않아도 되므로 편하다! (나는 어려웠다.)
    • CustomRepository, CustomRepositoryImpl 을 구현하고, 기본 Repository가 JPARepository와 함께 CustomRepository Interface를 상속하도록 하면 Spring Data가 CustomRepositoryImpl에 구현한 쿼리를 자동으로 찾아 실행해준다.
    • 쿼리 작성 시 조회하고 싶은 테이블을 Q클래스 인스턴스로 설정해주고, QueryDSL의 문법을 따라 쿼리를 작성해주면 된다. SQL과 유사한 문법을 사용하므로 겁먹지 않아도 된다!
    • 페이지 조회 시에는 해당 페이지의 쿼리와, 전체 집계 쿼리를 분리해서 작성해준다. 그리고 PageImpl<>에 (results, pageable, total )를 넣어 반환해준다.

Dr.G 피드백

  • QueryDSL이란?
  • 장점은?
  • 설정은?
  • 구조는?
  • 쿼리 방법
  • 성능 및 오류 방지를 위해 데이터 조회 쿼리와 count 쿼리를 분리해 처리한다!
profile
Experience

0개의 댓글