[Spring] QueryDSL 완벽 이해하기

YoungHo-Cha·2022년 5월 11일
32

운동 매칭 시스템

목록 보기
14/17

오늘은 실제로 내가 구현하고 있는 토이 프로젝트에 QueryDsl을 적용하면서 공부를 해볼 것이다!


목차

  • 요구사항
  • QueryDsl
  • With Spring Boot

요구사항

먼저, 내가 구현하려는 서비스 기능을 살펴보자.

기능 요구사항

  1. 본 서비스는 사용자의 입맛대로 "필터링"을 할 수 있는 기능이 있다.
  2. 필터링 기능은 "모임 방"에 적용할 수 있다.
  3. 필터링이 가능한 항목은 다음과 같다.
    • 제목 필터
    • 시간 필터
    • 종목 필터
    • 인원 필터
    • 지역 필터
    • 시간순
    • 인원순
    • 최신 등록순
    • 시간이 지난 방 보기 & 안보기
    • 인원이 마감된 방 보기 & 안보기
  4. 항목 내용은 페이징이 되어 응답한다.

Api

  • URI : "/api/room"
  • Method : GET
  • Parameter :
    • roomTitle : 방 제목
    • roomContent : 방 내용
    • area(Array) : 지역
    • exercise(Array) : 운동 종목
    • startAppointmentDate : 운동 시작 시간
    • endAppointmentDate : 운동 끝 시간
    • containNoAdmittance(Boolean) : 인원 마감 보기 & 안보기
    • requiredPeopleCount : 모집 중이 인원
    • page : 페이지
    • size : 페이지 당 데이터 사이즈
    • sort(DESC, ASC) :
      • updatedTime : 최신 등록 순
      • start : 시간이 임박한 순
      • participant : 인원 순

이렇게 많은 동적인 값들을 어떻게 편하게 처리해야 할까?

정답은 QueryDsl을 이용하는 것이다.


QueryDsl

먼저 QueryDsl을 안쓰는 경우를 살펴보자.

Spring Data JPA를 사용했을 경우에, RoomRepository는 다음과 같을 것이다.

public interface RoomRepository extends JpaRepository<Room, Long>, RoomRepositoryCustom {

    List<Room> findAllByContent(String Content);
    List<Room> findAllByContentAndStartAppointmentDateAndEndAppointmentDate(String Content, LocalDateTime startAppointmentDate, LocalDateTime endAppointmentDate);
    List<Room> findAllByContentAndParticipantCount(String content, int participantCount);

    //...


    // 쿼리 받아야할 개수가 11개니까.. 경우의 수는 대략 11! 정도?
    // 그럼.. Repository 추가 메서드가 몇개야..
}

위와 같이 보자..
Spring Data JPA를 이용해도 너무나 빡세다.
JPQL을 이용한다면? 더 끔찍하다.

그리고
메서드 명이 너무 길고 파라미터가 너무 많다.

그리고
단순 조회가 아닌 추가적인 다른 조건절을 추가했다고 생각했을 때, 더더더더더더더더더더더 복잡해진다.

그럼 이제부터 QueryDsl가 무엇인지 살펴보자.

QueryDsl이란?

QueryDsl은 정적 타입을 이용해서 SQL과 같은 쿼리를 생성할 수 있도록 해주는 프레임워크이다.

왜 사용하나?

실제로 Query를 사람이 짜다보면 수많은 쿼리를 수작업으로 생성해야한다.

사람이 짜다보면 Query는 컴파일 단계에서 오류가 있는지 알 수가 없다.
(String으로 처리되기 때문이다.)

Query 생성을 자동화 하여, 자바 코드로 작성할 수 있다.

그 외 기타 이득이 많다.


With Spring Boot

Bad News : Spring에서 QueryDsl을 정식 승인?을 하지 않았다는 글을 본 것 같다.(노확실) 그래서 수작업을 많이많이 해야 한다.

이제부터 Spring에 어떻게 적용할 지 살펴보자.

Gradle

buildscript { // 이놈
	ext {
		queryDslVersion = "5.0.0"
	}
}

plugins {
	id 'org.springframework.boot' version '2.6.3'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
	id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

group = 'com.togethersports'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	// spring dependencies
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
	implementation 'org.springframework.security:spring-security-acl'
	implementation 'org.springframework.boot:spring-boot-starter-aop'
	implementation 'org.springframework.boot:spring-boot-starter-cache'
	implementation 'org.springframework.boot:spring-boot-starter-validation'

	// querydsl 이놈
	implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
	implementation "com.querydsl:querydsl-apt:${queryDslVersion}"

	annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"

	// lombok & jdbc driver
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2' // 개발용
	runtimeOnly 'mysql:mysql-connector-java' // 배포용
	annotationProcessor 'org.projectlombok:lombok'

	//websocket
	implementation 'org.springframework.boot:spring-boot-starter-websocket'
	implementation 'org.webjars:sockjs-client:1.1.2'
	implementation 'org.webjars:stomp-websocket:2.3.3-1'

	// utility
	implementation 'org.modelmapper:modelmapper:3.1.0'

	//  jwt
	implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
	runtimeOnly'io.jsonwebtoken:jjwt-jackson:0.11.2'

	// poi (엑셀 파일 처리용 라이브러리)
	implementation 'org.apache.poi:poi-ooxml:5.2.2'

	// test
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}


tasks.named('test') {
	useJUnitPlatform()
}

//이놈
def querydslDir = "$buildDir/generated/querydsl"

querydsl { //이놈
	jpa = true
	querydslSourcesDir = querydslDir
}

sourceSets { //이놈
	main.java.srcDir querydslDir
}

configurations { // 이놈
	compileOnly {
		extendsFrom annotationProcessor
	}
	querydsl.extendsFrom compileClasspath
}

compileQuerydsl { // 이놈
	options.annotationProcessorPath = configurations.querydsl
}

다른 라이브러리들도 많아서 QueryDsl에 해당하는 부분만 보자!

  1. Plugin 추가
  2. dependencies 추가
  3. def 추가
  4. querydsl 추가
  5. sorceSets 추가
  6. compileQuerydsl 추가
  7. configurations 추가
  8. Gradle reload!

Q 클래스 생성

  1. Gradle 탭을 들어간다.
  2. other > compileQuerydsl
  3. "run"을 해준다.

이렇게 해주면 Pakage에서 다음 디렉터리가 생긴 것을 볼 수 있다.

그리고 Q클래스가 생성된 것을 볼 수 있다.

EntityManager 주입받기

QueryDsl이 query를 생성할 수 있도록 EntityManager를 주입하자!

QueryDslConfig 생성


/**
 * <h1>QuerydslConfig</h1>
 * <p>
 *     QueryDsl 설정
 * </p>
 * @author younghocha
 */
@Configuration
public class QuerydslConfig {

    @PersistenceContext
    private EntityManager entityManager;

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

RoomRepository 생성

public interface RoomRepository extends JpaRepository<Room, Long>, RoomRepositoryCustom {

}

RoomRepositoryCustom 생성

public interface RoomRepositoryCustom {

    Page<RoomOfList> searchAll(FieldsOfRoomList fieldsOfRoomList, Pageable pageable);
}

해당 클래스를 생성한 이유는 RoomRepository(JPARepository, RoomRepository)를 DAO를 1개만 사용하기 위해서 생성한 것이다!

RoomRepositoryImpl

이제 여기서 본격적으로 QueryDsl에 대한 로직을 작성한다!


/**
 * <h1>RoomRepositoryImpl</h1>
 * <p>
 * 방 필터링(동적쿼리)를 위한 클래스
 * </p>
 * @author younghoCha
 */

@RequiredArgsConstructor
public class RoomRepositoryImpl implements RoomRepositoryCustom{

    private final JPAQueryFactory queryFactory;
    private final ParsingEntityUtils parsingEntityUtils;

    @Override
    public Page<RoomOfList> searchAll(FieldsOfRoomList fieldsOfRoomList, Pageable pageable) {

        List<Room> result = queryFactory
                .selectFrom(room)
                .leftJoin(room.tags, tag1).fetchJoin()
                .where(eqInterests(
                    fieldsOfRoomList.getExercise()),
                    eqArea(fieldsOfRoomList.getArea()),
                    participateCount(fieldsOfRoomList.getParticipantCount(), fieldsOfRoomList.isContainNoAdmittance()),
                    betweenTime(fieldsOfRoomList.getStartAppointmentDate(), fieldsOfRoomList.getEndAppointmentDate(), fieldsOfRoomList.isContainTimeClosing()),
                    eqTitle(fieldsOfRoomList.getRoomTitle()),
                    eqContent(fieldsOfRoomList.getRoomContent())
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(sortRoomList(pageable))
                .fetch();


        return afterTreatment(result);

    }
    //방 제목 검색
    private BooleanExpression eqTitle(String searchTitle){
        return searchTitle == null ? null : room.roomTitle.contains(searchTitle);
    }

    //지역 필터링 검색
    private BooleanBuilder eqArea(List<String> areas){

        if(areas == null){
            return null;
        }

        BooleanBuilder booleanBuilder = new BooleanBuilder();

        for (String area : areas){
            booleanBuilder.or(room.roomArea.contains(area));
        }

        return booleanBuilder;
    }

    //종목 필터링(복수) 검색
    private BooleanBuilder eqInterests(List<String> interests){
        if(interests == null){
            return null;
        }

        BooleanBuilder booleanBuilder = new BooleanBuilder();

        for (String interest : interests){
            booleanBuilder.or(room.exercise.eq(interest));
        }

        return booleanBuilder;
    }


    //방 설명 검색
    private BooleanExpression eqContent(String content){
        return content == null ? null : room.roomContent.contains(content);
    }

    // 시간 대 검색
    private BooleanExpression betweenTime(LocalDateTime start, LocalDateTime end, boolean conTainTimeClosing){
        /*시간 마감 보기 설정 */
        if(conTainTimeClosing){
            //시간 검색 X
            if(start == null){
                return null;
            }
            //시간 검색 O
            return room.startAppointmentDate.between(start, end);
        }

        /*시간 마감 보기 설정 X*/
        //시간 검색 X
        if(start == null){
            return room.endAppointmentDate.gt(LocalDateTime.now());
        }
        //시간 검색 O
        return room.startAppointmentDate.between(start, end).and(room.endAppointmentDate.gt(LocalDateTime.now()));
    }


    /*입장 가능 인원 검색*/
    private BooleanExpression participateCount(Integer participantCount, boolean containNoAdmittance){
        //입장 마감 보기 시,
        if(containNoAdmittance){
            // 인원 검색을 하지 않았을 때
            if(participantCount == null){
                return null;
            }

            // 인원 검색을 했을 때
            return room.limitPeopleCount.gt(room.participantCount.add(participantCount));

        }

        /*입장 마감 안보기 시,*/
        // 인원 검색을 하지 않았을 때
        if(participantCount == null){
            return room.limitPeopleCount.gt(room.participantCount);
        }
        // 인원 검색을 했을 때
        return room.limitPeopleCount.gt(room.participantCount.add(participantCount));

    }

    // 정렬
    private OrderSpecifier<?> sortRoomList(Pageable page){
        //서비스에서 보내준 Pageable 객체에 정렬조건 null 값 체크
        if (!page.getSort().isEmpty()) {
            //정렬값이 들어 있으면 for 사용하여 값을 가져온다
            for (Sort.Order order : page.getSort()) {
                // 서비스에서 넣어준 DESC or ASC 를 가져온다.
                Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
                // 서비스에서 넣어준 정렬 조건을 스위치 케이스 문을 활용하여 셋팅하여 준다.
                switch (order.getProperty()){
                    case "start":
                        return new OrderSpecifier(direction, room.startAppointmentDate);
                    case "updatedTime":
                        return new OrderSpecifier(direction, room.updatedTime);
                    case "participant":
                        return new OrderSpecifier(direction, room.participantCount);
                }
            }
        }
        return new OrderSpecifier(Order.DESC, room.updatedTime);
    }

    /**
     * List<Tag> -> List<String>으로 교체하기 위한 후처리 메소드
     * @param entities : 동적 쿼리로 조회된 Room Entity List
     * @return : 데이터를 조작한 Room DTO List
     * @author : younghoCha
     */
    private PageImpl afterTreatment(List<Room> entities){

        List<RoomOfList> rooms = new ArrayList<>();
        for (Room room : entities){
            List<String> tag = parsingEntityUtils.parsingTagEntityToString(room.getTags());
            rooms.add(RoomOfList.of(room, tag));
        }

        return new PageImpl<>(rooms);

    }


}

코드를 살펴보면 DB를 상황에 맞게 Select하는 Query를 생성해주는 것을 볼 수 있다.

이제 하나씩 살펴보자.

  1. searchAll
    이 Room 필터링 과정 중에 필요한 동적 쿼리를 처리하기 위한 메소드를 오버라이드 한 것이다.

  2. selectFrom(room)
    "room"이 저장되어 있는 테이블로 부터 조회한다는 뜻이다.

  3. leftJoin(room.tags, tag1)
    room의 tag와 tag1을 leftJoin한다는 뜻이다.

  4. fetchJoin()
    3번의 leftJoin을 패치 조인을 적용한다는 뜻이다.

  5. where()
    where은 여러가지의 조건들을 입력하는 것이다.

  6. offset(pageable.getOffset())
    데이터를 가져올 레코드의 시작점을 결정해주는 메소드이다.

pageable.getOffset()의 구현체를 살펴보면 다음과 같다.

/*
	 * (non-Javadoc)
	 * @see org.springframework.data.domain.Pageable#getOffset()
	 */
	public long getOffset() {
		return (long) page * (long) size;
	}
  1. limit(pageable.getPageSize())

가져올 레코드의 개수를 정한다.

  1. orderBy(sortRoomList(pageable))

orderBy() 메소드는 파라미터로 "OrderSpecifier<?>" 타입을 받는다.

나온 값들을 정렬 해주는 메소드이다. 입력해준 값에 따라서 Query의 "Order By" 구문을 생성해준다.

  1. fetch()
    이 함수를 따라가면 다음과 같다.
@Override
    @SuppressWarnings("unchecked")
    public List<T> fetch() {
        try {
            Query query = createQuery();
            return (List<T>) getResultList(query);
        } finally {
            reset();
        }
    }

query를 생성하고, 결과를 리스트로 반환하는 역할을 한다.

그럼 여기서 가장? 중요하다고 판단되는 where 파라미터들을 살펴보자.

where()

먼저 where 함수를 살펴보자.

public Q where(Predicate... o) {
        return queryMixin.where(o);
    }

Predicate 타입의 객체를 받는 것을 알 수 있다.

Predicate로 받으면 query를 Predicate에 맞춰서 생성을 해준다.

생성하려는 조건 개수대로 Predicate를 생성해서 파라미터로 넘겨주면 그에 맞는 Query가 생성된다.

그리고 null을 파라미터로 넘겨주면 해당하는 파라미터를 무시하고, where 조건을 생성하지 않는다.

Predicate

살펴보니까 where에 Predicate 객체를 넘기는 방법은 여러가지다.

내가 이용한 이유만 2가지 살펴보자!

  1. BooleanExpression 리턴
    BooleanExpression을 살펴보면,
public abstract class BooleanExpression extends LiteralExpression<Boolean> implements Predicate {
}

Predicate를 구현한 것을 볼 수 있다.

그래서 BooleanExpression을 리턴해도 where() 파라미터로 잘 넘길 수 있다.

  1. BooleanBuilder 리턴
    BooleanBuilder 또한 다음과 같다.
public final class BooleanBuilder implements Predicate, Cloneable  {
}

Predicate를 똑같이 구현했다.

  1. 왜 두 가지를 채택했나?
    • BooleanExpression은 1번 조회만 가능하다고 판단이 되었다.
    • BooelanBuilder는 여러가지의 값을 같은 조건에 넣어야 할 때 사용했다.

코드를 살펴보고 이해해보자!


eqInterests(List)

요구사항에서 살펴보면 관심 종목으로 Room을 검색할 수 있다. 필터링 과정 중에서 "축구, 골프"로 필터를 검색하면 축구 or 골프가 조회되어야 한다.

그래서

List로 받은 값들을 전부 where 조건으로 만들어야 한다.
그래서 or() 함수로 반복해서 넣어주었다.

betweenTime(LocalDateTime start, LocalDateTime end, boolean containTimeClosing)

이 함수의 경우는 경우의 수가 4가지라서 로직이 4개로 분리된다.

  • 시간 마감된 방 보기 설정

    • 시간 검색을 한 경우 : between을 통해서 파라미터로 들어온 start와 end 사이의 시간사이에 room의 start가 있는 것을 조회

    • 시간 검색을 하지 않은 경우 : return null

  • 시간 마감된 방 안보기 설정 : 현재 시간 이후로만 조회

    • 시간 검색을 한 경우 : between을 통해서 파라미터로 들어온 start와 end 사이의 시간사이에 room의 start가 있는 것을 조회

    • 시간 검색을 하지 않은 경우 : 현재 시간 이후로만 조회되는 것만 리턴

기타 로직적으로 겹치는 부분은 생략했습니다!


잘못된 내용이 포함되어 있을 수 있습니다! 댓글로 알려주시면 감사합니다!

profile
관심많은 영호입니다. 궁금한 거 있으시면 다음 익명 카톡으로 말씀해주시면 가능한 도와드리겠습니다! https://open.kakao.com/o/sE6T84kf

6개의 댓글

comment-user-thumbnail
2022년 11월 11일

친절한 영호님 감사합니다!! :) 이해가 잘됩니다!!

1개의 답글
comment-user-thumbnail
2022년 11월 20일

감사합니다. 많은 도움이 되었습니다.!

1개의 답글
comment-user-thumbnail
2023년 4월 4일

정리 잘해주셔서 도움 되었습니다.
감사합니다.

한 가지 궁금한 점이 있습니다.

public interface RoomRepository extends JpaRepository<Room, Long>, RoomRepositoryCustom {

List<Room> findAllByContent(String Content);
List<Room> findAllByContentAndStartAppointmentDateAndEndAppointmentDate(String Content, LocalDateTime startAppointmentDate, LocalDateTime endAppointmentDate);
List<Room> findAllByContentAndParticipantCount(String content, int participantCount);

//...


// 쿼리 받아야할 개수가 11개니까.. 경우의 수는 대략 11! 정도?
// 그럼.. Repository 추가 메서드가 몇개야..

}

위 부분에서 11!이 아닌 2^11 경우의 수가 아닌가요?
팩토리얼로 추정되는 이유가 있으신지 궁금합니다

1개의 답글