본문 바로가기

스프링

[Querydsl] Querydsl 기본적인 사용 및 게시물 동적 검색 구현

Querydsl란

오늘은 Querydsl에 대한 포스트를 다뤄보려고한다.

우리는 이전에 Spring Data JPA를 활용하여 다양한 부분에 대한 쿼리를 다뤘다.

특히, 강력한 @Query의 기능으로 기존의 JPA를 더 효율적으로 다룰 수 있었다.

하지만 해당 부분으로 모든 부분의 조회 기능을 사용하기에는 한계가 있다.

사용해본 사람들은 알겠지만, 동적인 쿼리를 다루는 부분에 대해서 어려움을 느낄 것이다.

예를 들자면 어떤 게시물을 조회하는데 검색 조건이 달라지는 등의 부분 등을 살펴볼 수 있을 것이다.

그래서 사용하게 된 것이 바로 Querydsl이라는 프레임워크이다.

이것은 HQL(Hibernate Query Language) 쿼리를 타입에 안전하게 생성 및 관리할 수 있게 해주는 프레임워크이다.

이는 자바 코드를 기반으로 쿼리를 작성한다.

이제, 실제로 Querydsl에 대해서 살펴보기 위해 간단한 예를 통해 이를 진행해보려고한다.

Querydsl 사용

개발 환경

개발을 진행한 환경은 다음과 같다.

  • Spring Boot : 2.5.6
  • Gradle : 7.2
  • Querydsl : 4.4.0

우리는 간단한 예시를 통해 Querydsl을 활용해보려고한다.

주어진 상황은 다음과 같다.

엔티티

게시물은 작성자, 게시글의 제목, 게시글의 내용을 포함하고있다.

우리는 동적인 부분을 다뤄보기 위해 게시물의 검색을 Querydsl로 진행해보려고한다.

우리가 검색을 진행할때는 보통 제목으로하거나, 작성자로 하거나, 제목+내용으로 진행한다.

이 부분에 대해서 살펴보도록 하자.

다음은 실제로 사용한 Board 엔티티에 대한 부분이다.

@Entity
@Getter
@Setter
@SequenceGenerator(
        name="BOARD_SEQ_GENERATOR",
        sequenceName = "BOARD_SEQ"
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "BOARD_SEQ_GENERATOR")
    @Column(name = "board_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "student_id")
    private Student writer;

    @OneToMany(mappedBy = "board", cascade = CascadeType.ALL)
    private List<Attachment> attachedFiles = new ArrayList<>();

    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime writeTime;
    private Boolean isDeleted;
    private String title;
    private String content;
    private Integer hit;

    @Builder
    public Board(Student writer, List<Attachment> attachedFiles, LocalDateTime writeTime, Boolean isDeleted, String title, String content, Integer hit) {
        this.writer = writer;
        this.attachedFiles = attachedFiles;
        this.writeTime = writeTime;
        this.isDeleted = isDeleted;
        this.title = title;
        this.content = content;
        this.hit = hit;
    }

    public void setAttachment(Attachment attachment) {
        this.attachedFiles.add(attachment);
        attachment.setBoard(this);
    }
}

검색 조건 클래스

나는 위에서 언급한 3가지 부분에 대한 검색을 진행하기 위해 검색조건을 저장하는 클래스를 하나 만들었다.

content는 검색할 내용에 대해 저장하는 부분을 저장할 수 있는 변수이며, type은 검색조건에 대해 저장하는 부분이다.

검색조건은 Enum 클래스로 제목(TIT), 작성자(STUD), 제목+내용(TITCONT) 세 가지로 구분하여 구현하였다.

@Data
public class SearchCondition {
    String content;
    SearchType type;

    public SearchCondition(String content, SearchType type) {
        this.content = content;
        this.type = type;
    }
}
public enum SearchType {
    TIT, STUD, TITCONT
}

Q클래스 생성

Querydsl은 컴파일 단계에서 엔티티를 기반으로 Q클래스 파일들을 생성시킨다.

이 파일들을 기준으로 쿼리를 작성하기 때문에 이를 만들어보자.

이를 위해 다음과 같이 진행하면 된다.

해당 빨간색 부분을 실행하면 다음과 같이 자동으로 엔티티들에 대해 Q클래스를 만들어준다.

다음과 같이 잘 생성된 모습을 확인할 수 있다.

Repository 정의

이제, 실제로 쿼리를 날리기 위해 Querydsl을 사용해보자.

현재 Spring Data JPA를 사용하고 있어 Custom한 쿼리를 생성하기 위해 다음과 같은 설정을 진행해야한다.

먼저, BoardCustomRepository(Board에 대해 Custom한 Repository) 인터페이스를 만들어준다.

그 후, 이 인터페이스에 대한 구현체(BoardRepositoryImpl)로 해당 부분을 구현하면된다.

public interface BoardCustomRepository {
    public List<Board> search(SearchCondition searchCondition);
}
public class BoardRepositoryImpl implements BoardCustomRepository{
     ...
}

이제 실제로 작성해보자.

public class BoardRepositoryImpl implements BoardCustomRepository{

    public final JPAQueryFactory queryFactory; // JPAQueryFactory 빈 주입

    public BoardCustomRepositoryImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    @Override
    public List<Board> search(SearchCondition condition) { // 검색 쿼리
        return queryFactory
                .selectFrom(board)
                .where(isSearchable(condition.type, condition.content))
                .orderBy(board.writeTime.desc())
                .fetch();
    }

    ...
}

우리는 실제로 Querydsl을 통해 쿼리를 다루기 위해 JPAQueryFactory을 주입받는다.

그리고 우리는 JPAQueryFactory를 이용하여 쿼리를 작성한다.

위에서 볼 수 있듯이, 자바 코드를 기반으로 쿼리를 작성하는 것을 확인할 수 있는데 확인할 테이블을 Board로 설정하고 where로 조건문을 거는 모습을 볼 수 있다.

이제 실제로 조건문에 어떤식으로 적용했는지 살펴보자.

다음 부분은 BooleanBuilderSafe하게 만들기 위해 만든 메소드이다.

BooleanBuilder의 값으로 null이 오면 Exception이 발생하기 때문에 해당 부분을 try-catch로 감싸 처리해주었다.

BooleanBuilder nullSafeBuilder(Supplier<BooleanExpression> f) {
    try {
        return new BooleanBuilder(f.get());
    } catch (Exception e) {
        return new BooleanBuilder();
    }
}

이제 실제로 조건문을 걸어보자.

게시물 제목에 해당 내용을 포함하는 경우, 작성자 닉네임이 같은 경우, 게시물 제목 혹은 내용에 해당 내용을 포함하는 경우 3가지에 대해 작성하였다.

다음은 eq(Equal), contains(contain)을 적극 활용하여 구성하였다.

// [static import] QBoard.board  -> board

BooleanBuilder userEq(String content) {
    return nullSafeBuilder(() -> board.writer.nickname.eq(content)); 
}
BooleanBuilder titleCt(String content) {
    return nullSafeBuilder(() -> board.title.contains(content));
}
BooleanBuilder contentCt(String content) {
    return nullSafeBuilder(() -> board.content.contains(content));
}

이제 생성된 BooleanBuilder를 통해 조건을 만들어보자.

이전에 만들어둔 SearchType을 기준으로 구분하여 위에 만든 쿼리를 실행하면 된다.

BooleanBuilder isSearchable(SearchType sType, String content) {
    if (sType == SearchType.TIT) {
        return titleCt(content);
    }
    else if(sType == SearchType.STUD) {
        return userEq(content);
    }
    else {
        return titleCt(content).or(contentCt(content));
    }
}

Service 정의

이제 Repository에서 구현한 메소드를 기반으로 Service 로직을 처리해보자.

BoardRepository를 주입받고 해당 쿼리를 실행하면 된다.

public List<Board> findBoard(SearchCondition searchCondition) {
    return boardRepository.search(searchCondition);
}

테스트 진행

이제 실제로 잘 구동이 되는지 테스트해보자.

두 명의 학생이 가입을 한 상태이고 각각의 학생은 게시글 하나씩 작성하였다.

제목, 작성자 이름, 제목+내용을 기반으로 테스트를 진행해보았다.

@Test
public void 검색() throws Exception {
    //given
    Student student1 = new Student("testID1@gmail.com", "testPW1", "테스터A", "테스터A", "컴공", "백엔드");
    Student studentA = studentService.join(student1);

    Student student2 = new Student("testID2@gmail.com", "testPW2", "테스터B", "테스터B", "컴공", "백엔드");
    Student studentB = studentService.join(student2);

    BoardAddForm boardAddForm1 = new BoardAddForm("반가워요~ 하이요!", "방가방가!", null, null);
    BoardPostDto boardPostDto1 = boardAddForm1.createBoardPostDto(studentA);
    boardService.post(boardPostDto1);

    BoardAddForm boardAddForm2 = new BoardAddForm("방가! 다시왔어요!", "오랜만이네요 :(", null, null);
    BoardPostDto boardPostDto2 = boardAddForm2.createBoardPostDto(studentB);
    boardService.post(boardPostDto2);


    //when
    List<Board> titleBasedSearch = boardService.findBoard(new SearchCondition("반가", SearchType.TIT));
    List<Board> nicknameBasedSearch = boardService.findBoard(new SearchCondition("테스터B", SearchType.STUD));
    List<Board> titleAndContentBasedSearch = boardService.findBoard(new SearchCondition("방가", SearchType.TITCONT));


    //then
    assertThat(titleBasedSearch).extracting("title").containsExactly("반가워요~ 하이요!");
    assertThat(nicknameBasedSearch).extracting("title").containsExactly("방가! 다시왔어요!");
    assertThat(titleAndContentBasedSearch).extracting("title").containsExactlyInAnyOrder("반가워요~ 하이요!", "방가! 다시왔어요!");
}

다음과 같이 통과한 모습을 확인할 수 있었다.

Controller 정의

실제로 컨트롤러에서 어떻게 Service를 호출하는지 보자.

우리는 페이지로부터 검색 조건(searchtype)검색 내용(keyword)를 입력받는다.

그리고 검색 조건을 입력받았는지 확인 후 처리해주고 없다면 기본적인 출력을 진행해주면 된다.

@GetMapping
public String showBoard(@Login LoginForm loginForm, SearchType searchType, String keyword, Model model) {
    Map<SearchType, String> searchTypes = getSearchTypesMap();
    model.addAttribute("searchTypes", searchTypes);
    model.addAttribute("keyword", keyword);
    model.addAttribute("student", studentService.findStudent(loginForm.getEmail()).get());

    if (StringUtils.hasText(keyword)) {
        model.addAttribute("boards", boardService.findBoard(new SearchCondition(keyword, searchType)));
    } else {
        model.addAttribute("boards", boardService.findBoards(Sort.by(Sort.Direction.DESC, "writeTime")));
    }
    return "board";
}

Thymeleaf

실제로 페이지에서 넘기는 부분을 살펴보자.

먼저, 검색 조건을 입력받기 위해 Select Box로 입력을 받고 검색 조건은 Input으로 입력받는다.

그리고 Form - Submit으로 전송하여 컨트롤러에 전송하게 된다.

<form class="form-group" th:method="get" th:action>
    <select id="searchType" name="searchType">
        <option th:each="sType : ${searchTypes}" th:text="${sType.value}" th:value="${sType.key}"></option>
    </select>
    <div class="search-bar">
        <input class="form-control" type="text" placeholder="제목으로 검색하기" id="keyword" name="keyword" th:value="${keyword}">
    </div>
</form>

결과

실제로 사용해보자.

다음과 같이 게시물이 작성되어있을 때 검색을 하면 다음과 같이 잘 나오는 모습을 확인할 수 있다.

제목 검색

작성자 검색

제목+내용 검색