[코드로 배우는 스프링부트 웹 프로젝트] - 연관관계(6) : Querydsl 설정 및 검색 기능, 페이지 처리

Jongwon·2023년 1월 9일
0


이제 페이지는 모두 완성했습니다. 하지만 한가지 빠진 부분이 있는데, 바로 키워드 검색입니다. JPQL을 이용한 검색은 Querydsl을 이용하여 할 것이므로 이전 guestbook 예제에서 build.gradle 설정을 가져옵니다.

build.gradle

...추가
	implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
	annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
	annotationProcessor "jakarta.annotation:jakarta.annotation-api"
	annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

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

def generated = 'src/main/generated'

// querydsl QClass 파일 생성 위치를 지정
tasks.withType(JavaCompile) {
	options.getGeneratedSourceOutputDirectory().set(file(generated))
}

// java source set 에 querydsl QClass 위치 추가
sourceSets {
	main.java.srcDirs += [ generated ]
}

// gradle clean 시에 QClass 디렉토리 삭제
clean {
	delete file(generated)
}

clean 후 다시 build시에 insert 테스트가 다시 실행되어 이미 저장되어 있던 데이터와 충돌하는 경우가 발생합니다. 또한 test의 순서가 정해지지 않아 연관관계가 설정되어 있는 엔티티를 먼저 생성하려고 하면 에러가 발생합니다.
저는 일단 gradle build clean -> DB데이터 삭제 -> Member, Board, Reply순으로 insert test 실행 -> insert test 주석처리 -> gradle build로 진행하였습니다.

build가 성공적으로 완료되면 generated 폴더에 Q엔티티가 생성됩니다.



쿼리 메서드나 @Query 어노테이션으로 처리가 불가능한 질의문의 경우에는 별도의 인터페이스를 설계하여 구현하는 것이 좋습니다. 아래와 같이 SearchBoardRepository와 Impl을 생성합니다.

SearchBoardRepository

package org.zerock.board.repository.search;

import org.zerock.board.entity.Board;

public interface SearchBoardRepository {

    Board search1();
}

SearchBoardRepositoryImpl

package org.zerock.board.repository.search;

import lombok.extern.log4j.Log4j2;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import org.zerock.board.entity.Board;

@Log4j2
public class SearchBoardRepositoryImpl extends QuerydslRepositorySupport implements SearchBoardRepository {

    public SearchBoardRepositoryImpl() {
        super(Board.class);
    }

    @Override
    public Board search1() {
        log.info("search1.....................");

        return null;
    }
}

QuerydslRepositorySupport는 Spring Data JPA안에 있는 라이브러리로, Querydsl을 이용하여 직접 레포지토리를 구현할 때 사용합니다.
해당 라이브러리 내부에는 생성자가 선언되어 있기 때문에 super로 상속받습니다.

이제 BoardRepository가 SearchBoard를 상속하도록 BoardRepository를 수정합니다.

public interface BoardRepository extends JpaRepository<Board, Long>, SearchBoardRepository {



테스트 코드에 테스트를 작성한 후 실행해 보겠습니다.

BoardRepositoryTests

    @Test
    public void testSearch1() {
        boardRepository.search1();
    }

를 실행하여 로그가 찍히는지 확인합니다.






정상적으로 실행된다면 본격적으로 JPQL쿼리를 생성하겠습니다. 앞서만든 search1()을 아래의 코드로 수정합니다.

	@Override
    public Board search1() {
        log.info("search1.....................");

        QBoard board = QBoard.board;

        JPQLQuery<Board> jpqlQuery = from(board);

        jpqlQuery.select(board).where(board.bno.eq(1L));

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

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

        return null;
    }

JPQLQuery는 JPQL방식의 쿼리문을 위한 인터페이스, 즉 앞에서 사용했던 변수를 이용한 쿼리문을 의미합니다. 위의 JPQLQuery를 일반 쿼리문으로 변경해보면
select * from board where board.bno = 1L과 같습니다.

테스트 코드를 실행해보면 로그를 통해 확인할 수 있습니다.

JPQLQuery를 이용하면 Join연산도 쉽게 할 수 있습니다.

		jpqlQuery.leftJoin(member).on(board.writer.eq(member));
        jpqlQuery.leftJoin(reply).on(reply.board.eq(board));

Board를 기준으로 member와 left join하고, reply와 left join 함으로써 Board 레코드 전체와, 각 레코드마다 일치하는 writer, 일치하는 reply를 join합니다.


JPQL select문에서 여러 엔티티의 애트리뷰트를 가져오고 싶다면 JPQLQuery타입을 Tuple로 지정해주어야 합니다.

JPQLQuery<Tuple> tuple = jpqlQuery.select(board, member.email, reply.count());
tuple.groupBy(board);

그리고 이 쿼리문을 groupBy로 묶을 수 있습니다.

전체적인 코드는 아래와 같습니다.

package org.zerock.board.repository.search;

import com.querydsl.core.Tuple;
import com.querydsl.jpa.JPQLQuery;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import org.zerock.board.entity.Board;
import org.zerock.board.entity.QBoard;
import org.zerock.board.entity.QMember;
import org.zerock.board.entity.QReply;

import java.util.List;

@Log4j2
public class SearchBoardRepositoryImpl extends QuerydslRepositorySupport implements SearchBoardRepository {

    public SearchBoardRepositoryImpl() {
        super(Board.class);
    }

    @Override
    public Board search1() {
        log.info("search1.....................");

        QMember member = QMember.member;
        QBoard board = QBoard.board;
        QReply reply = QReply.reply;

        JPQLQuery<Board> jpqlQuery = from(board);

        jpqlQuery.leftJoin(member).on(board.writer.eq(member));
        jpqlQuery.leftJoin(reply).on(reply.board.eq(board));

        JPQLQuery<Tuple> tuple = jpqlQuery.select(board, member.email, reply.count());
        tuple.groupBy(board);

        log.info("-------------------");
        log.info(tuple);
        log.info("-------------------");

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

        log.info(result);

        return null;
    }
}






이제는 JPQLQuery를 Page로 변환하여 전달하면 됩니다. SearchBoardRepository에 아래와 같이 추가합니다.

Page<Object[]> searchPage(String type, String keyword, Pageable pageable);

그리고 구현은 guestbook과 바로 위의 search()에서 했던 방식을 응용하여 작성하겠습니다.

SearchBoardServiceImpl

    @Override
    public Page<Object[]> searchPage(String type, String keyword, Pageable pageable) {
        log.info("searchPage......................");

        QBoard board = QBoard.board;
        QMember member = QMember.member;
        QReply reply = QReply.reply;

        JPQLQuery<Board> jpqlQuery = from(board);

        jpqlQuery.leftJoin(member).on(board.writer.eq(member));
        jpqlQuery.leftJoin(reply).on(reply.board.eq(board));

        JPQLQuery<Tuple> tuple = jpqlQuery.select(board, member, reply.count());

        BooleanBuilder booleanBuilder = new BooleanBuilder();
        BooleanExpression expression = board.bno.gt(0L);

        booleanBuilder.and(expression);

        if(type != null) {
            String[] typeArr = type.split("");

            BooleanBuilder conditionBuilder = new BooleanBuilder();

            for(String t : typeArr) {
                switch (t){
                    case "t": conditionBuilder.or(board.title.contains(keyword));
                    break;

                    case "w": conditionBuilder.or(member.email.contains(keyword));
                    break;

                    case "c": conditionBuilder.or(board.content.contains(keyword));
                    break;
                }
            }

            booleanBuilder.and(conditionBuilder);
        }

        tuple.where(booleanBuilder);

        tuple.groupBy(board);

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

        log.info(result);

        return null;
    }

아래의 테스트를 실행하여 정상적으로 수행되는지 확인합니다.

BoardServiceTests

    @Test
    public void testSearchPage() {
        Pageable pageable = PageRequest.of(0, 10, Sort.by("bno").descending());

        Page<Object[]> result = boardRepository.searchPage("t", "1", pageable);
    }

결과로 나온 쿼리문을 보면 join이 정상적으로 되고있고, where에서 title을 keyword로 주었기 때문에 title로 조건을 설정한 모습을 확인할 수 있습니다.



검색은 정상적으로 수행되었고, 이제는 페이지 처리와 정렬을 진행해야 합니다.

Pageable.sort() 는 Sort 객체인데, JPQLQuery의 orderBy()는 Sort 객체를 지원하지 않기 때문에, OrderSpecifier.orderBy를 사용해야 합니다.

앞의 구현에서 아래와 같이 수정합니다.
SearchBoardRepositoryImpl

    @Override
    public Page<Object[]> searchPage(String type, String keyword, Pageable pageable) {
        log.info("searchPage......................");

        QBoard board = QBoard.board;
        QMember member = QMember.member;
        QReply reply = QReply.reply;

        JPQLQuery<Board> jpqlQuery = from(board);

        jpqlQuery.leftJoin(member).on(board.writer.eq(member));
        jpqlQuery.leftJoin(reply).on(reply.board.eq(board));

        JPQLQuery<Tuple> tuple = jpqlQuery.select(board, member, reply.count());

        BooleanBuilder booleanBuilder = new BooleanBuilder();
        BooleanExpression expression = board.bno.gt(0L);

        booleanBuilder.and(expression);

        if(type != null) {
            String[] typeArr = type.split("");

            BooleanBuilder conditionBuilder = new BooleanBuilder();

            for(String t : typeArr) {
                switch (t){
                    case "t": conditionBuilder.or(board.title.contains(keyword));
                    break;

                    case "w": conditionBuilder.or(member.email.contains(keyword));
                    break;

                    case "c": conditionBuilder.or(board.content.contains(keyword));
                    break;
                }
            }

            booleanBuilder.and(conditionBuilder);
        }

        tuple.where(booleanBuilder);

        for(Sort.Order order: pageable.getSort()) {
            //sorting의 방향
            Order direction = order.isAscending() ? Order.ASC : Order.DESC;
            //sorting의 기준
            String prop = order.getProperty();

            //prop이 담긴 엔티티 경로를 알려줌
            PathBuilder<Board> orderByExpression = new PathBuilder<>(Board.class, "board");
            tuple.orderBy(new OrderSpecifier(direction, orderByExpression.get(prop)));

        };

        tuple.groupBy(board);

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

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

        log.info(result);

        //총 개수를 바로 뽑을 수 있음
        long count = tuple.fetchCount();

        log.info("COUNT: " + count);

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


    }

꽤나 복잡한 코드입니다. 페이지 처리를 하고, 정렬도 하게 됩니다.


테스트 코드에서 정렬 기준을 아래와 같이 2개를 준다면

    @Test
    public void testSearchPage() {
        Pageable pageable = PageRequest.of(0, 10, Sort.by("bno").descending()
        	.and(Sort.by("title").ascending()));

        Page<Object[]> result = boardRepository.searchPage("t", "1", pageable);
    }

Order By에서 2가지 정렬을 모두 진행하는 것을 확인할 수 있습니다.






이제 마지막으로 Service처리만 하면 끝입니다. 뷰와 컨트롤러는 이미 guestbook에서 만든 것을 가져왔기 때문에 추가로 변경할 필요가 없습니다.

기존의 getList()를 아래와 같이 변경합니다.

BoardServiceImpl

    @Override
    public PageResultDTO<BoardDTO, Object[]> getList(PageRequestDTO pageRequestDTO) {
        log.info(pageRequestDTO);

        Function<Object[], BoardDTO> fn = (en -> entityToDTO((Board)en[0], (Member)en[1], (Long)en[2]));

        Page<Object[]> result = repository.searchPage(
                pageRequestDTO.getType(),
                pageRequestDTO.getKeyword(),
                pageRequestDTO.getPageable(Sort.by("bno").descending())
        );

        return new PageResultDTO<>(result, fn);
    }
profile
Backend Engineer

0개의 댓글