새소식

DB

[DB] QueryDSL을 활용하여 SNS 피드 만들기(1) - 동적 쿼리로 Pagination 피드를 만들어보자.

  • -

브레이킹 SNS github : https://github.com/Breaking-Dope

브레이킹 SNS 서버 github : https://github.com/Breaking-Dope/breaking-backend

 

도프(Dope) 팀에서 브레이킹 SNS 프로젝트를 하면서 SNS 피드를 구현할 일이 생겼습니다.

처음 기획 했던 형태는 여러가지 문제점들이 많았고, 추가적인 필터나 정렬이 필요했기에 많은 고민을 했었습니다.

 

이 피드 문제를 해결하기 위해 QueryDSL을 통한 동적 쿼리를 작성했는데,

겪었던 문제들을 공유하고자 합니다.

 

목차는 다음과 같습니다.

- 참고했던 서비스들과 최종 기획

- QueryDSL으로 기본적인 DTO 조회

- 필터 기능을 위한 동적 쿼리 작성

- 피드 정렬 기준 변경을 위한 동적 정렬

- 오프셋 페이징 (Offset Pagination)

- 커서 페이징 (Cursor-based Pagination)

 

요구사항

역시 요구사항 정확하게 먼저 정리를 해야합니다.

 

기본 요구사항

1. 필터 기능 (게시글의 상태, 게시글 유형)

2. 정렬 기능 (최신순, 좋아요순, 조회수순)

3. 게시글 숨김

4. 유저페이지에서 나오는 피드 (작성한 게시글, 북마크한 게시글, 구매한 게시글)

5. 미션과 관련된 게시글이 나오는 피드

 

추가 요구사항

1. 페이징 시, 사용자들의 interaction 에 의한 중복 데이터 해결

 

참고했던 서비스들과 최종 기획

대표적으로 '피드'라고 했을때 떠오르는 SNS 서비스들을 들어가서 참고를 했습니다.
- 페이스북

- 인스타그램

- 당근마켓

- 트위터

 

대표적인 피드 기반 SNS 서비스들의 형태

UI 적으로 가장 비슷한 것은 당근마켓이였고, 페이징 방식은 인스타그램의 최신순 피드가 가장 저희와 비슷해 보였습니다.

 

 

결국 저희 피드는 다음과 같은 디자인으로 구성되었습니다.

좌: PC, 우: 안드로이드

 


QueryDSL으로 기본적인 DTO 조회

대부분의 SNS 서비스에서의 피드 조회 쿼리 및 데이터 폼은 다양한 곳에서 재활용 가능합니다.

저희 서비스의 경우 다음과 같은 곳에서 비슷한 로직으로 동일한 데이터 폼을 가져오게 됩니다.

  • SNS 메인 피드
  • 유저페이지에서 나오는 피드
    • 작성한 게시글
    • 북마크한 게시글
    • 구매한 게시글
  • 미션과 관련된 게시글 피드 (미션과 관련된 게시글을 작성하는 컨셉의 서비스가 있습니다.)

 

그렇다면, 이런 공통적인 것들을 하나의 쿼리 템플릿으로 만들고, 필요한 부분들을 동적 쿼리로 끼워넣겠습니다.

 

private JPQLQuery<FeedResultPostDto> getBaseQuery(SearchFeedConditionDto searchFeedConditionDto, Post cursorPost, User me) {
    return queryFactory
            .select(Projections.constructor(FeedResultPostDto.class,
                    post.id,
                    post.title,
                    Projections.constructor(LocationDto.class,
                            post.location.address,
                            post.location.longitude,
                            post.location.latitude,
                            post.location.region_1depth_name,
                            post.location.region_2depth_name
                    ),
                    post.thumbnailImgURL,
                    post.postLikeList.size(),
                    post.commentList.size(),
                    post.postType,
                    post.viewCount,
                    Projections.constructor(WriterDto.class,
                            post.user.id,
                            post.user.compressedProfileImgURL,
                            post.user.nickname
                    ),
                    post.price,
                    post.createdDate,
                    post.isPurchasable,
                    post.isSold,
                    post.isAnonymous,
                    post.isHidden,
                    me == null ? Expressions.asBoolean(false) : post.user.eq(me), //isMyPost
                    Expressions.asBoolean(false), //isLiked
                    Expressions.asBoolean(false) //isBookmarked
                    ))
            .from(post)
            .where(
                    soldOption(searchFeedConditionDto.getSoldOption()),
                    postTypeFilter(searchFeedConditionDto.getPostType()),
                    cursorPagination(cursorPost, searchFeedConditionDto.getSortStrategy()),
                    sameLevelCursorFilter(cursorPost, searchFeedConditionDto.getSortStrategy())
            )
            .limit(searchFeedConditionDto.getSize());
}

쿼리 구문마다 하나씩 살펴보겠습니다.

 

SELECT

.select(Projections.constructor(FeedResultPostDto.class,

위의 방식은 쿼리 결과를 DTO 로 받는 방식이다. DTO로 결과를 받는 방법에는 이 외에도 다양한 방식이 있는데, 이유는 다음과 같습니다.

  • Q Class를 통해 DTO의 constructor를 생성해서 하는 방법은, '템플릿화를 통한 동적 쿼리'가 불가능했다.
  • Q Class를 사용하면 변경 사항이 있을때 마다 개발환경에서 매번 컴파일을 따로 해줘야하는 불편함이 있다. (협업시, 브랜치 이동시에 번거롭다.)
  • DTO 안에 inner DTO를 사용할 일이 많았는데, 이 방법으로 했을때 가장 깔끔하고 편리하다.

처음에는 @Projection 어노테이션을 붙여서 Q Class constructor를 사용했었는데, 골치아픈 부분들이 많이 등장해서 위의 방식으로 리팩토링을 했습니다.

 

 

추가적으로 여기에서 주의깊게 볼만한 부분이 있습니다.

 

좋아요와 댓글의 내용들은 모두 다른 테이블에 존재하기 때문에, 두가지 방법이 있습니다.

  • 서브쿼리
  • 쿼리 분리

해당 쿼리에서는 다음과 같이 서브쿼리를 활용해서 간단하게 해결했다.

다음의 방법을 사용하게 되면 다음과 같이 select 부분에 서브 쿼리가 나간다.

post.postLikeList.size(),
post.commentList.size(),

이것을 위해서는 엔티티 양방향 연관관계를 열어야 합니다.

 

그러나 서브쿼리는 최적화를 받을 수 없는 등 성능 문제 때문에 테이블의 크기가 크거나 복잡할 경우에는 쿼리를 분리하는 것도 좋은 방법이라고 생각됩니다. 위의 경우에는 테이블 구조가 간단하고 실제 서브 쿼리가 나가는 것을 확인해도, 서브 쿼리 내용도 간단해서 무리가 없어 보입니다.

 

FROM

.from(post)

from 절에서는 post  테이블을 가져오는 부분만 존재했습니다.

유저 정보 등을 join으로 가져와야하나 처음에 고민했었으나, join은 연산이 크고  다양한 요구사항들을 모두 join으로 구현하게 되면 3번 이상 조인을 하는 일이 생길 것 같습니다.

WHERE

.where(
        soldOption(searchFeedConditionDto.getSoldOption()),
        postTypeFilter(searchFeedConditionDto.getPostType()),
        cursorPagination(cursorPost, searchFeedConditionDto.getSortStrategy()),
        sameLevelCursorFilter(cursorPost, searchFeedConditionDto.getSortStrategy())
)

공통적인 동적 쿼리가 들어가있습니다.

soldOption과 postTypeFilter는 메인피드 필터에 관련된 내용입니다.

그리고 cursorPagination과 sameLevelCursorFilter는 cursor paging을 위해 사용된 조건입니다.

 

두 부분 모두 뒷쪽에서 동적 쿼리와 커서 페이징 부분에서 다루도록 하겠습니다.

 

LIMIT

.limit(searchFeedConditionDto.getSize());

페이징을 위한 쿼리다.

다음에서 소개할 offset paging 방식은 limit 과 offset을 동시에 사용하여 페이징을 하지만, 최종적으로 선택한 cursor paging은 offset을 사용하지 않기 때문에 최종 쿼리에서 빠져있는 모습입니다.

 

뒤에서 페이징 기법에 대해서 나올때 자세히 설명하겠습니다.

 

 

이렇게 기본적인 템플릿화 된 쿼리를 살펴보았습니다.

다음부터는 동적 쿼리를 어떻게 작성했는가를 살펴보겠습니다.

 


필터 기능을 위한 동적 쿼리 작성

동적 쿼리를 작성하는 방법은 두 가지가 있습니다.

  • 함수로 빼내서 해결한다.
    • return 으로 null값을 넣으면 해당 조건은 없는 것으로 적용됩니다.
  • 위에서 작성된 baseQuery 템플릿에 추가적인 조건들을 끼워넣는다.
    • 당연히 함수로 빼서 사용 가능.
    • where 뿐만 아니라, order by 및 동적 join도 가능,

 

여기에서는 공통된 부분은 템플릿(queryBase)에 함수 형식으로 동적 쿼리를 넣었고,

페이지 요구사항마다 다른 부분은 템플릿에 추가적인 동적 쿼리를 넣는 방식으로 적용했다.

 

먼저 함수로 빼내서 동적 쿼리를 작성하는 부분을 보겠습니다.

private JPQLQuery<FeedResultPostDto> getBaseQuery(
	SearchFeedConditionDto searchFeedConditionDto, Post cursorPost, User me) {
	
    return queryFactory
        .select(Projections.constructor(FeedResultPostDto.class,

        .
        .
        .
        
        .where(
                soldOption(searchFeedConditionDto.getSoldOption()),
                postTypeFilter(searchFeedConditionDto.getPostType()),
                .
                .
        )

where 에서 soldOption이라는 매서드를 한 번 보겠습니다.

해당 매서드는, 해당 게시글이 판매되었는지에 대한 필터를 구현했습니다.

private Predicate soldOption(SoldOption soldOption) {
    if(soldOption == null) {
        return null;
    }
    switch (soldOption) {
        case SOLD:
            return post.isSold.eq(true);
        case UNSOLD:
            return post.isSold.eq(false);
        case ALL:
        default:
            return null;
    }
}

QueryDSL에서 지원하는 Predicate 타입으로 반환되며, 요구사항에 따라 적절한 쿼리를 반환하면 됩니다.

 

WHERE 절에 해당 조건문을 넣고 싶지 않으면 null을 반환합니다.

 

 

baseQuery 템플릿에 추가적인 조건들을 끼워서 동적 쿼리를 작성해보겠습니다. 

 

동적 동적 쿼리 ?

 

프로젝트에서 피드 조회가 사용되는 경우를 다시 정리해보겠습니다.

  • SNS 메인 피드
  • 유저페이지에서 나오는 피드
    • 작성한 게시글
    • 북마크한 게시글
    • 구매한 게시글
  • 미션과 관련된 게시글 피드 (미션과 관련된 게시글을 작성하는 컨셉의 서비스가 있습니다.)

해당 내용들은 동일한 데이터 폼(DTO) 형태지만, 필요한 요구 조건과 정렬의 유무가 너무 다릅니다.

물론 해당 조건을 하나의 쿼리에 모두 쑤셔넣을 수 있지만, 그렇게 해버리면 너무 많은 동적 조건들이 하나의 쿼리 안에 나타나고, 유지보수가 힘들어 집니다.

 

그러면, baseQuery 템플릿에 JPA 쿼리문을 추가적으로 끼워넣어서 작성을 해보겠습니다.

 

여기에서 살펴볼 부분은 유저페이지에서의 피드입니다. 직관적인 설명을 위해 실제 구현된 모습을 보고 넘어가겠습니다.

 

실제 SNS 서비스의 유저페이지에서도 내가 작성한 제보나 북마크한 제보를 확인 할 수 있습니다.

 

 

 

@Override
public List<FeedResultPostDto> searchUserPageBy(
	SearchFeedConditionDto searchFeedConditionDto, User owner, User me, Post cursorPost) {
    
    return getBaseQuery(searchFeedConditionDto, cursorPost, me)
        .where(
                hiddenPostFilter(owner, me),
                anonymousPostFilter(owner, me),
                userPageFeedOption(searchFeedConditionDto.getUserPageFeedOption(), owner, me)
        )
        .orderBy(boardSort(SortStrategy.CHRONOLOGICAL))
        .fetch();
}

위에서 계속 등장하던 getBaseQuery 라는 템플릿 매서드 안에 주가적인 조건을 넣는 방식입니다.

 

실제로 상위 서비스 레이어에서 호출할 때는 searchUserPageBy 매서드를 호출하겠지요.

 

템플릿에 작성했던 동적 쿼리를 동일한 방식으로 사용하시면 됩니다.

그러면 템플릿과 추가한 동적 쿼리들이 합쳐진 쿼리가 생성됩니다.

동적 동적 쿼리인가요..?

 

이렇게 하면 관심사를 분리해서 해당 로직에 맞는 동적 함수만 체크하면 디버깅이 쉬워집니다.

 

 


피드 정렬 기준 변경을 위한 동적 정렬

그렇다면 정렬도 동적으로 가능할까요?

넵!

 

유저 요구사항에는 아마 다양한 방식으로 피드를 접하고 싶을 것입니다.

저희의 메인 피드에서는 세가지 정렬 방식을 지원합니다.

  • 최신순(default)
  • 좋아요순
  • 조회수순

 

코드를 보겠습니다.

.orderBy(boardSort(searchFeedConditionDto.getSortStrategy()), boardSort(SortStrategy.CHRONOLOGICAL))
private OrderSpecifier<?> boardSort(SortStrategy sortStrategy) {

    if(sortStrategy == null){
        return new OrderSpecifier<>(Order.DESC, post.id);
    }

    switch (sortStrategy){
        case LIKE:
            return new OrderSpecifier<>(Order.DESC, post.postLikeList.size());
        case VIEW:
            return new OrderSpecifier<>(Order.DESC, post.viewCount);
        case CHRONOLOGICAL:
            return new OrderSpecifier<>(Order.DESC, post.id);
    }

    return new OrderSpecifier<>(Order.DESC, post.id);

}

WHERE 절에 쓰이던 동적 쿼리와 비슷하게 OrderSpecifier라는 형식을 지원합니다.

return new OrderSpecifier<>(정렬방식, 비교 가능한 대상);

당연하게도, 정렬 기준은 반드시 from에서 명시된 테이블에서만 가능합니다.

그래서 다른 테이블에 있는 조건으로 정렬을 하고싶으시면 양방향 으로 열거나, join을 활용하셔야합니다.

 

해당 쿼리에서 저는 좋아요 순서를 구현하고 싶어서, 좋아요를 기록하는 테이블을 양방향 관계로 열고 비교 대상으로 설정했습니다.

 

 

여기에서도 재미있는 점이 있는데요,

최신순으로 정렬하는 것을 보면, 단순히 PK를 가지고 DESC 를 적용한 모습을 볼 수 있습니다.

최적화를 위한 꼼수인데, 관계형 데이터베이스에서 옵션을 AUTO_INCREMENT 를 사용하고 있다면 위와 같이 PK로 간단하고 성능이 괜찮은 쿼리를 뽑아볼 수 있습니다. 다른 데이터베이스는 모르겠지만, MYSQL에서는 AUTO_INCREMENT로 설정된 PK 값이 감소하는 일은 없습니다. 그래서 PK 를 역순으로 정렬하면 최신순이 되는것이죠.

 

 

 

다음 포스트에서는 이어서 페이징 방식에 대해서 알아보겠습니다.

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.