개요
프로젝트에서 조회 시 성능 향상을 위해 QueryDSL을 사용해 pagenation 구현했다.
심화 과정의 특강을 통해 QueryDSL을 더 효율적으로 사용할 수 있는 방법에 대해 배웠다.
강의 중 배운 내용을 정리하고 프로젝트에 적용해 보는 것이다.
QueryDSL
Java 애플리케이션에서 타입 안전하고 간결한 SQL 쿼리 작성을 지원하는 오픈소스 플레임워크.
사용 이유
- Method Query로 작성할 수 없는 복잡한 쿼리를 작성할 수 있음
- JPQL로는 작성할 수 없는 동적 쿼리를 작성할 수 있음
사용 시 주의사항
- 비효율을 방지하기 위해 LAZY 옵션을 사용하는데 N + 1 문제가 발생할 수 있음
해결 방법
- 트랜잭션 readOnly = true 옵션과 yml에서 open-in-view = false, default_batch_fetch_size 사용으로 위 문제를 해결할 수 있다.
- 또는 fetchJoin을 사용하는 방법이 있다.
QueryDSL 동적 쿼리
1. Predicate
- 역할: 조건을 나타내는 인터페이스. where() 절에 사용.
- 주요 사용처: JPAQuery의 where() 메서드에 전달해 조건 필터링을 수행.
2. BooleanBuilder
- 역할: 조건을 동적으로 조합하는 빌더 클래스. 조건을 누적해서 구성할 수 있다.
- 특징: 초기 값이 없을 수 있으며, 조건을 점진적으로 추가할 때 유용하다.
3. BooleanExpression
- 역할: Predicate의 하위 인터페이스로 불리언 논리를 수행하는 조건 표현식.
- 특징: 조건을 메서드 체인으로 조합할 수 있어 가독성이 좋음.
QueryDSL 대용량 처리 최적화
동적 쿼리 구현 시 BooleanBuilder 대신 BooleanExpression
- 동적 쿼리를 작성할 때 흔히 BooleanBuilder를 쓰지만, 이 경우 조건이 많아질수록 쿼리 가독성이 떨어짐
- BooleanExpression을 사용하면 특정 조건이 null일 경우 조건절이 자동으로 제거되므로, 보다 명시적이고 깔끔한 동적 쿼리 구성이 가능
exists() vs count()
- count() 쿼리는 조건에 맞는 모든 로우를 세어야 하므로 비용이 크다.
- exists() 쿼리는 첫 번째 매칭되는 로우를 찾으면 바로 종료하기 때문에 더 빠르다.
JPA와 QueryDSL에서 제공하는 fetchOne()이나 특정 메서드들은 내부적으로 count 쿼리를 사용한다.
이를 개선하려면 직접 limit 1을 사용한 쿼리를 작성해 "데이터 존재 여부"만 빠르게 확인할 수 있다(대안: fetchFirst() != null).
묵시적 조인(implicit join)의 함정
public List<Member> crossJoin() {
return queryFactory
.selectFrom(member)
.where(member.team.id.gt(member.team.leader.id))
.fetch();
}
- 묵시적 조인을 사용하면 쉽게 크로스 조인이 발생할 수 있고, 이로 인해 성능 문제가 생김.
- 명시적 조인(join() 메서드 사용)으로 전환하면 불필요한 크로스 조인을 피하고 성능을 개선할 수 있음.
엔티티 대신 DTO 조회하기
엔티티를 직접 조회하면:
- 필요 없는 컬럼까지 전부 조회
- 1:1 관계 시 N+1 문제 발생
- 2차 캐시 등 불필요한 기능 작동으로 오버헤드 증가
단순 조회나 대용량 조회에서는 DTO를 통해 필요한 컬럼만 가져오는 것이 성능에 유리
또한, SELECT 절에 엔티티를 바로 넣지 말고, 필요한 ID 값만 조회한 뒤 DTO로 매핑하는 방식으로 N+1 문제를 회피할 수 있음.
Group By, 정렬 최적화
- Group By 시 정렬(sort) 비용이 발생할 수 있음.
- MySQL 5.6 기반 환경에서는 order by null과 같은 문법으로 파이소트(filesort) 제거가 가능하지만, QueryDSL에서 직접적으로 지원하지 않는다.
- 우회 방법으로 “OrderByNull”과 같은 유틸 클래스를 만들어 직접 적용할 수 있다.
- 대용량 정렬은 DB보다 WAS(애플리케이션 레벨)에서 처리하는 것이 자원 효율적일 수 있다.
- 페이징 없는 단순 그룹 통계 쿼리라면 DB 정렬 부담을 최소화하고 WAS에서 후처리하는 방식 고려.
커버링 인덱스(Covering Index) 활용
페이징 시 커버링 인덱스를 사용하면 성능을 대폭 개선할 수 있다.
다만, QueryDSL JPA는 서브쿼리나 특정 쿼리 구조를 제한하기 때문에
- 인덱스 컬럼으로 PK만 빠르게 조회한 뒤
- 그 PK 리스트를 바탕으로 다시 필요한 컬럼들을 가져오는두 단계 쿼리 방식을 통해 커버링 인덱스 효과를 낼 수 있다.
두 번 쿼리를 실행하지만 전체 성능은 거의 커버링 인덱스 사용 시와 비슷한 성능 향상을 기대할 수 있다.
대량 업데이트(1 벌크 업데이트) vs 더티 체킹(Dirty Checking)
- 더티 체킹: 엔티티를 전부 로딩해 수정 시 자동 반영. 1만건, 10만건 업데이트 시 성능 저하 심각.
- QueryDSL을 통한 1회성 벌크 업데이트: 한 번의 쿼리로 대량 업데이트 가능, 성능 우수.
단, 벌크 업데이트는 2차 캐시나 엔티티 상태 관리 부분을 직접 갱신하지 않으므로 캐시 정리 등의 추가 작업이 필요
- 실시간 변동이 많은 소규모 업데이트 → 더티 체킹
- 대규모 배치 업데이트 → 벌크 업데이트
벌크 Insert 최적화와 EntityQL
JPA는 기본적으로 JDBC의 “insert batch 옵션”을 제대로 활용하지 못함.
수만 건 인서트를 JPA로 처리하면 성능 문제가 심각해짐.
- JDBC 템플릿: 문자열로 직접 쿼리를 작성 → 타입 세이프티(Type-safety) 부족
- EntityQL: 엔티티 기반 메타 정보로 QueryDSL Native SQL 생성 → 타입 세이프 + 벌크 Insert 가능
EntityQL을 사용하면 JPA 엔티티를 이용해 QueryDSL Native SQL QClass를 생성, 마치 QueryDSL로 JPQL 쓰듯 네이티브 Insert Batch를 타입 안전하게 작성할 수 있다.
다만, EntityQL은 설정 복잡, Gradle 플러그인 의존, 임베디드 타입 미지원 등의 단점이 있어 아직 널리 쓰기엔 제약이 있다.
JDBC 템플릿 vs EntityQL 중 어떤 불편함이 더 낫느냐를 판단해 상황에 따라 선택
QueryProjection
QueryProjection은 QueryDSL에서 DTO(데이터 전송 객체)를 생성할 때 사용되는 어노테이션으로 DTO에 직접 매핑할 생성자를 만들어 주며, 타입 안전한 쿼리를 지원한다.
사용 이유
- 타입 안전성 보장: DTO의 필드를 정확하게 매핑해 런타임 오류를 줄임
- 코드 가독성 향상: 쿼리 결과를 DTO로 직접 매핑하여 변환 로직 제거
주의사항
- DTO 생성자에 @QueryProjection을 반드시 붙여야 한다.
- DTO 클래스는 QueryDSL 프로세스가 빌드 시 Q-클래스를 생성하도록 설정해야 한다.
- querydsl-apt 의존성을 추가하고 annotationProcessor 설정이 필요하다.
PagedModel
스프링 Web에서 권장하는 Page용 DTO 객체
직렬화, 역직렬화 시 발생하는 문제를 최소화
Page 객체 사용 시 꼭 확인해야할 세팅
@Transactional(readOnly = true) 또는 @Transactional 어노테이션 필수!
JPA와 QueryDsl 조합 Search 구현
JPA에서 일반적으로 사용하는 Method Query에 QueryDsl을 연동하여 Search를 간편하게 구현할 수 있다.
1. QuerydslPredicateExecutor
QuerydslPredicateExecutor는 Predicate를 JpaRepository에서 추출할 수 있도록 해준다.
Controller에서 Predicate와 Pageable을 매개변수로 받는다.
Service에서 Repository를 호출 할 때 Predicate와 Pageable을 전달한다.
2. BooleanBuilder
BooleanBuilder를 이용하여 검색조건 추가하거나 제한할 수 있다.
Service에서 BooleanBuilder를 선언하고 Predicate를 매개변수로 포함한다.
idList가 null이 아니라면 리스트를 In절로 검색하도록 추가한다.
stock (재고량)이 0개보다 큰 것만 조회하도록 제한한다.
3. QuerydslBinderCustomizer
QuerydslBinderCustomizer는 추출된 Predicate의 조건을 수정할 수 있다.
1, 2는 문자 검색 시 equals 조건만 검색이 되었다.
문자 검색 시 contains 조건으로 변경.
정리
- QueryDSL의 다양한 메서드와 기능을 사용해서 Search를 쉽게 구현할 수 있는 방법과 대용량 처리 및 동적 쿼리 방법에 대해 배웠다.
- 진행 중인 프로젝트에 위 예시들을 보면서 수정 적용하는 과정이 익숙하지 않아 어렵게 느껴졌다, 익숙해질 수 있도록 연습이 필요할 것 같다.