반응형
04-20 10:01
Today
Total
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
관리 메뉴

개발하는 고라니

[Spring Boot] JPA 동적 검색 (QueryDsl) 본문

Framework/Spring Boot

[Spring Boot] JPA 동적 검색 (QueryDsl)

조용한고라니 2021. 1. 13. 01:23
반응형

# JPQL

JPQL(Java Persistence Query Language)는 JPA(Java Persistence API)의 일부로 정의된 플랫폼에 독립적인 객체지향 쿼리 언어이다. JPQL은 관계형 데이터베이스의 엔티티에 대한 쿼리를 만드는데 사용된다.

 

JPA는 엔티티 객체를 중심으로 개발하므로 SQL을 사용하지 않는다. 하지만 검색쿼리를 사용할 때는 SQL을 사용해야 한다.

SQL의 영향을 받아 SQL과 비슷하나, DB 테이블에 직접 접근하는 것이 아닌 JPA 엔티티에 동작한다. 그래서 JPQL의 쿼리에는 테이블이 아닌 엔티티에서 사용되는 컬럼의 이름을 사용해야 한다.

 

* SQL : 데이터베이스 테이블을 대상으로 쿼리함

* JPQL : 엔티티 객체를 대상으로 쿼리함

 

# JPQL로 검색을?

웹 어플리케이션 개발에 있어 검색 기능은 매우 중요하다. 무분별한 정보들 중 클라이언트가 찾고자 하는 정보를 빠르게 얻게할 수 있기 때문이다. MyBatis에서 검색할 때 <if>, <choose>, <when> ... 같은 것을 써서 동적으로 쿼리를 생성했다. JPA에서 SQL을 왠만해선 직접 사용하지 않고, Query Method, @Query, Querydsl 등을 사용한다. 따라서 이 기능 안에서 효율적으로 검색 기능을 완성해야한다.

 

관계형 데이터베이스에서 각 테이블이 서로 FK로 참조하듯이 엔티티에서도 서로 연관이 있다면 참조할 수 있다.

 

만약 단일 엔티티의 경우(FK를 갖지않는) Querydsl로 비교적 간단하게 검색기능을 작성할 수 있다. 하지만 여러 엔티티가 있고 서로 외래키로 참조하고 있는 경우 JPQL로 직접 처리하는 것은 Object[] 타입으로 나오기 때문에 (Tuple 형태) 작성하는 방법 자체가 다르고 복잡하다. 하지만 어떤 상황에서도 가장 강력한 JPQL을 구성할 수 잇는 방식이기도 하다.

 

# Querydsl

오픈 소스 프로젝트이고 type-safe한 쿼리를 위한 Domain Specific Language이다.

 

SQL 쿼리는 문자이기 때문에 type-check가 불가하고 실행해 보기 전 까지 작동 여부 확인이 어려운데, 만약 SQL이 Class처럼 Type을 갖고 Java 코드로 작성할 수 있다면 좋지 않을까? SQL을 java로 type-safe하게 개발 할 수 있게 해주는 프레임워크가 Querydsl이다. 이는 JPQL을 type-safe하게 작성하기 위해 만들어졌으며 다음과 같이 작동한다.

 

Querydsl => JPQL => SQL


영화와 영화 리뷰를 등록하는 웹 어플리케이션에서 목록 페이지 내 검색을 JPQL을 이용해 만들어보자.

사용되는 Entity는 Movie, Review, MovieImage, Member가 되겠으나 검색에서 필요한 정보를 담은 Entity 클래스는 3개이다. Movie, Review, MovieImage가 필요하다. 각 엔티티는 다음과 같다.

//Movie Entity
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class Movie extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int mno;

    private String title;
}
//MovieImage Entity
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@ToString(exclude = "movie")
@Builder

public class MovieImage {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer inum;

    private String uuid;
    private String imgName;
    private String path;

    @ManyToOne(fetch = FetchType.LAZY)
    private Movie movie;
}
//Review Entity
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@ToString(exclude = {"movie", "member"})
public class Review extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer reviewNum;

    @ManyToOne(fetch = FetchType.LAZY)
    private Movie movie;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;

    private int grade;
    private String comment;
}

MovieImage -> FK -> Movie

Review -> FK -> Movie

Review -> FK -> Member

 

즉, Movie 1개는 여러 개의 MovieImage를 갖을 수 있다.

    Movie 1개는 여러 개의 Review를 갖을 수 있다.

 

이제 각 엔티티를 사용해야 하는데, Querydsl을 사용할 것이기에 Q엔티티를 생성해야한다. Q엔티티 Class를 생성하는 방법은 이전 게시물에서 찾아볼 수 있다.

2020/12/27 - [Framework/Spring Boot] - [Spring Boot] Gradle의 Querydsl 설정

 

[Spring Boot] Gradle의 Querydsl 설정

//플러그인 추가 plugins { id 'com.ewerk.gradle.plugins.querydsl' version '1.0.10' } //의존성 주입 dependencies { implementation 'com.querydsl:querydsl-jpa' } //Gradle에서 사용할 추가적인 task def qu..

dev-gorany.tistory.com

# Repository 확장

Spring Data JPA의 Repository를 확장하기 위해서는 다음과 같은 단계로 처리된다.

  • 쿼리 메소드나 @Query 등으로 처리할 수 없는 기능은 별도의 인터페이스로 설계
  • 별도의 인터페이스에 대한 구현 클래스를 작성한다. 이 때, QuerydslRepositorySupport라는 클래스를 부모 클래스로 사용한다.
  • 구현 클래스에 인터페이스의 기능을 Q도메인 클래스와 JPQLQuery를 이용해서 구현한다.

QuerydslRepositorySupport 

Querydsl을 이용해 쿼리를 작성하기 위해서는 이 클래스를 상속받아야 하며 super(DslMember.class); 처럼 도메인 엔티티 클래스를 슈퍼타입인 QuerydslRepositorySupport 생성자의 인자로 넘겨주어야 한다.

 

개발자가 커스터마이징 하는 Repository를 작성하는 데 있어서 가장 중요한 클래스는 QuerydslRepositorySupport라는 클래스이다. 이는 Spring Data JPA에 포함된 클래스로 Querydsl 라이브러리를 이용해서 직접 무언가 구현할 때 사용한다.

출처: https://adrenal.tistory.com/25

Repository를 확장시키기 위해 repository 패키지 밑에 search라는 별도의 패키지를 만들어 SearchMovieRepository 인터페이스와 이를 구현한 SearchMovieRepositoryImpl 클래스를 작성한다. (이 때 중요한 점은 구현 클래스의 이름은 반드시 '인터페이스 이름 + Impl'로 작성해야 한다.)

 

구현된 클래스에서 가장 중요한 점은 QuerydslRepositorySupport를 상속해야하고, 생성자가 존재하므로 클래스 내에서 super()를 이용해 호출해야 한다는 점이다. super(Domain.class)에 NULL이 올 수 없다.

package org.gorany.mreview.repository.search;

import lombok.extern.log4j.Log4j2;
import org.gorany.mreview.entity.Movie;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;

@Log4j2
public class SearchMovieRepositoryImpl extends QuerydslRepositorySupport implements SearchMovieRepository {
    
    /**
     * Creates a new {@link QuerydslRepositorySupport} instance for the given domain type.
     *
     * @param domainClass must not be {@literal null}.
     */
    public SearchMovieRepositoryImpl() {
        super(Movie.class);
    }
}

# JPQLQuery의 일반 용법

  • from : 쿼리 소스를 추가한다.
  • innerJoin, join, leftJoin, fullJoin, on : 조인 부분을 추가한다. 조인 메서드에서 첫 번째 인자는 조인 소스이고, 두 번째 인자는 대상(별칭)이다.
  • where : 쿼리 필터를 추가한다. 가변 인자나 AND/OR 메서드를 이용해 필터를 추가한다.
  • groupBy : 가변인자 형식의 인자를 기준으로 그룹을 추가한다.
  • having : Predicate 표현식을 이용해 "group by" 그룹핑의 필터를 추가한다.
  • orderBy : 정렬 표현식을 이용해서 정렬 순서를 지정한다. 숫자나 문자열에 대해서는 asc()나 desc()를 사용하고, OrderSpecifier에 접근하기 위해 다른 비교 표현식을 사용한다.
  • limit, offset, restrict : 결과의 페이징을 설정한다. limit은 최대 결과 개수, offset은 결과의 시작 행, restrict는 limit과 offset을 함께 정의한다.

더 자세한 내용은 querydsl reference을 참고하도록 한다.

www.querydsl.com/static/querydsl/4.0.1/reference/ko-KR/html_single/

 

Querydsl - 레퍼런스 문서

Querydsl은 JPA, JDO, Mongodb 모듈에서 코드 생성을 위해 자바6의 APT 어노테이션 처리 기능을 사용한다. 이 절에서는 코드 생성을 위한 다양한 설정 옵션과 APT에 대한 대안을 설명한다. 기본적으로 Query

www.querydsl.com

 

검색 코드를 작성하기에 앞서 실제 JPQL을 작성하고 실행해보는 것도 좋을 것 같다. 이 과정에서 Querydsl 라이브러리 내에는 JPQLQuery라는 인터페이스를 활용하게 되는데, 참고로 JPQLQuery를 구현한 클래스는 JPAQuery와 HibernateQuery가 있다.

 

* Movie의 PK인 mno = 45인 데이터를 가져와보자.

@Override
public Movie test() {

  log.info("# Test Code !!");

  QMovie qMovie = QMovie.movie;

  JPQLQuery<Movie> jpqlQuery = from(qMovie);
  jpqlQuery.select(qMovie).where(qMovie.mno.eq(45));

  log.info("==============================");
  log.info(jpqlQuery);
  log.info("==============================");

  List<Movie> result = jpqlQuery.fetch();

  return null;
}

 

이를 테스트 실행 코드에서 실행시키면 다음과 같은 로그가 출력된다.

2021-01-12 22:07:29.531 INFO 40568 --- [ Test worker] o.g.m.r.s.SearchMovieRepositoryImpl : # Test Code !!
2021-01-12 22:07:29.578 INFO 40568 --- [ Test worker] o.g.m.r.s.SearchMovieRepositoryImpl : ==============================
2021-01-12 22:07:29.583 INFO 40568 --- [ Test worker] o.g.m.r.s.SearchMovieRepositoryImpl : select movie from Movie movie where movie.mno = ?1
2021-01-12 22:07:29.583 INFO 40568 --- [ Test worker] o.g.m.r.s.SearchMovieRepositoryImpl : ==============================
Hibernate:
select
  movie0_.mno as mno1_1_,
  movie0_.mod_date as mod_date2_1_,
  movie0_.reg_date as reg_date3_1_,
  movie0_.title as title4_1_
from
  movie movie0_
where
  movie0_.mno=?

이로써 JPQLQuery의 사용 또한 잘 동작하는 것을 확인했다.

 

일단 검색을 떠나 목록에 필요한 엔티티는 Movie, Review, MovieImage이므로 조인이 필요하다.

* SQL

SELECT M.*, MI.*, R.*
FROM Movie M
  LEFT (OUTER) JOIN MovieImage MI
    ON MI.Movie_mno = M.mno
  LEFT (OUTER) JOIN Review R
    ON R.Movie_mno = M.mno
GROUP BY M.mno

* JPQLQuery

QMovie movie = QMovie.movie;
QReview review = QReview.review;
QMovieImage image = QMovieImage.movieImage;

JPQLQuery<Movie> jpqlQuery = from(movie);

jpqlQuery.leftJoin(review).on(review.movie.eq(movie));
jpqlQuery.leftJoin(image).on(image.movie.eq(movie));

jpqlQuery.select(movie, review, image, review.count()).groupBy(movie);

Querydsl을 위와 같이 작성하고 테스트 코드로 실행한 후 로그를 보면 다음과 같다.

2021-01-13 00:09:26.362 INFO 4516 --- [ Test worker] o.g.m.r.s.SearchMovieRepositoryImpl :
select movie, review, movieImage, count(review)
from Movie movie
left join Review review
with review.movie = movie
left join MovieImage movieImage
with movieImage.movie = movie
group by movie

의도한 SQL대로 정확히 실행됨을 알 수 있다.

 

SELECT로 가져오는 데이터는 하나의 엔티티가 아닌, 여러 엔티티의 데이터들이 섞인 Object라고 할 수 있는데, 이는 "Tuple"이라는 객체를 통해 추출할 수 있다.

QMovie movie = QMovie.movie;
QReview review = QReview.review;
QMovieImage image = QMovieImage.movieImage;

JPQLQuery<Movie> jpqlQuery = from(movie);
jpqlQuery.leftJoin(review).on(review.movie.eq(movie));
jpqlQuery.leftJoin(image).on(image.movie.eq(movie));

JPQLQuery<Tuple> tuple = jpqlQuery.select(movie, review, image, review.count()).groupBy(movie);

List<Tuple> result = tuple.fetch();

이렇게 만들어진 List<Tuple>에는 어떤 값이 들어있을까? 각 튜플에는 Movie의 데이터, Review의 데이터 MovieImage의 데이터가 담겨있다.

[
Movie(mno=79, title=Movie (79)), //Movie
null, //Review
MovieImage(inum=241, uuid=ef0e234f-177b-4fcc-bc10-48077761de26, imgName=test0.jpg, path=null),
0 //Count(Review)
]

데이터베이스로 부터 데이터를 올바르게 뽑아왔다. 그러나 우리가 필요한 데이터 타입은 JPQLQuery<Tuple>이 아니다. 우리가 필요한 것은 Service 단으로 넘겨줘야할 데이터 타입인 Page<Object[]> 이다. 이 객체는 페이징처리 + 검색처리까지 완료된 결과물을 담게 될 것이다.

 

페이징 처리에 필요한 Pageable과 검색에 필요한 keyword와 type을 인자로 받는 메서드를 정의하자. 이 메서드가 이번 포스팅에서 궁극적으로 만들고자 하는 메서드이다.

@Override
public Page<Object[]> searchMovie(Pageable pageable, String keyword, String type) {

	return null;
}

검색에 필요한 type은 't', 'c', 'w'를 갖는다. 각각 제목, 내용, 작성자를 의미한다. (현재 작성자와 내용은 설정하지 않았지만 추후 다른 엔티티에도 동일하게 적용 가능하므로 그냥 진행한다.) 따라서 t, c, w, tc, tw, cw, tcw를 값으로 갖을 수 있다.

 

public class SearchMovieRepositoryImpl extends QuerydslRepositorySupport implements SearchMovieRepository {

    /**
     * Creates a new {@link QuerydslRepositorySupport} instance for the given domain type.
     *
     * @param domainClass must not be {@literal null}.
     */
    public SearchMovieRepositoryImpl() {
        super(Movie.class);
    }
    
    @Override
    public Page<Object[]> searchMovie(Pageable pageable, String keyword, String type) {

        log.info("@SearchMovieRepository, searchMovie : " + keyword + " // " + type);
        log.info(pageable);

        QMovie movie = QMovie.movie;
        QReview review = QReview.review;
        QMovieImage image = QMovieImage.movieImage;

        JPQLQuery<Movie> jpqlQuery = from(movie);
        jpqlQuery.leftJoin(review).on(review.movie.eq(movie));
        jpqlQuery.leftJoin(image).on(image.movie.eq(movie));

        JPQLQuery<Tuple> tuple = jpqlQuery.select(movie, image, review.grade.avg().coalesce(0.0), review.countDistinct());

        BooleanBuilder booleanBuilder = new BooleanBuilder();
        BooleanExpression exp = movie.mno.gt(0); //movie.mno > 0

        booleanBuilder.and(exp); //AND (movie.mno > 0)
        if(type != null){
            String[] typeArr = type.split("");

            BooleanBuilder searchBuilder = new BooleanBuilder();

            for(String t:typeArr){
                switch (t) {
                    case "t":
                        searchBuilder.or(movie.title.contains(keyword));
                        break;
                    case "w":
                        //searchBuilder.or(movie.writer.contains(keyword));
                        break;
                    case "c":
                        //searchBuilder.or(movie.content.contains(keyword));
                        break;
                }
            }

            booleanBuilder.and(searchBuilder); //Movie.mno > 0 AND (...)
        }

        tuple.where(booleanBuilder);
        tuple.groupBy(movie); //GROUP BY Movie.mno

        List<Tuple> result = tuple.fetch();

        return null;
    }
}

동적으로 검색조건이 처리되는 것은 BooleanBuilder 와 BooleanExpression이 필요하다. 검색 조건을 처리 후에 정렬List<Tuple> ==> Page<Object[]>로 변환하면 마무리된다. 정렬의 경우 보통 "Sort"객체를 이용하는데, JPQL에서는 Sort 객체를 지원하지 않기 때문에 orderBy()의 경우 OrderSpecifier<T> extends Comparable>을 파라미터로 처리해야 한다.

 

Sort를 쓰면 간단한 문제를.. 우회하려니 조금 복잡하므로 정렬 부분만 잘라서 보도록 하자.

//order by
Sort sort = pageable.getSort();

//tuple.orderBy(movie.mno.desc());

sort.stream().forEach(order -> {
	Order direction = order.isAscending()? Order.ASC:Order.DESC;
	String property = order.getProperty();

	PathBuilder orderByExpression = new PathBuilder(Movie.class, "movie");
	tuple.orderBy(new OrderSpecifier(direction, orderByExpression.get(property)));
});

사실 이 예제에서 필요한 것은 mno를 내림차순으로 정렬해달라는 것이다. 그것을 코드로 표현하면 tuple.orderBy(movie.mno.desc());로 끝난다. 하지만 다양한 웹 어플리케이션에서 재사용되게 하려면 이와 같이 작성해두는 것도 나쁘지 않다고 생각한다.

 

1) Pageable로부터 Sort를 가져온다.

2) Sort는 단일 컬럼에만 가능한 것이 아니므로 순회하며 처리한다.

3) 방향이 내림차순인지 오름차순인지 확인한다.

4) property는 컬럼을 가져오는 것이다. (예: order by mno desc 이면 mno를 가져오는 것)

5) PathBuilder를 이용해 Sort객체의 속성 등을 처리한다.

 

여기에 count를 얻는 방법은 fetchCount()를 사용하면 된다. 마지막으로 offset과 limit만 설정해주고 Page<Object[]>로 변환해주면 검색 처리 + 페이징 기능을 한 번에 처리할 수 있는 메서드를 완성하게 된다.

long count = tuple.fetchCount();

tuple.offset(pageable.getOffset());
tuple.limit(pageable.getPageSize());

Page<Object[]> pages = new PageImpl<Object[]>(result.stream()
							.map(t -> t.toArray())
                            .collect(Collectors.toList))
                            , pageable
                            , count);
return pages;

Page<T>는 인터페이스 이므로 이를 구현한 PageImpl<T>객체를 반환하도록 하자. PageImpl 생성자에는 Pageable과 long값을 이용하는 생성자가 존재한다.

 

# Whole Code </>

@Override
public Page<Object[]> searchMovie(Pageable pageable, String keyword, String type) {

  log.info("@SearchMovieRepository, searchMovie : " + keyword + " // " + type);
  log.info(pageable);

  QMovie movie = QMovie.movie;
  QReview review = QReview.review;
  QMovieImage image = QMovieImage.movieImage;

  JPQLQuery<Movie> jpqlQuery = from(movie);
  jpqlQuery.leftJoin(review).on(review.movie.eq(movie));
  jpqlQuery.leftJoin(image).on(image.movie.eq(movie));

  JPQLQuery<Tuple> tuple = jpqlQuery.select(movie, image, review.grade.avg().coalesce(0.0), review.countDistinct());

  BooleanBuilder booleanBuilder = new BooleanBuilder();
  BooleanExpression exp = movie.mno.gt(0); //movie.mno > 0

  booleanBuilder.and(exp); //AND (movie.mno > 0)
  if(type != null){
  	String[] typeArr = type.split("");

  	BooleanBuilder searchBuilder = new BooleanBuilder();

  	for(String t:typeArr){
  		switch (t) {
  		case "t":
 			searchBuilder.or(movie.title.contains(keyword));
  			break;
  		case "w":
  			//searchBuilder.or(movie.writer.contains(keyword));
  			break;
  		case "c":
  			//searchBuilder.or(movie.content.contains(keyword));
  			break;
  		}
  	}

  	booleanBuilder.and(searchBuilder); //Movie.mno > 0 AND (...)
  }

  tuple.where(booleanBuilder);
  tuple.groupBy(movie); //GROUP BY Movie.mno

  //order by
  Sort sort = pageable.getSort();

  //tuple.orderBy(movie.mno.desc());
  sort.stream().forEach(order -> {
  	Order direction = order.isAscending()? Order.ASC:Order.DESC;
  	String property = order.getProperty();

  	PathBuilder orderByExpression = new PathBuilder(Movie.class, "movie");
  	tuple.orderBy(new OrderSpecifier(direction, orderByExpression.get(property)));
  });

  long count = tuple.fetchCount();

  tuple.offset(pageable.getOffset());
  tuple.limit(pageable.getPageSize());

  List<Tuple> result = tuple.fetch();

  Page<Object[]> pages = new PageImpl<>(result.stream()
  							.map(t -> t.toArray())
  							.collect(Collectors.toList())
 							, pageable
  							, count);

  return pages;
}

 

메서드를 완성했으니 테스트 할 일이 남았다. 제목 중 "0"이 들어가는 것들을 검색해보자.

@Test
    public void JPQLTest(){

        PageRequestDTO requestDTO = new PageRequestDTO();
        requestDTO.setKeyword("0");
        requestDTO.setType("t");
        Pageable pageable = requestDTO.getPageable(Sort.by("mno").descending());


        Page<Object[]> result = movieRepository.searchMovie(pageable, requestDTO.getKeyword(), requestDTO.getType());

        result.stream().forEach(objects -> {
            System.out.println(Arrays.toString(objects));
        });
    }

로그를 보면 10개가 출력되고, 제목에 "0"이 들어간 것만 나오는 것을 알 수 있다.

 

 

<참고>

Spring Data JPA, Querydsl 등에 대한 참고할 만한 곳 : ict-nroo.tistory.com/117

코드로 배우는 스프링 부트 웹 프로젝트

adrenal.tistory.com/25

velog.io/@leyuri/JPA-Querydsl%EB%9E%80

cornswrold.tistory.com/332

donnaknew.tistory.com/3

반응형
Comments