JPA

QueryDSL 적용기

나도관 2023. 5. 20. 13:58
 

GitHub - Bipum-In/Bipum-In-BE: 비품인 백엔드 레포입니다.

비품인 백엔드 레포입니다. Contribute to Bipum-In/Bipum-In-BE development by creating an account on GitHub.

github.com

 

이번에 진행했던 프로젝트에서 대시보드 기능 구현을 위해 쿼리를 작성할 일이 많았다. 기존 JPA를 사용하기에는 쿼리메소드의 이름이 길어져서 직관적이지 않다는 문제가 있었고, N+1 문제가 발생하여 성능이 떨어지는 이슈가 발생했다.

 

JPQL 과 Native Query를 사용해서 복잡한 이름의 쿼리 메소드들을 직접 변환하여 문제를 해결하였지만, 그 과정에서 쿼리 문법 오류에 의한 Runtime Error가 자주 발생하였다. JPQL에서는 컴파일 시, 문법 오류를 체크해줄 수 없었기 때문에 사전에 런타임 에러를 방지할 수 있는 QueryDSL 을 적용해보기로 했다.

 

 

QueryDSL 이란?

먼저, QueryDSL이 무엇인지 정확히 알고가야할 것 같다.

QueryDSL은 SQL, JPQL 등을 코드로 작성할 수 있도록 지원해주는 빌더 오픈소스 프레임워크로서, JPA 뿐만 아니라 Mongodb, SQL 등 다양한 언어에 대한 서비스를 제공한다고 한다.

 

 

QueryDSL JPA

JPA에 대한 QueryDSL인 QueryDSL JPA는 Entity 클래스와 매핑되는 QClass 라는 객체를 사용하여 쿼리를 실행한다.

 

QClass는 컴파일 단계에서 엔티티를 기반으로 생성되는 객체로서, JPAAnnotationProcessor 가 컴파일 시점에 작동하여 @Entity 등의 어노테이션의 찾아 해당 파일을 분석하여 QClass를 생성하게 된다. 즉, QClass는 Entity 클래스를 QueryDSL 사용을 위해 변환한 클래스라고 보면 될 것 같다.

 

 

QueryDSL 사용을 위해서, 여러가지 설정 추가가 필요하다.

 

build.gradle

plugins {
	id 'java'
	id 'org.springframework.boot' version '2.7.9'
	id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

apply plugin: "io.spring.dependency-management"

group = 'com.sparta'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	//querydsl 추가
	implementation("com.querydsl:querydsl-core") // querydsl
	implementation("com.querydsl:querydsl-jpa") // querydsl
	annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa" // querydsl JPAAnnotationProcessor 사용 지정
	annotationProcessor("jakarta.persistence:jakarta.persistence-api")
	annotationProcessor("jakarta.annotation:jakarta.annotation-api")
}

// querydsl에서 사용할 경로 설정
def querydslDir = "src/main/generated"

sourceSets {
	main.java.srcDirs += [ querydslDir ]
}

tasks.named("compileJava") {
	options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
}

clean.doLast {
	file(querydslDir).deleteDir()
}

jar {
	enabled = false
}

 

대다수의 사람들이 사용하는 QueryDSL 세팅방법으로 적용하니, Runtime 에러가 지속적으로 발생해서 모 개발 블로그에서 다른 방법으로 세팅을 진행하는 코드를 참고했다. 블로그를 다시 찾기가 어려워 링크는 참고하지 못하는 점 양해 부탁드린다.

 

만약, 실행 시 QClass 를 찾지 못한다는 Runtime 오류가 발생한다면 아래의 블로그를 참고하되, InteliJ 기준 Project Structure - Modules 메뉴에 빌드 후 생성될 generated 폴더가 소스 폴더로 지정되어 있는지 확인 바란다.

만약 되어있지 않다면, generated 폴더를 선택하고 폴더 모양의 Sources를 누르면 소스 폴더로 지정된다.

 

위 설정 때문에 시간을 엄청나게 잡아먹었던 기억이 난다.. 도움이 되길 바란다.

 

 

Intellij에서 QueryDSL 오류 (cannot find symbol Q class)

Spring Boot 프로젝트에서 Spring Data JPA를 사용할때 한 번쯤 볼수있는 오류입니다. 참고로 JPA에서 쿼리를 작성하는 방법은 쿼리메소드, @Query…

devfoxstar.github.io

 

 

Config.java

//해당 파일에서 등록한 jpaFactory 빈을 Repository에서 사용한다.
@Configuration
public class QueryDslConfig {
    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

프로젝트 전역에서 QueryDSL을 작성할 수 있도록, JPAQueryFactory를 Bean으로 등록해준다.

 

 

여러 엔티티에서 쿼리 dsl 작업을 할 것이기 때문에 한 개의 폴더에서 쿼리 DSL관련 파일을 확인할 수 있도록 폴더구조를 구성했다.

 

기존 Repository 구조를 유지하면서 쿼리 DSL 관련 코드를 추가하길 원했기 때문에, 관련 인터페이스를 추가하고, 인터페이스에 대한 구현체에서 추상메서드를 구현해주었다.

 

인터페이스

public interface NotificationRepositoryCustom {
    Page<NotificationResponseForAdmin> findUserNotification(Long adminId, Pageable pageable);
    Page<NotificationResponseForUser> findAdminNotification(Long userId, Pageable pageable);
    List<Notification> findOldNotification();
}

 

구현체

@Repository
@RequiredArgsConstructor
public class NotificationRepositoryImpl implements NotificationRepositoryCustom{
    private final JPAQueryFactory jpaQueryFactory;

    // notification의 request_id 와 같은 요청과 그 요청에 해당하는 관리자의 정보를 가져온다 (승인/폐기 등 처리 건만)
    // 요청의 수신자가 userId에 해당하는 알림 건만 가져온다.
    public Page<NotificationResponseForAdmin> findUserNotification(Long adminId, Pageable pageable) {
        QNotification notification = QNotification.notification;
        QRequests request = QRequests.requests;
        QUser user = QUser.user;

        JPAQuery<NotificationResponseForAdmin> query = jpaQueryFactory.select(Projections.constructor(
                        NotificationResponseForAdmin.class,
                        notification.content, user.image, notification.createdAt.as("createdAt"),
                        request.requestId.as("requestId"), notification.id.as("notificationId"), request.requestType
                        )
                )
                .from(notification)
                .join(request).on(notification.request.requestId.eq(request.requestId))
                .join(user).on(request.user.id.eq(user.id))
                .where(notification.receiver.id.eq(adminId)
                        .and(notification.notificationType.eq(NotificationType.REQUEST))
                        .and(notification.isRead.eq(false)))
                .orderBy(notification.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize());

        List<NotificationResponseForAdmin> notificationList = query.fetch();
        long totalCount = query.fetchCount();

        return new PageImpl<>(notificationList, pageable, totalCount);
    }

    // notification의 requests_id 와 같은 요청과 그 요청에 해당하는 유저의 정보를 가져온다 (요청 건만)
    // 요청의 수신자가 adminId에 해당하는 알림 건만 가져온다.
    public Page<NotificationResponseForUser> findAdminNotification(Long userId, Pageable pageable) {
        QNotification notification = QNotification.notification;
        QRequests requests = QRequests.requests;
        QUser user = QUser.user;

        JPAQuery<NotificationResponseForUser> query = jpaQueryFactory.select(Projections.constructor(
                                NotificationResponseForUser.class,
                                notification.content, notification.createdAt.as("createdAt"), notification.acceptResult,
                                requests.requestId.as("requestId"), notification.id.as("notificationId"), requests.requestType
                        )
                )
                .from(notification)
                .join(requests).on(notification.request.requestId.eq(requests.requestId))
                .join(user).on(requests.admin.id.eq(user.id))
                .where(notification.receiver.id.eq(userId)
                        .and(notification.notificationType.eq(NotificationType.PROCESSED))
                        .and(notification.isRead.eq(false)))
                .orderBy(notification.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize());

        List<NotificationResponseForUser> notificationList = query.fetch();
        long totalCount = query.fetchCount();

        return new PageImpl<>(notificationList, pageable, totalCount);
    }

    public List<Notification> findOldNotification() {
        QNotification notification = QNotification.notification;
        BooleanExpression isRead = notification.isRead.eq(true);
        BooleanExpression createdAt = notification.createdAt.before(LocalDateTime.now().minusDays(1));
        BooleanExpression now = Expressions.asBoolean(true);

        return jpaQueryFactory.selectFrom(notification)
                .where(isRead, createdAt, now)
                .fetch();
    }
}

 

기존 Repository

public interface NotificationRepository extends JpaRepository<Notification, Long>, NotificationRepositoryCustom {

    long countByReceiver_IdAndNotificationTypeAndIncludeCountTrue(Long id, NotificationType notificationType);

    List<Notification> findByReceiver_IdAndNotificationTypeAndIncludeCountTrue(Long id, NotificationType notificationType);

    List<Notification> findByRequest_RequestId(Long requestId);

    List<Notification> findByReceiverAndNotificationType(User user, NotificationType notificationType);
}

기존 Repository에서 해당 인터페이스를 상속받아 사용할 수 있도록 구성했다.

 

 

참고 자료

 

Spring Boot에 QueryDSL을 사용해보자

1. QueryDSL PostRepository.java Spring Data JPA가 기본적으로 제공해주는 CRUD 메서드 및 쿼리 메서드 기능을 사용하더라도, 원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL…

tecoble.techcourse.co.kr