오늘은 실제로 내가 구현하고 있는 토이 프로젝트에 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은 정적 타입을 이용해서 SQL과 같은 쿼리를 생성할 수 있도록 해주는 프레임워크이다.
실제로 Query를 사람이 짜다보면 수많은 쿼리를 수작업으로 생성해야한다.
사람이 짜다보면 Query는 컴파일 단계에서 오류가 있는지 알 수가 없다.
(String으로 처리되기 때문이다.)
Query 생성을 자동화 하여, 자바 코드로 작성할 수 있다.
그 외 기타 이득이 많다.
Bad News : Spring에서 QueryDsl을 정식 승인?을 하지 않았다는 글을 본 것 같다.(노확실) 그래서 수작업을 많이많이 해야 한다.
이제부터 Spring에 어떻게 적용할 지 살펴보자.
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에 해당하는 부분만 보자!
이렇게 해주면 Pakage에서 다음 디렉터리가 생긴 것을 볼 수 있다.
그리고 Q클래스가 생성된 것을 볼 수 있다.
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);
}
}
public interface RoomRepository extends JpaRepository<Room, Long>, RoomRepositoryCustom {
}
public interface RoomRepositoryCustom {
Page<RoomOfList> searchAll(FieldsOfRoomList fieldsOfRoomList, Pageable pageable);
}
해당 클래스를 생성한 이유는 RoomRepository(JPARepository, RoomRepository)를 DAO를 1개만 사용하기 위해서 생성한 것이다!
이제 여기서 본격적으로 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를 생성해주는 것을 볼 수 있다.
이제 하나씩 살펴보자.
searchAll
이 Room 필터링 과정 중에 필요한 동적 쿼리를 처리하기 위한 메소드를 오버라이드 한 것이다.
selectFrom(room)
"room"이 저장되어 있는 테이블로 부터 조회한다는 뜻이다.
leftJoin(room.tags, tag1)
room의 tag와 tag1을 leftJoin한다는 뜻이다.
fetchJoin()
3번의 leftJoin을 패치 조인을 적용한다는 뜻이다.
where()
where은 여러가지의 조건들을 입력하는 것이다.
offset(pageable.getOffset())
데이터를 가져올 레코드의 시작점을 결정해주는 메소드이다.
pageable.getOffset()의 구현체를 살펴보면 다음과 같다.
/*
* (non-Javadoc)
* @see org.springframework.data.domain.Pageable#getOffset()
*/
public long getOffset() {
return (long) page * (long) size;
}
가져올 레코드의 개수를 정한다.
orderBy() 메소드는 파라미터로 "OrderSpecifier<?>" 타입을 받는다.
나온 값들을 정렬 해주는 메소드이다. 입력해준 값에 따라서 Query의 "Order By" 구문을 생성해준다.
@Override
@SuppressWarnings("unchecked")
public List<T> fetch() {
try {
Query query = createQuery();
return (List<T>) getResultList(query);
} finally {
reset();
}
}
query를 생성하고, 결과를 리스트로 반환하는 역할을 한다.
그럼 여기서 가장? 중요하다고 판단되는 where 파라미터들을 살펴보자.
먼저 where 함수를 살펴보자.
public Q where(Predicate... o) {
return queryMixin.where(o);
}
Predicate 타입의 객체를 받는 것을 알 수 있다.
Predicate로 받으면 query를 Predicate에 맞춰서 생성을 해준다.
생성하려는 조건 개수대로 Predicate를 생성해서 파라미터로 넘겨주면 그에 맞는 Query가 생성된다.
그리고 null을 파라미터로 넘겨주면 해당하는 파라미터를 무시하고, where 조건을 생성하지 않는다.
살펴보니까 where에 Predicate 객체를 넘기는 방법은 여러가지다.
내가 이용한 이유만 2가지 살펴보자!
public abstract class BooleanExpression extends LiteralExpression<Boolean> implements Predicate {
}
Predicate를 구현한 것을 볼 수 있다.
그래서 BooleanExpression을 리턴해도 where() 파라미터로 잘 넘길 수 있다.
public final class BooleanBuilder implements Predicate, Cloneable {
}
Predicate를 똑같이 구현했다.
코드를 살펴보고 이해해보자!
요구사항에서 살펴보면 관심 종목으로 Room을 검색할 수 있다. 필터링 과정 중에서 "축구, 골프"로 필터를 검색하면 축구 or 골프가 조회되어야 한다.
그래서
List로 받은 값들을 전부 where 조건으로 만들어야 한다.
그래서 or() 함수로 반복해서 넣어주었다.
이 함수의 경우는 경우의 수가 4가지라서 로직이 4개로 분리된다.
시간 마감된 방 보기 설정
시간 검색을 한 경우 : between을 통해서 파라미터로 들어온 start와 end 사이의 시간사이에 room의 start가 있는 것을 조회
시간 검색을 하지 않은 경우 : return null
시간 마감된 방 안보기 설정 : 현재 시간 이후로만 조회
시간 검색을 한 경우 : between을 통해서 파라미터로 들어온 start와 end 사이의 시간사이에 room의 start가 있는 것을 조회
시간 검색을 하지 않은 경우 : 현재 시간 이후로만 조회되는 것만 리턴
기타 로직적으로 겹치는 부분은 생략했습니다!
잘못된 내용이 포함되어 있을 수 있습니다! 댓글로 알려주시면 감사합니다!
정리 잘해주셔서 도움 되었습니다.
감사합니다.
한 가지 궁금한 점이 있습니다.
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 경우의 수가 아닌가요?
팩토리얼로 추정되는 이유가 있으신지 궁금합니다
친절한 영호님 감사합니다!! :) 이해가 잘됩니다!!