Syncope.T-*
Published 2022. 10. 4. 08:11
QueryDSL에 관하여 下篇 BackEnd/Spring
728x90

시작하기에 앞서

Legacy 1, 2를 두고서 얼마나 귀찮고 힘든 과정으로 필터문을 만들어야 했는지 알아보았다.

결과적으로 SQL이 차지하는 비율은 항상 절반이 넘었다는 것을 알 수 있었다.

또한, JPA만 사용한다고 하더라도, Specification이나 Native Query를 꼭 사용해야만 하는 경우가 많았음을 보여준다.

이때까지만 하더라도 QueryDSL이 그렇게 좋아? 하는 의문이 들었다.

예전부터 QueryDSL을 쓰고 싶었으나, 내 입맛에 맞게 쓰려면 Hibernate 5, 6이나 vladmihalcea의 hibernate-types까지 나오기 전엔 못쓰겠다 싶었는데 다행히 도입하는 시점에는 입맛에 맞는 상황이 돼버린 터라 쓰게 되었다.

QueryDSL을 시작하려고 다른 사람들은 어떻게 쓰고 있는가 해서 맛을 보려했지만 인프런의 강좌는 결제를 해야 하니 나중으로 미루고 검색을 통해 기술 블로그, 깃허브들은 찾아보았으나 튜토리얼 이전 단계처럼 정말 쓸모없는 자료들이 많았다. 심한 곳은 그냥 복사 붙여넣기한 곳도 있었다.

결국은 입맞에 맞게 설정을 하면서 커스터마이징도 같이 진행했다.

 

build.gradle

우선 build.gradle 설정부터 보자.

나는 일반적으로 웹 프로젝트 진행 시 의존성을 저 정도로 쓰고 있긴 하나 다이어트한다면 jsr310 정도지 않을까 싶다..

buildscript{
    ext{
        queryDslVersion = "5.0.0" // version 설정
    }
}

plugins {
    id 'org.springframework.boot' version '2.7.3'
    id 'io.spring.dependency-management' version '1.0.13.RELEASE'
    id 'java'
    id 'war'
    /**
     * QueryDSL
     */
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

.
.
.


dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-aop'

    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-gson:0.11.5'
    implementation 'org.javassist:javassist:3.29.0-GA'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'mysql:mysql-connector-java'

    implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.4'
    implementation 'com.google.code.gson:gson:2.9.0'
    implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.11'
    implementation group: 'commons-io', name: 'commons-io', version: '2.8.0'
    implementation 'org.hibernate:hibernate-java8:5.6.11.Final'
    implementation 'com.vladmihalcea:hibernate-types-55:2.19.2'
    implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5:2.13.4'
    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:3.1.4'
    implementation 'io.github.openfeign:feign-core:11.9.1'

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


    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
    implementation('org.apache.tomcat.embed:tomcat-embed-jasper')
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
}

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


/**
 * QueryDSL
 */
// querydsl 사용할 경로 지정합니다. 
// 이 경로는 .gitignore에 포함됨을 유의
def querydslDir = "$buildDir/generated/'querydsl'"

// JPA 연동 설정
querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}

// Android 프로젝트 처럼 따로 src를 지정해주어야 한다.
sourceSets {
    main.java.srcDir querydslDir
}


// 아래 Task를 통하게 끔 만들어준다.
compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

// compileClasspath 없이는, JPA 의존성에 접근하지 못한다.
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    querydsl.extendsFrom compileClasspath
}

 

 

주제는 무엇인가요?

동영상 정보를 담고 CRUD를 하는 Service를 작성할 것이다.

특히 이 글에서는 list(GET)의 Filtering이 어떻게 적용되는가가 중심이다.

페이지네이션부터 생각

우선 Controller에서 Pagination을 포함한 데이터 정보를 받을 수 있는 공용 DTO를 선언해보자.

필터링할 용도로 잘 간섭하게끔 짜 보자 나는 이렇게 쓰려고 한다

VideoController

@RestController
@RequestMapping("/api/video")
@AllArgsConstructor
public class VideoController {

    private final VideoService videoService;

    @GetMapping
    public ResponseEntity list(@RequestBody SearchVideo searchVideo) throws Exception {
        return ResponseEntity.ok(videoService.list(searchVideo));
    }
}

PageOption

@Getter
@Setter
public class PageOption {
    private Integer page;
    private Integer size;
    private List<PageOrder> orders;

    @Getter
    @Setter
    public static class PageOrder implements PageOrderer {
        private String direction;
        private String property;

        @Override
        public Sort.Order toOrder() {
            return new Sort.Order(Sort.Direction.fromString(direction), property);
        }
    }

    public Pageable pageable(){
        if(orders != null && orders.size() > 0){
            return PageRequest.of(page, size, toSort());
        }
        return PageRequest.of(page, size);
    }

    public Sort toSort(){
        return Sort.by(orders.stream().map(PageOrder::toOrder).collect(Collectors.toList()));
    }
}
public interface PageOrderer {
    Sort.Order toOrder();
}

다음은 Controller에 넣은 DTO는 각 Entity에 필터링 용도에 맞게끔 구성할 수 있게 위 PageOption을 상속해서 구성한다.

Q. 왜 쓸데없이 interface를 늘리나요?
A. 경험에 의해서 언제든지 기획적/기능적 확장될 가능성이 보이는 곳에서는 규약을 설정하는 것이 유지보수와 당신의 스트레스에 이롭습니다.

SearchVideo

@Getter
@Setter
public class SearchVideo extends PageOption {
    private VideoSearchClauser searchOption;
    private String searchText;
}

VideoSearchClauser는 다음 Repository에서 설명한다.

Repository

Repository에서는 한 Entity에 대하여 JPARepostiory를 상속하는 기존 Repository는 놔두자.

나는 QueryDSL를 필터링 용도로만 사용할 것이기 때문이다.

VideoService의 2개의 Repository를 따로 사용 해보자

@Slf4j
@Repository
@AllArgsConstructor
public class VideoDSLRepository {

    private final JPAQueryFactory queryFactory;

    public Page<Video> search(SearchVideo dto) throws Exception {
        Pageable pageable = dto.pageable();
        VideoSearchClauser whereClauser = dto.getSearchOption();
        VideoOrderByClauser orderByClauser = VideoOrderByClauser.instance();

        Predicate origin = whereClauser.build(video, dto);

        Long total = queryFactory.from(video)
                .select(Wildcard.count)
                .where(origin)
                .fetch()
                .get(0);

        List<Video> result = total > 0 ? queryFactory.selectFrom(video)
                .where(origin)
                //.orderBy(video.uploadDateTime.desc(), video.createdDate.desc())
                .orderBy(orderByClauser.orderBy(dto))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch() : new ArrayList<>();

        return new PageImpl<>(result, pageable, total);
    }
}

DTO의 pagable을 받고, SearchClauser에서 where 메소드의 Predicate를,

OrderByClauser에서 orderBy 메소드에 들어갈 Predicate를 따로 관리하게끔 하려 한다.

궁극적인 목적은 다음과 같다.

  • where를 길게 적거나, 메소드로 따로 빼서 관리하는 건 유지보수가 힘들 수 있다
  • 따로 빼서 관리하는 Predicate 공간이 존재한다면, 다른 곳에서도 분명 쓰일 수 있을 것이다.
  • 코드는 항상 간결하고 명확할수록 가시성과 이해력에 도움이 된다.

다른 블로그들 보니, 따로 method를 빼서 BooleanBuilder나 BooleanExpression을 if, if, if.... 해서 써놨는데.

내 코드에서는 꼴 뵈기가 싫다. 집 정리는 안 하더라도 내 코드상에서는 깔끔하게 관리하고 싶다.

당장 옆 백엔드 동료에게 물어보아라
○○님 Video Search Predicate만 따로 만들어 주실 수 있으신가요? Entity는 만들어 두었어요.
Clauser 인터페이스는 따로 두었고요 구현체만 활용해서 만들어주시면 됩니다.
라고 했을 때가 더 편하지 않을까?

반대로

○○님 Video Search 쪽 전담해주세요 했을 때, 두 분이 일란성쌍둥이로 태어났더라도 서로에게 만족하는 코드가 나올 수 없을 것이다.

잡소리는 거두절미하고 빠르게 WhereClauser로 넘어가 보자.

Interface WhereClauser

public interface WhereClauser<V extends EntityPathBase<?>, T> {
    public Predicate build(V v, T t) throws Exception;
}

단순히 WhereClauser라는 규약만 설정하였다. 어차피 where 조건에 던져줄 것은 Predicate이기 때문이다.

쓰임새를 보면 이해가 될 것이다.

 

VideoSearchClauser

@Slf4j
@Getter
public enum VideoSearchClauser implements WhereClauser<QVideo, SearchVideo> {
    ALL((t, s) -> null),
    TITLE((t, s) -> (t != null && s != null) ? t.title.contains(s) : null),
    //KEYWORD((t, s) -> Expressions.stringTemplate("JSON_SEARCH(keyword, {0}, {1})", "one", s).isNotNull()),
    KEYWORD((t, s) -> MySQLJsonPredicate.List.contains("keyword", s)),
    ;

    private final QueryDSLWherePredicator<QVideo, String> predicate;

    VideoSearchClauser(QueryDSLWherePredicator<QVideo, String> predicate){
        this.predicate = predicate;
    }

    @Override
    public Predicate build(QVideo entity, SearchVideo s) throws Exception{
        BooleanExpression exp = entity.id.gt(0);
        if(this == ALL){
            List<VideoSearchClauser> exceptAll = Arrays.stream(VideoSearchClauser.values()).filter(x-> x != ALL).collect(Collectors.toList());
            for (VideoSearchClauser clauser : exceptAll) {
                //log.info("모든 클로저 중 실행 {} -> {}", clauser.name(), clauser.predicate.test(entity, s.getSearchText()));
                exp.or(clauser.getPredicate().test(entity, s.getSearchText()));
            }
        }else{
            //log.info("클로저 {} -> {}", name(), predicate.test(entity, s.getSearchText()));
            exp.and(predicate.test(entity, s.getSearchText()));
        }
        return exp;
    }

}

이걸 보니 나중에 내 블로그를 들르는 분들에게 enum 활용기를 따로 써주어야겠다고 생각이 들기도 한다.

우아한 형제들에서 enum 활용기는 정보 측면에서 엄청 잘 쓴 글이긴 하지만, 실무에서 어떻게 쓰는가에 대한 감이 오지는 않을 것 같다고 생각이 들긴 하지만 나도 그럴지도 모르겠다..

 

Clauser는 Enum으로 이루어졌다. 나름 규약을 설정해서 API Documentation을 작성할 때에 해당 값들을 같이 던져주면 되니깐 말이다. 물론 Swagger에서도 Enum은 지원하니깐 말이다.

 

build 메서드를 보면, BooleanExpression exp를 기본적으로 id 값이 0보다 큰(그냥 모든 값)을 하나 선언해두고 or나 and 원하는 방향으로 구현을 해주면 되는 사항이다.

 

다음으로 Enum Value에서 KEYWORD를 주목하면, 해당 칼럼 `KEYWORD`가 Table 내에서 Type이 JSON으로 되어있는데. JSON의 경우 Expression.stringTemplate로 직접 쿼리를 실행하여 찾으면 되긴 하나, 매번 중복된 코드로 보이고, 다양한 Collection이 이러한 경우가 될 것 같아서  아래와 같이 static으로 빼 두었다. 사실 JSON을 지양하는 게 제일 좋긴 하나, 복잡한 Nested Data를 엄청난 JOIN으로 들고 오기 싫으면 JSON으로 넣어두는 게 맘이 편하다.

public class MySQLJsonPredicate {

    /**
     * Collections
     */
    public static JsonListPredicate List = new JsonListPredicate();

    public static class JsonListPredicate{
        public BooleanExpression contains(String column, String find){
            return Expressions.stringTemplate("JSON_SEARCH({0}, {1}, {2})", column, "one", find).isNotNull();
        }
    }
}

 

OrderByClauser

public interface OrderByClauser<T> {
    public OrderSpecifier<?>[] orderBy(T t);
}

Repository에서 orderBy 메서드가 OrderSpecifier를 요구하고 있어 해당 객체를 내보내 주기로 규약을 설정한다. SearchVideoClauser와 같이 흐름을 구성하면 되나, 여기선 Pagable 객체에 있는 Order들을 기준으로 구성하면 된다.

@Getter
@NoArgsConstructor
public class VideoOrderByClauser implements OrderByClauser<SearchVideo> {

    public static VideoOrderByClauser instance(){
        return VideoOrderByClauserHolder.instance;
    }

    private static class VideoOrderByClauserHolder{
        private static final VideoOrderByClauser instance = new VideoOrderByClauser();
    }

    @Override
    public OrderSpecifier<?>[] orderBy(SearchVideo s) {
        List<OrderSpecifier<?>> orders = new ArrayList<>();
        Pageable pageable = s.pageable();
        for (Sort.Order order : pageable.getSort()) {
            VideoOrder clauser = VideoOrder.ofProperty(order.getProperty());
            Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
            orders.add(clauser.getPredicate().test(direction));
        }
        return orders.toArray(new OrderSpecifier[0]);
    }

}

위와 같이 구성하면 되는데, VideoOrder 값은 API Documentation나 Frontend에게 알려줄 값들이다.

DEFAULT에 null 값을 적어준 이유는 Order에서 Null일 경우 무시하고 넘어가기 때문에, 정렬 없음과 똑같은 뜻이다.

@Getter
public enum VideoOrder {
    DEFAULT(null),
    createdDate(direction -> new OrderSpecifier(direction, video.createdDate)),
    title(direction -> new OrderSpecifier(direction, video.title)),
    ;

    private final QueryDSLOrderPredicator<Order> predicate;

    VideoOrder(QueryDSLOrderPredicator<Order> predicate) {
        this.predicate = predicate;
    }

    public static VideoOrder ofProperty(String property) {
        return Arrays.stream(VideoOrder.values())
                .filter(a -> a.name().equals(property))
                .findFirst()
                .orElse(DEFAULT);
    }
}

여기서의 predicate도 별건 없지만, FunctionalInterface로 구성했다.

이유는 Predicate를 받고 싶은데, 파라미터를 같이 받고 싶어 그런 것이다.

@FunctionalInterface
public interface QueryDSLOrderPredicator<S> {
    OrderSpecifier test(S s);
}

 

이로써 나만의 아름다운 QueryDSL이 구성되었다.

위 설정 과정을 거치면서 다시금 느끼는 것이 있다.

 

항상 Plugin, Libarary, Framework 등 3rd Party 것들은 실력과 관계없이

내 입맛에 맞는가가 가장 중요하지 않은가 싶다.

사실상 프로젝트 경험 중 실패가 많으면 얻을 수 있는 과정이긴 하지만 말이다.

 

추신.

여러 블로그에서 QueryDSL을 Java에서 구현하는데 절차 지향적으로 구성한 Service나 Repository들이 너무 많아서 안타까움만 남았다..

profile

Syncope.T-*

@Syncope

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

profile on loading

Loading...