개요
프로젝트를 진행하면서, 여러 조건에 따라 선택적으로 게시글을 조회하는 요구사항이 생겼습니다. 쉽게 말해 검색 기능을 구현해야했던 것입니다. 요구사항에서 추출한 검색 조건 및 필터는 다음과 같습니다.
- 제목 (포함)
- 태그 (포함)
- 카테고리 (정확히 일치)
- 게시글 상태 (정확히 일치)
- N일전 게시글
(포함) 조건은 주어진 텍스트가 값에 포함되어있다면 조회하고, (정확히 일치) 조건은 말 그대로 값이 정확히 일치해야 조회한다는 뜻입니다.
위 요구사항대로 검색조건을 구현하기 위해선, SQL의 WHERE 조건절에 'LIKE'나 '=' 연산자를 통해 주어진 조건에 일치하는 데이터만 조회해야합니다.
앞선 검색 조건들이 항상 모두 적용되지는 않고, 보통 일부 조건만 사용하기 때문에 순수 SQL문을 사용한다면 검색 조건에 따라 사용되는 쿼리를 전부 다르게 해주어야한다는 문제점이 생깁니다.
-- 제목, 카테고리 조건으로 검색시
WHERE `title` LIKE '%조건%' AND `category`='조건'
-- 제목, 상태, 태그 조건으로 검색시
WHERE `title` LIKE '%조건%' AND `state`='조건' AND `tag` LIKE '%조건%'
조건에 따라 매번 다른 쿼리를 작성하고 조합해야하는 것은 상당히 번거로운 일입니다. QueryDsl에서는 이러한 동적 쿼리를 아주 쉽고 간결하게 작성할 수 있습니다.
검색 조건 쿼리
Querydsl에서는 Predicate 구현체를 넘겨주는 것으로 where절을 작성할 수 있습니다.
이 때 Predicate 구현체로 BooleanExpression 객체를 넘겨주면 됩니다.
BooleanExpression에는 다음과 같이 JPQL이 제공하는 모든 검색 조건을 제공합니다.
member.username.eq("member1") // username = 'member1'
member.username.ne("member1") //username != 'member1'
member.username.contains("member1") // username like '%member1%'
member.username.isNotNull() // username is not null
member.age.in(10, 20) // age in (10,20)
member.age.notIn(10, 20) // age not in (10, 20)
member.age.between(10,30) // between 10, 30
member.age.goe(30) // age >= 30member.age.gt(30) // age > 30
member.age.loe(30) // age <= 30
member.age.lt(30) // age < 30
member.username.like("member%") // like 검색
member.username.contains("member") // like ‘%member%’ 검색
member.username.startsWith("member") // like ‘member%’ 검색
...
QueryDsl의 where절
Querydsl의 where절은 null값을 무시하는 특성이 있습니다.
따라서, 검색에 필요한 조건만 Predicate를 구현한 구현체를 넘겨주고 검색에 사용되지 않는 조건은 null을 넘겨주면 됩니다.
실제 쿼리는 null을 제외한 조건이 WHERE에 붙게됩니다.
private List<Post> search(final String title) {
return queryFactory
.selectfrom(post)
// where절에 title like 조건만 포함됨
.where(null, recruitment.title.contains(title))
.fetch();
}
이 때 주의해야할 사항은 다음과 같습니다.
where에는 null 값을 넘겨주어도 되지만, BooleanExpression에 null값을 넣을 경우 IllegalArgumentException이 발생합니다.
📌 recruitment.title.contains(null) -> IllegalArgumentException 예외 발생!!
위 주의사항을 반영하여 주어진 검색 값이 없을 경우 null을 반환하는 메서드를 작성하였습니다.
// where 조건
private BooleanExpression titleLike(String title) {
return StringUtils.hasText(title) ? post.title.contains(title) : null;
}
// query
private List<Post> search(final String title) {
return queryFactory
.selectfrom(post)
.where(titleLike(title))
.fetch();
}
- StringUtils의 hasText()는 내부적으로 != null / !isEmpty / !isBlank를 실행하여 텍스트가 존재하는지 판단하고 boolean 값을 리턴합니다.
- 따라서 titleLike 는 매개변수로 넘겨받은 title 텍스트가 존재할 경우 조건절을 반환하고 아니라면 null을 반환합니다.
조건 여러개 추가하기
where에 여러 조건을 넘겨주는 방법은 크게 2가지가 있습니다.
- BooleanBuilder를 생성하여 and()에 BooleanExpression을 추가하는 방법
- 여러개의 BooleanExpression을 파라미터로 넘기는 방법
저는 1번의 방법을 사용하였습니다. 검색에 사용되는 조건이 많았기 때문에 2번의 방법을 사용하게 될 경우 쿼리가 길어질 것이라 생각했기 때문입니다. 하지만 조건문을 한 눈에 확인할 수 있다는 장점도 있기 때문에 이 부분은 성향에 따라 선택하시면 될 것 같습니다.
최종 코드
// query
private List<Post> search(final Map<String, String> searchCondition) {
return queryFactory
.selectfrom(post)
.where(allCond(searchCondition))
.fetch();
}
// BooleanBuilder
private BooleanBuilder allCond(final Map<String, String> searchCondition) {
BooleanBuilder builder = new BooleanBuilder();
return builder
.and(titleLike(searchCondition.getOrDefault(TITLE.getParamKey(), null)))
.and(categoryEq(searchCondition.getOrDefault(CATEGORY.getParamKey(), null)))
.and(tagLike(searchCondition.getOrDefault(TAG.getParamKey(), null)))
.and(stateEq(searchCondition.getOrDefault(STATE_CODE.getParamKey(), null)))
.and(withInDays(searchCondition.getOrDefault(DAYS_AGO.getParamKey(), null)));
}
// 조건1
private BooleanExpression titleLike(final String title) {
return StringUtils.hasText(title) ? post.title.contains(title) : null;
}
// 조건2
private BooleanExpression categoryEq(final String category) {
if (!StringUtils.hasText(category)) return null;
Category instance = Category.getInstance(category);
return post.category.eq(instance);
}
// 조건3
private BooleanExpression titleLike(final String tag) {
return StringUtils.hasText(tag) ? post.tag.contains(tag) : null;
}
// 조건4
private BooleanExpression stateEq(final String stateCode) {
if (!StringUtils.hasText(stateCode)) return null;
Integer code = convertInteger(stateCode);
RecruitStatus instance = RecruitStatus.getInstance(code);
return post.status.eq(instance);
}
// 조건5
private BooleanExpression withInDays(final String daysAgo) {
if (!StringUtils.hasText(daysAgo)) return null;
Long days = convertLong(daysAgo);
LocalDate now = LocalDate.now();
LocalDate startDate = now.minusDays(days);
LocalDateTime startDateTime = LocalDateTime.of(startDate, LocalTime.MIN);
return post.createdDate.goe(startDateTime);
}
- 검색에 사용되는 값들을 쿼리 파라미터로 넘겨주게 되는데, 컨트롤러에서 map 자료형에 해당 값들을 저장하여 넘겨주었습니다.
- Key 값은 SearchParam이라는 Enum 객체에 상수로 저장했습니다.
- @RequestParam Map<String, String> searchCondition
- map의 getOrDefault 메서드로 쿼리 파라미터로 값을 넘겨받지 못했다면 null을 넘겨주어 where절에 조건이 포함되지 않도록 하였습니다.
테스트
@DisplayName("제목과 카테고리로 여러 게시글을 검색한다")
@Test
void searchAll_Title() {
//given
List<Post> posts = new ArrayList<>();
for (int i = 0; i <= 10; i++) {
posts.add(new Post("게시글 " + i));
}
for (int i = 0; i <= 10; i++) {
posts.add(new Post("토익 " + i));
}
postRepository.saveAll(posts);
Map<String, String> searchCondition = new ConcurrentHashMap<>();
searchCondition.put(TITLE.getParamKey(), "토익");
searchCondition.put(CATEGORY.getParamKey(), "어학");
//when
List<Post> posts = searchRepository.search(searchCondition);
//then
assertAll(
() -> assertThat(posts.size()).isEqualTo(10),
() -> assertThat(posts.get(0).getContent()).startsWith("토익")
);
}
- where절에 2개의 조건만 삽입된 것을 확인할 수 있습니다.
- 실제 구현한 쿼리는 다르기 때문에 where을 제외한 다른 연산자는 무시하시면 됩니다.