BackEnd/쿠링

[쿠링] QueryDsl을 활용한 키워드 검색 쿼리 구현

샤아이인 2023. 3. 7.

개인적으로 작업하고 있는 프로젝트에서 검색쿼리를 리팩토링 해야 하는 상황이 발생하였다.

 

우선 이전 방식의 코드를 살펴본 후, 이를 리팩토링 해 나가는 과정을 남겨보자!

 

1. 리팩토링 전의 코드

우선 keywords를 전달받아 처리하는 이전의 코드는 다음과 같다.

 

▶ NoticeServce.handleSearchRequest()

public List<Notice> handleSearchRequest(String keywords) {

    keywords = keywords.trim();
    String[] splitedKeywords = keywords.split("[\\s+]");

    // 키워드 중 공지 카테고리가 있다면, 이를 영문으로 변환
    for (int i = 0; i < splitedKeywords.length; ++i) {
        for (CategoryName categoryName : categoryNames) {
            if (splitedKeywords[i].equals(categoryName.getKorName())) {
                splitedKeywords[i] = categoryName.getName();
                break;
            }
        }
    }

    return getNoticesBySubjectOrCategory(splitedKeywords);
}

1) 전달받은 keywords를 공백 기준으로 split한다.

2) 해당 Keywords 중 categoryName과 일치하는 값은 영문으로 변환한다.

3) 변환된 keywords를 getNoticesBySubjectOrCategory()

 

▶ NoticeServce.getNoticesBySubjectOrCategory()

private List<Notice> getNoticesBySubjectOrCategory(String[] keywords) {

    List<Notice> notices = noticeRepository.findBySubjectContainingOrCategoryNameContaining(keywords[0], keywords[0]);
    Iterator<Notice> iterator = notices.iterator();

    for (int i = 1; i < keywords.length; ++i) {
        while (iterator.hasNext()) {
            Notice notice = iterator.next();
            String curKeyword = keywords[i];

            if (notice.getSubject().contains(curKeyword) || notice.getCategory().getName().contains(curKeyword)) {

            } else {
                iterator.remove();
            }
        }
    }

    // 날짜 내림차순 정렬
    notices.sort(ObjectComparator.NoticeDateComparator);

    return notices;
}

getNoticesBySubjectOrCategory 에서는 받은 keyword들로 Spring Data JPA의 쿼리 메서드를 사용하여 조회해오고 있는 방식이다!

 

다만 이전방식의 쿼리 메서드를 보면 2가지 의문점이 들었다!

 

1) Spring Data Jpa의 쿼리 메서드의 이름이 너무 길어 의미가 불명확 하다

사실 길이가 긴 거는 문제가 되지는 않는다. 의미만 명확하다면 변수명은 충분하게 길어도 된다.

 

사실 위 변수를 보고 처음 든 생각은 "아하! Subject나 Category가 포함되 있는 녀석을 찾아오면 되는구나!" 였다.

즉, 해당 메서드는 subject나 categoryName 을 전달해줘야 찾아오겠다는 의미로 보인다.

 

하지만, 우리의 메서드는 어떠한 키워드를 전달해도 키워드 전부를 사용하여 찾아올 수 있어야 한다.

 

이게 잘 생각해야 하는데, 일치 여부를 판단할때 당연히 notice의 subject(제목)과 categoryName을 사용할 것 이다.

다만 그 매칭대는 대상을 찾기 위해서 사용할 키워드 들이 사용자의 모든 입력과 매칭해봐야 한다는 의미이다.

 

좀 더 단순하게 findAllByKeywords정도면 의미도 명확하며, 더 간단하다 생각된다.

 

2) keyword를 단 2개만 전달하고 있다?

이부분이 조금 아쉬운 부분이라 생각된다?

만약 "학교 학식 공지 날짜" 라고 전달했다면 keyword가 총 4개가 된다.

"학교" 
"학식" 
"공지" 
"날짜"

 

위와 같이 키워드가 4개라면 검색하는데, 이전의 코드는 "학교" 과 "학식" 2개만 사용하고 있었다.

 

아마도? 이 부분은 직전에 언급한 1번의 네이밍의 어색함으로 인하여 인자를 2개만 전달해 버리는 휴먼 에러가 발생해 버린 것 같다?

 

2. 리팩토링 적용하기

우선 기존의 handleSearchRequest가 다음 메서드로 변경되었다.

 

▶ NoticeServce.findAllNoticesByContent()

public List<NoticeSearchDto> findAllNoticeByContent(String content) {
    String[] splitedKeywords = splitBySpace(content);

    List<String> keywords = noticeCategoryNameConvertEnglish(splitedKeywords);

    return noticeRepository.findAllByKeywords(keywords);
}

private static String[] splitBySpace(String content) {
    return content.trim().split(SPACE_REGEX);
}

private List<String> noticeCategoryNameConvertEnglish(String[] splitedKeywords) {
    return Arrays.stream(splitedKeywords)
            .map(this::convertEnglish)
            .collect(Collectors.toList());
}

private String convertEnglish(String keyword) {
    for (CategoryName categoryName : categoryNames) {
        if (categoryName.isSameKorName(keyword)) {
            return categoryName.getName();
        }
    }
    return keyword;
}

 

1) 공백 기준으로 잘라서

2) 카테고리 이름을 영문으로 변환한 후

3) 해당 keywords를 통해 조회하게 된다.

public 메서드 기준으로 코드의 가독성 또한 좋아졌다고 생각된다!

 

▶ Entity를 직접 조회하기보다는, Dto를 바로 조회하기!

또한 이전에는 Notice를 직접 조회해오고 있었는데, 사실 불필요하게 전체 Entity를 조회하지는 않아도 된다 생각한다.

위와 같이 필요한 데이터만 DTO로 직접 조회하게 변경하면서, 불필요한 I/O 작업의 리소스 낭비를 줄일 수 있다.

 

3. 검색 쿼리

마지막으로 공지 검색 쿼리를 살펴보자.

기존의 findBySubject ~~~ 를 사용하지 말고 QueryDsl을 통해 직접 구현해 보자!

 

where절의 경우 BooleanBuilder를 통해 or을 조합하여 동적 조건 쿼리를 작성하였다.

@Override
public List<NoticeSearchDto> findAllByKeywords(List<String> keywords) {
    return queryFactory
            .select(new QNoticeSearchDto(
                    notice.articleId,
                    notice.postedDate,
                    notice.subject,
                    notice.category.name))
            .from(notice)
            .where(isContainSubject(keywords).or(isContainCategory(keywords)))
            .orderBy(notice.postedDate.desc())
            .fetch();
}

private static BooleanBuilder isContainSubject(List<String> keywords) {
    BooleanBuilder booleanBuilder = new BooleanBuilder();
    for (String containedName : keywords) {
        booleanBuilder.or(notice.subject.contains(containedName));
    }

    return booleanBuilder;
}

private static BooleanBuilder isContainCategory(List<String> keywords) {
    BooleanBuilder booleanBuilder = new BooleanBuilder();
    for (String containedName : keywords) {
        booleanBuilder.or(notice.category.name.contains(containedName));
    }

    return booleanBuilder;
}

 

쿼리또한 내가 생각한 방식대로 한방 쿼리로 잘 나오고 있다!

4. 참고

https://blueshare.tistory.com/224

 

MSSQL LIKE 특수문자(와일드카드) ESCAPE 검색 방법 ([, %, _)

MSSQL LIKE 특수문자(와일드카드) ESCAPE 검색 방법 ([, %, _) MS SQL에서 와일드카드 문자 또는 특수문자를 LIKE 조건으로 검색해야 할 때가 있는데요. 그럴 경우 ESCAPE 옵션을 사용하시면 됩니다. 보통 설

blueshare.tistory.com

 

댓글