인프런 커뮤니티 질문&답변

백엔드 주니어 개발자님의 프로필 이미지

작성한 질문수

실전! Querydsl

동적 정렬(orderBy)처리중 페이징 문제가 발생했는데 해결해주실 수 있을까요?ㅠㅠ

해결된 질문

작성

·

942

0

개인 프로젝트를 진행하며 querydsl을 사용하여 동적 쿼리문을 작성했습니다. 무한스크롤 페이징 처리를 하였고, 코드의 일부분만 보여드리면 아래와 같습니다.

public ArticlePagingResponse<Article> searchDynamicQueryAndPaging(Long lastArticleId,
                                                                      ArticleSearchCond cond,
                                                                      String orderBy,
                                                                      int size) {
        JPAQuery<Article> query = new JPAQuery<>(em);

        query.from(article)
                .join(article.member,member)//article.member는 Article테이블에 있는 member_id, member는 Member테이블에 있는 id라고 생각
                .join(article.restaurant, restaurant)//article.restaurant는 Article테이블에 있는 restaurant_id, restaurant는 Restaurant테이블에 있는 id
                .fetchJoin()
                .where(
                        // no-offset 페이징 처리
                        ltStoreId(lastArticleId),
                        // 검색조건들
                        생략...
                );


        //정렬 동적 처리
        switch(orderBy){
            case OrderConst.CREATED_DATE_DESC://최신 순으로 정렬
                query.orderBy(article.createdDate.desc());
                break;
            case OrderConst.CREATED_DATE_ASC://오래된 순으로 정렬
                query.orderBy(article.createdDate.asc());
                break;
            case OrderConst.VIEWS_DESC://조회수 순으로 정렬
                query.orderBy(article.views.desc(),article.createdDate.desc());
                break;
            case OrderConst.LIKE_COUNT_DESC://좋아요 갯수 순으로 정렬
                query.leftJoin(likeArticle)
                        .on(article.id.eq(likeArticle.article.id))
                        .groupBy(article.id)
                        .orderBy(likeArticle.count().desc(),article.createdDate.desc());
                break;
            case OrderConst.COMMENT_COUNT_DESC://댓글 갯수 순으로 정렬
                query.leftJoin(comment)
                        .on(article.id.eq(comment.article.id))
                        .groupBy(article.id)
                        .orderBy(comment.count().desc(),article.createdDate.desc());
                break;
            default:
                throw new IllegalStateException("OrderConst에 정의되어있는 orderBy값 외의 다른 값이 들어왔습니다.");
        }
        List<Article> results = query
                .limit(size + 1)
                .fetch();//size를 DB에서 받는 것보다 프론트에서 받는게 더 유연할 것같음.fetch();

        boolean hasNext = false;
        if (results.size() > size) {//결과가 6개이면 size(5)보다 크므로 다음 페이지가 있다는 의미
            hasNext = true;
            results.remove(size - 1);//다음 페이지 확인을 위하 게시글을 하나더 가져왔으므로 확인 후 삭제
        }
        return new ArticlePagingResponse<>(results,hasNext);
    }

코드를 보면 no-offset방식으로 구현을 하였습니다.
whrer문의 lastArticleId 값을 받고 그것보다 작은 값중에 5개씩 받도록 처리하였습니다.
예) 10, 9, 8, 7, 6 의 게시글을 받고 그다음 스크롤 이벤트가 발생하면 6보다 작은 값 중에서 5개인 5, 4, 3, 2, 1 을 가져 오는 것입니다.


문제는 최신순으로 정렬하여 값을 가져오면 최신 게시글의 id값이 가장 크므로 잘 작동하는데 다른 정렬 조건(오래된 순, 조회수순, 좋아요 갯수순, 댓글 갯수순) 으로 가져올 때는 id값의 순서를 예상하지 못하니때문에 정렬이 되지않는 문제가 발생하였습니다.

오래된 순은 id값을 lt 대신에 gt쓰고 어떻게 구현할 수 있을 것같은데 다른 정렬 조건(조회수순, 좋아요순, 댓글순)은 어떻게 구현할 좋은 방법이 생각 나질 않네요 방법을 아시는 분 계시면 알려주시면 감사하겠습니다.

답변 3

2

김영한님의 프로필 이미지
김영한
지식공유자

안녕하세요. 백엔드 개발자 취준생님

결론부터 말씀드리면 NO OFFSET 방식은 반드시 정렬 기준이 되는 고유한 키를 가져야 합니다. 이 키는 고유하고 변경되지 않아야 합니다. 그렇지 않으면 정확한 페이징을 보장할 수 없습니다.

보통 특정 시간을 기준으로 ID가 생성되기 때문에 이런 경우에는 NO OFFSET이 유요하지만 임의의 정렬 기준을 다시 새워야 한다면 NO OFFSET 방식을 사용하기는 어렵습니다. 이때는 일반적인 페이징 방식을 고려해야 합니다.

관련해서 NO OFFSET 단점으로 검색해보시면 도움이 되실거에요.

감사합니다.

1

해결했습니다!!!
조회수를 구현할 때는 조건식을 where절에 넣어야했지만 좋아요순과, 댓글순은 group by를 사용했으니 having절에 넣어야했는데 조회수했을 때 처럼 where절에 조건식을 넣어버려서 생긴 문제였습니다.ㅠㅠㅠ
테스트 코드의 일부분만 보여드리면 아래와 같습니다.

List<Article> findArticles = query.select(article)
        .from(article)
        .leftJoin(article.likes, likeArticle)
        .groupBy(article.id)
        .having(
                likeArticle.count().loe(likeCount),
                article.id.loe(lastArticleId)
        )
        .orderBy(
                likeArticle.count().desc(),
                article.id.desc()
        )
        .fetch();

답변 감사합니다!

1

안녕하세요, 백엔드 개발자 취준생 님. 공식 서포터즈 y2gcoder 입니다.

똑같이 마지막 row의 id를 저장했다가 같은 필터 조건으로 들어왔을 때는 마지막 id를 기준으로 검색하시면 되지 않을까 생각합니다. 혹시 이 경우에 어떤 점이 걸리셔서 질문을 주셨을 지 알고 싶습니다!

감사합니다.

조회수순으로 정렬은 해결을 했는데 좋아요순, 댓글순 정렬에서 막힙니다ㅠㅠㅠㅠ

최신순은 id값이 높을 수록 높을 수록 최신순이므로 마지막 id값을 기준으로 해당 id 값보다 작은 값중 5개를 가져오는 것처럼 조회수순으로 정렬할 때도 똑같은 원리로
마지막 조회수보다 작은 값을 가져오게되면 같은 값의 조회수를 가진 게시글이 여러개일 경우 해당 게시글이 무시됩니다.

그래서 정렬 기준을 views, id 2개를 넣고 where조건문으로 같은 조회수를 가진 게시글이면 id값을 기준으로 게시글을 가져오도록 조건 처리를 하였습니다.
SQL코드로 구현하면 아래와 같습니다.

select * from article where 
( views < {이전 페이지의 마지막 게시글의 조회수} ) 
    or ( views = {이전 페이지의 마지막 게시글의 조회수 } and id < {이전 페이지의 마지막 게시글의 id})
order by views desc, id desc
limit 5;

이런식으로 코드를 짜면 같은 조회수의 게시글이 있더라도 후 순위로 게시글 id를 기준으로 내림차순 값이 나오며 조회수가 같더라도 무시되지 않고 잘 출력됩니다.
querydsl로 구현한 코드는 아래와 같습니다. 초기에 조회수를 기준으로 값을 가져올 때는 조회수가 값으느로 null처리를 해주었습니다.

case OrderConst.VIEWS_DESC://조회수 순으로 정렬
              if (orderCond.getViews() != null) {
                    orderBuilder.or(
                            articleViewLt(orderCond.getViews())
                            ).or(
                                    articleViewsEq(orderCond.getViews())
                                            .and(articleIdLoe(lastArticleId))
                    );
                } else {
                    orderBuilder.and(articleIdLoe(lastArticleId));
                }
                query.where(orderBuilder)
                        .orderBy(article.views.desc(),article.id.desc());
                break;

문제는 여기서 부터입니다!!! 위를 똑같이 적용해서 좋아요 개수순, 댓글 개수순으로 정렬을 구현할 때입니다. 정확하게는 모르겠는데 집계함수를 사용함으로인해 group 함수에서 문제가 발생하는 것 같습니다.
제가 구현한 코드는 아래와 같습니다.

생략...

case OrderConst.LIKE_COUNT_DESC://좋아요 갯수 순으로 정렬

    if (orderCond.getLikeCount() != null) {
        orderBuilder.or(
                likeArticleCountLt(orderCond.getLikeCount())
        ).or(
                likeArticleCountEq(orderCond.getLikeCount())
                        .and(articleIdLoe(lastArticleId))
        );
        query.groupBy(article.id,likeArticle.article.id);
    } else {
        orderBuilder.and(articleIdLoe(lastArticleId));
        query.groupBy(article.id);
    }
    query.leftJoin(likeArticle)
            .on(article.id.eq(likeArticle.article.id))//likeArticle과 join
            .where(orderBuilder)
            .orderBy(likeArticle.count().desc(),article.id.desc());
    break;

생략...
private BooleanExpression likeArticleCountEq(Integer likeCount) {
    return likeCount != null ? likeArticle.count().eq(Long.valueOf(likeCount)) : null;
}

private BooleanExpression likeArticleCountLt(Integer likeCount) {
    return likeCount != null ? likeArticle.count().lt(likeCount) : null;
}

코드를 보면 likeArticleCountEq, likeArticleCountLt에 likeArticle.count()를 사용하며 집계함수를 사용하였습니다.
정확하게 오류가 발생하는 시점을 말씀드리면, 좋아요 개수순으로 게시글을 가져올 때는, 정삭적으로 출력이 됩니다. 그런데 스크롤 이벤트를 통해 orderCond.getLikeCount() != null 일경우 값을 가져오면
java.sql.SQLException: Invalid use of group function
오류를 출력합니다.

orderCond.getLikeCount() 이 null일 경우에는 잘 작동하는데, orderCond.getLikeCount() != null 경우에만 오류가 발생하는 것을 보면 두개의 차이점은 where문 조건절이고 where문 조건절에 count() 집계함수를 사용하냐 안하냐의 차이인데.... 아직 집계함수에 대한 이해도가 부족해서 코드를 어떻게 수정해야할지 감이 안잡힙니다.ㅠㅠ
select절에 likeArticle을 사용하지 않아서 발생하는 문제인걸 수도 있을 것같기도하고....혹시 해결해주실 분 있으면 감사하겠습니다ㅠㅠㅠ 메서드 전체소스 코드는 아래와 같습니다.

public ArticlePagingResponse<Article> searchDynamicQueryAndPaging(Long lastArticleId,
                                                                  ArticleSearchCond searchCond,
                                                                  ArticleOrderCond orderCond,
                                                                  int size) {

    JPAQuery<Article> query = new JPAQuery<>(em);

    //where문을 보면 ,로 구분이 되었는데 이는 and조건이므로 or로 조건을 걸어야하는 키워드검색은
    //BooleanBuilder 객체를 사용해서 조건들을 체이닝해준다.
    //BooleanBuilder객체를 사용하지 않고 체이닝을 하면 제일 앞에있는 조건의 값이
    //null일경우 에러가 발생하게되어서 or조건들은 BooleanBuilder에 체이닝을 해주었다.
    BooleanBuilder keywordCond = new BooleanBuilder();
    keywordCond.or(contentLike(searchCond.getContent()))//글 내용 keyword검색
            .or(nickNameLike(searchCond.getWriter()))//작성자(닉네임) keyword검색
            .or(nameLike(searchCond.getWriter()))//작성자(이름) keyword검색
            .or(tagArticleIn(searchCond.getArticlesByTagValue()))//태그 keyword검색
            .or(restaurantNameLike(searchCond.getRestaurantName()));//음식점명 keyword검색

    BooleanBuilder statusCond = new BooleanBuilder();
    statusCond.or(statusEq(ArticleStatus.NORMAL))// 상태가 NORMAL 게시글들만 출력
            .or(statusEq(ArticleStatus.REPORT));// 상태가 REPORT 게시글들만 출력

    query.select(article)
            .from(article)
            .join(article.member,member)//article.member는 Article테이블에 있는 member_id, member는 Member테이블에 있는 id라고 생각
            .join(article.restaurant, restaurant)//article.restaurant는 Article테이블에 있는 restaurant_id, restaurant는 Restaurant테이블에 있는 id
            .fetchJoin()
            .where(//게시글 필터링
                    blockMemberIdNotIn(searchCond.getBlockedMemberIds()),//차단회원의 게시글 필터링
                    statusCond//상태조건 필터링
            )
            .where(// 검색조건들
                    followMembersIn(searchCond.getFollowMembers()),//팔로우한 유저로 검색
                    sidoEq(searchCond.getSido()),//시도로 검색
                    sigoonEq(searchCond.getSigoon()),//시군으로 검색
                    dongEq(searchCond.getDong()),//동으로 검색
                    latitudeBetween(searchCond.getLatitude()),//위도로 검색
                    longitudeBetween(searchCond.getLongitude()),//경도로 검색
                    categoryEq(searchCond.getCategory()),//음식점 카테고리로 검색
                    likeArticleIn(searchCond.getLikeArticles()),//좋아요누른 게시판 검색
                    keywordCond//keyword조건 검색
            );

    BooleanBuilder orderBuilder = new BooleanBuilder();
    //정렬 동적 처리
    switch(orderCond.getOrderBy()){
        case OrderConst.CREATED_DATE_DESC://최신 순으로 정렬
            query.where(articleIdLt(lastArticleId))// no-offset 페이징 처리
                    .orderBy(article.id.desc());
            break;
        case OrderConst.CREATED_DATE_ASC://오래된 순으로 정렬
            query.where(articleIdGt(lastArticleId))
                    .orderBy(article.id.asc());
            break;
        case OrderConst.VIEWS_DESC://조회수 순으로 정렬
            if (orderCond.getViews() != null) {
                orderBuilder.or(
                        articleViewLt(orderCond.getViews())
                        ).or(
                                articleViewsEq(orderCond.getViews())
                                        .and(articleIdLoe(lastArticleId))
                );
            } else {
                orderBuilder.and(articleIdLoe(lastArticleId));
            }
            query.where(orderBuilder)
                    .orderBy(article.views.desc(),article.id.desc());
            break;
        case OrderConst.LIKE_COUNT_DESC://좋아요 갯수 순으로 정렬

            if (orderCond.getLikeCount() != null) {
                orderBuilder.or(
                        likeArticleCountLt(orderCond.getLikeCount())
                ).or(
                        likeArticleCountEq(orderCond.getLikeCount())
                                .and(articleIdLoe(lastArticleId))
                );
                query.groupBy(article.id,likeArticle.article.id);
            } else {
                orderBuilder.and(articleIdLoe(lastArticleId));
                query.groupBy(article.id);
            }
            query.leftJoin(likeArticle)
                    .on(article.id.eq(likeArticle.article.id))//likeArticle과 join
                    .where(orderBuilder)
                    .orderBy(likeArticle.count().desc(),article.id.desc());
            break;
        case OrderConst.COMMENT_COUNT_DESC://댓글 갯수 순으로 정렬
            query.leftJoin(comment)
                    .on(article.id.eq(comment.article.id))
                    .groupBy(article.id, comment.count())
                    .where(
                            comment.count().lt(orderCond.getCommentCount())
                                    .or(
                                            comment.count().eq((long) orderCond.getCommentCount())
                                                    .and(articleIdLoe(lastArticleId))
                                    )
                    )
                    .orderBy(comment.count().desc(),article.id.desc());
            break;
        default:
            throw new IllegalStateException("OrderConst에 정의되어있는 orderBy값 외의 다른 값이 들어왔습니다.");
    }
    List<Article> results = query
            .limit(size + 1)
            .fetch();//size를 DB에서 받는 것보다 프론트에서 받는게 더 유연할 것같음.fetch();
    log.info("실행된 쿼리문 = {} ",query.toString());
    boolean hasNext = false;
    if (results.size() > size) {//결과가 6개이면 size(5)보다 크므로 다음 페이지가 있다는 의미
        hasNext = true;
        results.remove(size - 1);//다음 페이지 확인을 위하 게시글을 하나더 가져왔으므로 확인 후 삭제
    }
    return new ArticlePagingResponse<>(results,hasNext);
}

//where절에서 사용하는 집계함수가 사용되는는 메서드

private BooleanExpression likeArticleCountEq(Integer likeCount) {
    return likeCount != null ? likeArticle.count().eq(Long.valueOf(likeCount)) : null;
}

private BooleanExpression likeArticleCountLt(Integer likeCount) {
    return likeCount != null ? likeArticle.count().lt(likeCount) : null;
}

 //console창으로 출력된 쿼리문

select

article0_.id as id1_0_0_,

restaurant2_.id as id1_9_1_,

article0_.created_date as created_2_0_0_,

article0_.modified_date as modified3_0_0_,

article0_.content as content4_0_0_,

article0_.image as image5_0_0_,

article0_.image_size as image_si6_0_0_,

article0_.member_id as member_i9_0_0_,

article0_.restaurant_id as restaur10_0_0_,

article0_.status as status7_0_0_,

article0_.views as views8_0_0_,

restaurant2_.dong as dong2_9_1_,

restaurant2_.sido as sido3_9_1_,

restaurant2_.sigoon as sigoon4_9_1_,

restaurant2_.category as category5_9_1_,

restaurant2_.address as address6_9_1_,

restaurant2_.latitude as latitude7_9_1_,

restaurant2_.longitude as longitud8_9_1_,

restaurant2_.old_address as old_addr9_9_1_,

restaurant2_.name as name10_9_1_

from

article article0_

inner join

member member1_

on article0_.member_id=member1_.id

inner join

restaurant restaurant2_

on article0_.restaurant_id=restaurant2_.id

left outer join

like_article likearticl3_

on (

article0_.id=likearticl3_.article_id

)

where

(

article0_.status=?

or article0_.status=?

)

and (

count(likearticl3_.id)<?

or count(likearticl3_.id)=?

and article0_.id<=?

)

group by

article0_.id ,

likearticl3_.article_id

order by

count(likearticl3_.id) desc,

article0_.id desc limit ?