페이징 때문에 서비스가 망하는 과정

혁콩·2024년 7월 13일

모두의 음악

목록 보기
11/17
post-thumbnail

들어가기에 앞서

모두의 음악 프로젝트는 그룹 기반 커뮤니티 서비스입니다.

이번 개발 사항으론 서비스에서 가장 많이 사용될 것으로 예측되는 게시글 목록 조회 기능을 구현했어요.

API를 구현하며 진행한 선택과 그 이유에 대해 정리해볼까해요.

페이지네이션

페이징, 페이지네이션은 기본적으로 정보를 나눠받기 위해 존재한다고 생각해요.

페이징은 크게 전통적인 방식의 오프셋 기반 페이징과 페이스북, 인스타그램에서 볼 수 있는 무한 스크롤 에 자주 사용되는 커서 기반 페이징으로 구분할 수 있습니다.

1. 오프셋 기반 페이징

오프셋 기반 페이징페이지(오프셋) 을 입력받아 해당 페이지에 해당하는 데이터를 조회해요. 한 페이지(LIMIT)10 일 때, 페이지가 0 이라면 조회 결과에서 1~10 번째에 해당하는 결과를 받을 수 있어요.

이러한 오프셋 기반 페이징은 구현이 굉장히 간단하다는 장점이 존재해요. Java의 경우 Page 인터페이스를 통해 간편하게 구현할 수 있죠. 다만, 몇가지 단점이 존재합니다.

단점

1. Count Query 의 필요성

오프셋 기반 페이징은 내 페이지가 몇 번째 아이템부터 시작하는지를 확인하기 위해 조회 결과의 개수를 알아야 해요. 이 때문에 Count Query 가 추가로 발생하게되므로, 최소 2번의 쿼리가 발생해요.

2. 성능 문제

오프셋 기반 페이징을 검색할 시 항상 딸려오는 성능 문제 또한 존재해요.

간단히 설명하자면, 특정 페이지에 해당하는 정보를 찾기 위해 가장 처음부터 순회를 한 후, 필요없는 데이터를 버리는 방식 때문이예요.

오프셋 페이징이 느린 진짜 이유 포스트에 굉장히 자세히 설명되어 있어요!

즉, 500 페이지 를 읽기 위해선 0~499 페이지를 읽은 후 버리고 500 페이지 를 가져올 수 있다는 말입니다. 이 때문에 오프셋 기반 페이징은 뒤쪽 페이지를 요청할수록 성능이 저하되는 문제가 발생해요.

2. 커서 기반 페이징

커서 기반 페이징오프셋 기반 페이징의 성능 문제를 해결하며 등장했어요. 마지막으로 읽은 값 이후로 LIMIT 만큼만 가져오기에 오프셋 기반 페이징에서 발생하는 Count Query 가 불필요하며, 앞에서부터 읽지 않기에 점점 저하되는 성능 문제를 해결할 수 있었죠.

다만, 커서 기반 페이징에도 단점은 존재해요.

단점

1. 보다 복잡한 구현

커서 기반 페이징오프셋 기반 페이징에 비해 복잡한 구현이 필요해요. 특히, 정렬할 keyunique 하지 않은 경우, 데이터의 누락이 발생할 수 있기 때문에 조심해야 해요.

예를 들어 볼까요? 날짜에 의해 정렬된 데이터가 존재하며 LIMIT2 라고 가정해볼게요.


첫 조회에선 커서부터 2개의 7월 17일 데이터를 가져올거예요. 다음 조회를 볼까요?


커서의 검색 조건7월 17일보다 큰 으로 들어가기에 3번째 데이터를 건너뛰게되고, 데이터 누락이 발생해요.

2. 제한

뿐만 아니라 커서 기반 페이징은 상황에 따라 도입하기 힘들 수도 있어요.

한번에 여러 페이지를 건너뛰어야 한다.

이러한 요구 사항이 존재할경우, 마지막 Item을 기준으로 다음 것을 조회하는 커서 기반 페이징은 도입하기 힘들거예요.

구현

구현엔 QueryDSL을 사용했어요. 보다 쉽게 동적 쿼리 를 작성할 수 있게 도와준답니다.

1. 오프셋 기반 페이징

오프셋 기반 페이징은 위에서 말한 것처럼 Count Query 를 추가로 작성해줘야 합니다.

inner join 을 진행하기에 Count Query 에선 조인을 제외했어요.

    @Override
    public Page<Post> searchRecentPosts(PostSearchCondition cond, Pageable pageable) {
        List<Post> content = queryFactory
                .select(post)
                .from(post)
                .join(post.group, group).fetchJoin()
                .join(post.profile, profile).fetchJoin()
                .where(
                        groupIdEq(cond.getGroupId()),
                        postScopeEq(cond.getPostScope()),
                        post.state.eq(cond.getState())
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        Long totalCount = queryFactory
                .select(post.count())
                .from(post)
                .where(
                        groupIdEq(cond.getGroupId()),
                        postScopeEq(cond.getPostScope()),
                        post.state.eq(cond.getState())
                )
                .fetchOne();

        return new PageImpl<>(content, pageable, totalCount);
    }

    private BooleanExpression postScopeEq(PostScope postScope) {
        return postScope != null ? post.postScope.eq(postScope) : null;
    }

    private BooleanExpression groupIdEq(Long groupId) {
        return groupId != null ? post.group.id.eq(groupId) : null;
    }

2. 커서 기반 페이징

커서 기반 페이징에선 위와 다르게 .offset() 이 사라지고, where 절에 cursorIdLt() 가 추가되었어요.

또한 게시글이 등록된 순서대로 조회할 것이기에 별다른 정렬 조건은 필요하지 않아 생각보다 편하게 구현할 수 있었습니다.

    @Override
    public Slice<Post> searchRecentPosts(Long cursorId, PostSearchCondition cond, Pageable pageable) {
        List<Post> content = queryFactory
                .select(post)
                .from(post)
                .join(post.group, group).fetchJoin()
                .join(post.profile, profile).fetchJoin()
                .where(
                        groupIdEq(cond.getGroupId()),
                        postScopeEq(cond.getPostScope()),
                        post.state.eq(cond.getState()),
                        cursorIdLt(cursorId) // 커서 적용
                )
                .orderBy(post.id.desc())
                .limit(pageable.getPageSize() + 1)
                .fetch();

        boolean hasNext = false;
        if (content.size() > pageable.getPageSize()) {
            content.remove(pageable.getPageSize());
            hasNext = true;
        }
        return new SliceImpl<>(content, pageable, hasNext);
    }

    private BooleanExpression cursorIdLt(Long cursorId) {
        return cursorId != null ? post.id.lt(cursorId) : null;
    }

    private BooleanExpression postScopeEq(PostScope postScope) {
        return postScope != null ? post.postScope.eq(postScope) : null;
    }

    private BooleanExpression groupIdEq(Long groupId) {
        return groupId != null ? post.group.id.eq(groupId) : null;
    }

성능 테스트

테스트를 위해 약 1만개의 게시글 데이터를 준비했습니다.
로컬 환경에서 테스트했기에 일관된 테스트가 진행되지 않았습니다. 차선책으로 여러 번 테스트한 후 판단했습니다.

테스트는 두 방식으로 구현된 API의 성능 차이를 확인하기 위해 JMeter를 사용해 진행했습니다.

테스트는 특정 지점에 있는 데이터를 조회하는 방식으로 진행했습니다. 오프셋 기반 페이징은 0, 250, 500, 750, 999 페이지를 조회했으며, 커서 기반 페이징 마지막, 7500번, 5000번, 2500번, 50번 Item을 조회했습니다.

오프셋 기반 페이징의 경우, 250 페이지 까진 일정한 Throughput 을 기록했지만, 이후부턴 조금씩 감소함을 확인할 수 있었어요. 마지막 페이지 의 경우엔 약 40% 의 성능 하락이 발생함을 확인할 수 있었습니다.

커서 기반 페이징의 경우, 처음엔 오프셋 기반 페이징보다 조금 더 낮은 성능을 기록했어요. 하지만, 뒤쪽의 데이터를 조회해도 성능이 감소하지 않았어요.

테스트 결과는 아래쪽에 사진으로 남겨놓았답니다.

오프셋 기반 페이징

0 페이지


250 페이지


500 페이지


750 페이지


999페이지


커서 기반 페이징

10000번째


7500번째


5000번째


2500번째


50번째

선택

1. 성능 상 이점?

모두의 음악 프로젝트는 모바일 환경을 메인으로 합니다.
게시글 조회 또한 무한 스크롤 방식으로의 구현을 생각하고 있습니다.

커서 기반 페이징이 일정한 성능을 보장할 수 있음을 알았습니다. 그럼에도 불구하고, 성능 때문에 커서 기반 페이징을 도입할 이유는 없다고 느껴졌어요.

서비스의 특성

커뮤니티 기반의 게시글 서비스이고, 어플리케이션의 주 목적은 음악 동아리 를 타겟한 네이버 밴드 와 같은 서비스를 기획했어요.

이러한 특성을 생각하면 최신 게시글 이 가장 빈번하게 조회될테고, 성능에 영향이 가기 위해선 최소 500번 가량의 스크롤링이 진행되어야 합니다.
500 번 이상 스크롤을 내릴 가능성이 현저히 적을 것이라 생각했으며, 만약 이전 게시글을 봐야한대도 검색 기능 을 이용할 것이라 생각했어요.

사용자가 늘어나 게시글이 많아지고, 스크롤을 많이 내리는 현상이 잦아질 경우 구현하는 것이 더 좋다고 판단했습니다.

2. 그럼에도 커서 기반 페이징을 선택한 이유

그럼에도 불구하고 커서 기반 페이징을 도입한 이유는 UX(사용자 경험)에 있습니다.

오프셋 기반 페이징은 페이지를 기반으로 데이터를 조회합니다. 다음과 같은 상황을 생각해볼까요.

처음 조회 시 페이지 010 ~ 6 에 해당하는 Item을 조회합니다. 스크롤링 발생 시 페이지 1 을 조회할거예요.

문제는 여기서 발생합니다. 첫 조회 이후 스크롤링이 발생하기 전 새로운 게시글이 등록되었다고 가정해볼까요?


새로운 Item이 등록되었기에 페이지가 밀리게되고, 사용자의 화면엔 ID6 인 게시글이 2개 보이게 됩니다. 활성화된 그룹일수록 글이 많이 등록될테고, 중복 게시글이 화면을 뒤덮을 확률 또한 존재했습니다.

이러한 현상은 사용자에게 버그로 인식될테고, 사용자에게 부정적인 경험을 야기하게 됩니다. 결국 사용자는 모두의 음악 서비스를 떠나게 되겠죠.

그것만은 안된다!

참고 자료

Wonit - 오프셋 페이징이 느린 진짜 이유
minsangk - 커서 기반 페이지네이션 구현하기
Lifealong - Apache JMeter를 사용해보자

profile
아는 척 하기 좋아하는 콩

0개의 댓글