[식구하자_MSA] QueryDSL 동적쿼리 사용해서 검색 기능 구현해보자!

이민우·2024년 5월 9일
2

🍀 식구하자_MSA

목록 보기
11/21


이번에는 저번시간에 이어 식구하자 프로젝트의 SNS 서비스의 querydsl 동적쿼리를 검색기능을 구현해보는 과정을 포스팅 해보겠습니다❗️

개요

프로젝트를 진행하면서, 여러 조건에 따라 선택적으로 게시글을 조회하는 요구사항이 생겼습니다. 쉽게 말해 검색 기능을 구현해야했던 것입니다. 요구사항에서 추출한 검색 조건 및 필터는 다음과 같습니다.

  • 동적검색
    • 해시태그(완전일치)
    • 제목(포함)
    • 내용(포함)
    • 닉네임(완전일치)

(포함) 조건은 주어진 텍스트가 값에 포함되어있다면 조회
(정확히 일치) 조건은 말 그대로 값이 정확히 일치해야 조회한다는 뜻입니다.

위 요구사항대로 검색조건을 구현하기 위해선, SQL의 WHERE 조건절에 'LIKE''=' 연산자를 통해 주어진 조건에 일치하는 데이터만 조회해야합니다.

앞선 검색 조건들이 항상 모두 적용되지는 않고, 보통 일부 조건만 사용하기 때문에 순수 SQL문을 사용한다면 검색 조건에 따라 사용되는 쿼리를 전부 다르게 해주어야한다는 문제점(수고스러움)이 생깁니다.

-- 닉네임, 해시태그 조건으로 검색시
WHERE `nickname` == '조건' AND `hashtag`='조건'

-- 제목, 내용 조건으로 검색시
WHERE `title` LIKE '%조건%' AND `content` 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<SnsPost> search(final String snsPostTitle) {
    return queryFactory
            .selectfrom(post)
            // where절에 snsPostTitle like 조건만 포함됨
            .where(null, snsPost.snsPostTitle.contains(snsPostTitle))
            .fetch();
}

📌 주의 사항

where에는 null 값을 넘겨주어도 되지만, BooleanExpression에 null값을 넣을 경우 IllegalArgumentException이 발생합니다.
snsPost.snsPostTitle.contains(null) -> IllegalArgumentException 예외 발생!!


위 주의사항을 반영하여 주어진 검색 값이 없을 경우 null을 반환하는 메서드를 작성하였습니다.

private BooleanExpression snsPostTitleLike(String snsPostTitle) {
    return StringUtils.hasText(snsPostTitle) ? snsPost.snsPostTitle.contains(snsPostTitle) : null;
}

// query
private List<Post> search(final String snsPostTitle) {
    return queryFactory
            .selectfrom(snsPost)
            .where(snsPostTitleLike(snsPostTitle))
            .fetch();
}
  • StringUtils의 hasText()는 내부적으로 != null / !isEmpty / !isBlank를 실행하여 텍스트가 존재하는지 판단하고 boolean 값을 리턴합니다.
  • 따라서 titleLike 는 매개변수로 넘겨받은 title 텍스트가 존재할 경우 조건절을 반환하고 아니라면 null을 반환합니다.

💻 조건 여러개 추가하기!


where에 여러 조건을 넘겨주는 방법은 크게 2가지가 있습니다.

  1. BooleanBuilder를 생성하여 and()에 BooleanExpression을 추가하는 방법
  2. 여러개의 BooleanExpression을 파라미터로 넘기는 방법

저는 1번의 방법을 사용하였습니다. 검색에 사용되는 조건이 많았기 때문에 2번의 방법을 사용하게 될 경우 쿼리가 조인도 하고 프로젝션도 하는데 길어질 것이라 생각했기 때문입니다. 하지만 조건문을 한 눈에 확인할 수 있다는 장점도 있기 때문에 이 부분은 성향에 따라 선택하시면 될 것 같습니다.

최종 검색 기능 구현


  @Override
    public List<SnsPostResponseDto> search(final Map<String, String> searchCondition) {
        return jpaQueryFactory
                .select(Projections.constructor(SnsPostResponseDto.class,
                        snsPost.id,
                        snsPost.memberNo,
                        snsPost.snsPostTitle,
                        snsPost.snsPostContent,
                        snsPost.createdBy,
                        snsPost.createdAt,
                        snsPost.snsLikesCount,
                        snsPost.snsViewsCount,
                        list(hashTag.name),
                        list(image.url)))
                .from(snsHashTagMap)
                .join(snsHashTagMap.snsPost, snsPost)
                .join(snsHashTagMap.hashTag, hashTag)
                .join(snsPost.imageList, image)
                .where(allCond(searchCondition))
                .fetch();
    }
    // BooleanBuilder 검색 동적 쿼리
    private BooleanBuilder allCond(Map<String, String> searchCondition) {
        BooleanBuilder builder = new BooleanBuilder();

        return builder
                .and(snsPostTileLike(searchCondition.getOrDefault(SNSPOSTTITLE.getParamKey(), null)))
                .and(hashTagNameEq(searchCondition.getOrDefault(HASHTAGNAME.getParamKey(), null)))
                .and(snsPostContentLike(searchCondition.getOrDefault(SNSPOSTCONTENT.getParamKey(), null)))
                .and(nicknameEq(searchCondition.getOrDefault(NICKNAME.getParamKey(), null)));
    }
    // 검색 동적 쿼리 조건1
    private BooleanExpression snsPostTileLike(String snsPostTitle) {
        return StringUtils.hasText(snsPostTitle) ? snsPost.snsPostTitle.contains(snsPostTitle) : null;
    }
    // 검색 동적 쿼리 조건2
    private BooleanExpression hashTagNameEq(String hashTagName) {
        return StringUtils.hasText(hashTagName) ? snsHashTagMap.hashTag.name.eq(hashTagName) : null;
    }
    // 검색 동적 쿼리 조건3
    private BooleanExpression snsPostContentLike(String snsPostContent) {
        return StringUtils.hasText(snsPostContent) ? snsPost.snsPostContent.contains(snsPostContent) : null;
    }
    // 검색 동적 쿼리 조건4
    private BooleanExpression nicknameEq(String nickname) {
        return StringUtils.hasText(nickname) ? snsPost.createdBy.eq(nickname) : null;
    }
  • 검색에 사용되는 값들을 쿼리 파라미터로 넘겨주게 되는데, 컨트롤러에서 map 자료형에 해당 값들을 저장하여 넘겨주었습니다.
  • Key 값은 SearchParam이라는 Enum 객체에 상수로 저장했습니다.
  • @RequestParam Map<String, String> searchCondition map의 getOrDefault 메서드로 쿼리 파라미터로 값을 넘겨받지 못했다면 null을 넘겨주어 where절에 조건이 포함되지 않도록 하였습니다.

검색 조건 enum 클래스

@Getter
public enum SearchParam {
    HASHTAGNAME("hashTagName"),
    SNSPOSTTITLE("snsPostTitle"),

    SNSPOSTCONTENT("snsPostContent"),
    NICKNAME("nickname");



    private final String paramKey;

    SearchParam(String paramKey) {
        this.paramKey = paramKey;
    }

}

참고

https://jojoldu.tistory.com/394
https://velog.io/@foodsmith96/%EA%B9%80%EC%98%81%ED%95%9C-Querydsl-6-%EC%A4%91%EA%B8%89-%EB%AC%B8%EB%B2%95-3-%EB%8F%99%EC%A0%81-%EC%BF%BC%EB%A6%AC-%EC%98%88%EC%A0%9C

profile
백엔드 공부중입니다!

0개의 댓글

관련 채용 정보