티스토리 뷰

무한 스크롤

지난 여름, 우아한 테크코스에서 Step 2 과정으로 3주 동안 미니 프로젝트를 진행했습니다.
저희 팀의 주제는 인스타그램이었는데요.
Spring Boot와 JPA를 기반으로 만들어보고 싶었던 기능들을 마음껏 구현해볼 수 있었던 재미있는 프로젝트였습니다.
시간이 좀 지났지만 JPA의 페이지네이션 기능을 사용해서 만들었던 무한 스크롤을 주제로 간단하게 포스팅을 해보겠습니다.

인스타그램에서 페이지네이션?

인스타그램 서비스의 메인 페이지에는 내가 팔로우하고 있는 사람들의 게시물들이 시간 순서대로 보입니다.
그런 페이지를 만들어 보려고 생각해보니, 인스타그램이라는 서비스에서 [1] [2] [3] [4] ... 와 같이 일반적인 페이지네이션을 구현한다는 것이 굉장히 어색하다는 사실을 인지하지 못하고 있다가 문득 깨달았습니다.
그렇다고 DB에서 모든 데이터를 꺼내와서 게시물들을 그려주는 것도 말이 안 되고, 그래서 말로만 듣던 무한 스크롤을 실제로 구현해보게 되었습니다.

기본적인 개념은 페이지에서 스크롤 이벤트를 감지하면, 전통적인 페이지네이션처럼 게시물들을 일정한 개수의 페이지로 끊어서 가져온다는 것입니다.
여기서 기존 페이지네이션 방식에서는 드러나지 않는 사소하지만 중요한 이슈가 발생하는데, 아래에서 알아보도록 하겠습니다.

머릿속으로 고민했던 구현 계획은 다음과 같았는데요, 코드와 함께 하나씩 살펴보겠습니다.

1. 클라이언트에서 scroll event를 감지한다.
2. 다음 차례의 게시글 목록을 구성하기 위해 아래와 같은 정보를 담아 ajax 콜
    - lastArticleId : 현재까지 페이지에 그려진 게시물 id 중 가장 작은 값
        (역순으로 게시글이 나열되기 때문에 가장 아래 있는 글이 가장 오래된 글)
    - size : 가져올 글의 개수

3. 내가 팔로우하고 있는 Member의 List를 먼저 꺼내와서, 그 Member들의 글들을 기준으로 Article을 꺼낼 준비를 한다.
4. 내 글도 포함해야 하므로 List에 나도 포함시킨다.

5. 클라이언트에서 넘겨준 정보와 List를 조건으로 하여 PageRequest 객체를 사용해서 repository에서 pagination한다.
   이 때 lastArticleId 보다 id 값이 작은 게시물들을 기준으로 한다.
6. PageRequest 객체는 보통 size와 page값을 필요로하는데, 이 때 page는 "무조건 0번 페이지"를 가져오도록 한다.

7. 서버에서 내려준 json을 파싱하여 Article card template을 구성한 후, 화면에 그려준다.

스크롤 이벤트 감지하기

먼저 무한 스크롤 이벤트를 위해 스크롤 이벤트를 감지할 수 있도록 자바스크립트 코드를 작성합니다.
제이쿼리를 지양하고 싶어서, 이 페이지의 6번 방법를 참고하여 바닐라로 스크롤 이벤트를 아래와 같이 작성하였습니다.

// articleController

const defaultArticlePaginationSize = 5; // 한 번 요청으로 가져올 게시글의 개수

const getScrollTop = function () {
    return (window.pageYOffset !== undefined) ? window.pageYOffset : (document.documentElement || document.body.parentNode || document.body).scrollTop;
};

const getDocumentHeight = function () {
    const body = document.body;
    const html = document.documentElement;

    return Math.max(
        body.scrollHeight, body.offsetHeight,
        html.clientHeight, html.scrollHeight, html.offsetHeight
    );
};

const onscroll = function () {
    if (getScrollTop() === getDocumentHeight() - window.innerHeight) {
        const articleCards = document.querySelectorAll('.article-card');

        const lastArticleId = Array.from(articleCards).map(function (card) {
              return parseInt(card.id, 10);
        }).reduce(function (previous, current) {
              return previous > current ? current : previous;
        }); // 현재 DOM에 그려진 게시물 중 가장 작은 id 값을 추려낸다.

        articleService.fetchArticlePages(lastArticleId, defaultArticlePaginationSize); // Ajax 로직 실행
    }
};

onscroll() 함수에서는 스크롤의 위치를 구하는 getScrollTop() 함수와 Document의 높이를 구하는 getDocumentHeight()를 사용하여 스크롤이 맨 끝에 올 경우를 감지합니다.
스크롤이 맨 끝으로 내려온 순간에 페이지네이션을 위한 데이터를 구해서 서버로 ajax 콜을 날립니다.
이 때 현재 게시물의 id 중 가장 작은 값을 추려내고, 그 id 값과 가져올 게시물의 개수를 ajax 콜에 실어서 요청합니다.

axios
    .get('/api/articles', {
        lastArticleId: lastArticleId,
        size: size,
    })
    .then(response => response.data)
    .then(data => {
        data.forEach(function (json) {
            // 가져온 게시물 데이터를 DOM에 그리는 작업
        }
    })
})

간편한 ajax 라이브러리인 axios를 사용하였습니다.
앞서 구한 가장 작은 게시물 id와 게시물의 size를 실어서 서버에 요청을 하고, 페이지네이션되어 날아온 게시물 데이터들을 파싱하여 화면에 게시물을 그리는 작업을 수행해 주면 클라이언트 측에서 해야 할 작업은 끝이 납니다.

가장 작은 게시물 id를 서버로 보내는 이유가 궁금하실 텐데요.
다음 단락에서 설명해 보겠습니다.

무한 스크롤 도중 최신 게시물이 추가된다면?

어떤 사용자가 피드에서 게시물을 무한 스크롤을 통해 조회하는 중입니다.
DB에 해당 사용자가 조회할 글은 순서대로 1번부터 10번까지 존재한다고 가정해 보겠습니다.
최신 게시물부터 볼 수 있어야 하므로 가장 최신 글인 10번 글부터 내림차순으로 게시글을 화면에 보여줄 계획입니다.
페이지네이션 size는 3이라고 하겠습니다.

기존의 페이지네이션 방식을 따른다고 생각해봅시다.
위 그림과 같이 0번째 페이지를 불러오면, 차례로 10, 9, 8번 게시물을 가져옵니다.
그리고 다음 스크롤 이벤트 때는 1번째 페이지인 7, 6, 5번 게시물을 가져옵니다.
또 그 다음 이벤트에서는 2번째 페이지를 가져오겠죠?

그런데 이 때 피드에 새로운 11번 게시물이 추가됩니다.

같은 조건 아래에서 페이지로 잘라서 가져올 데이터 자체가 변경된다면, 그러니까 지금처럼 11번 데이터가 추가되거나 하는 상황이 생긴다면, 페이지네이션의 결과는 번호가 하나씩 밀릴 수밖에 없습니다.
따라서 사용자는 2페이지를 조회했는데, 번호가 밀린 페이지네이션을 받아서 기대했던 4, 3, 2번 글이 아닌 5, 4, 3번 글을 받게 됩니다.
이미 사용자의 피드에는 5번이 그려져 있는 상태인데, 5번 게시물이 중복해서 발생하는 문제가 생깁니다.

일반적인 게시판이었다면 현재 페이지에서 다음 페이지를 조회했을 때, 내가 이전 페이지에서 보았던 페이지가 중복해서 나오더라도 '아, 새로운 게시물이 추가되어서 번호가 밀렸구나'라고 인지했을 것입니다.
하지만 무한 스크롤에서는 하나의 피드에서 모든 게시물들을 연속적으로 조회하다보니 접근하는 관점이 조금 달라졌다고 볼 수 있겠습니다.

동적으로 페이지네이션 조건을 적용하기

따라서 이런 문제를 해결하기 위해 저는 다음과 같은 방법을 적용하였습니다.
클라이언트에서 게시물 요청을 할 때, 현재 피드에 몇 번 게시물까지 그려졌는지를 알려줍니다.
가장 작은 id 값을 구해서 보내주는 것이죠.
그리고 서버 측에서는 해당 id 보다 크기가 작은 id의 게시물들 중에서 size만큼 페이지네이션을 한 후, 항상 0번째 페이지를 가져오도록 합니다.
페이지 번호를 바꾸는 대신, 페이지네이션의 조건을 매번 동적으로 바꿔주는 것입니다.

// ArticleApiController.java

@GetMapping("/api/articles")
public ResponseEntity<List<ArticleResponse>> getArticlePages(@RequestParam Long lastArticleId, @RequestParam int size, MemberSession memberSession) {
    List<ArticleResponse> articleResponses = articleService.fetchArticlePagesBy(lastArticleId, size, memberSession.getId());
    return new ResponseEntity<>(articleResponses, HttpStatus.OK);
}

이제 서버의 코드를 봅시다.

요청한 ajax콜을 받아주는 ArticleApiController의 메소드를 하나 만듭니다.
클라이언트에서 요청한 가장 작은 게시물 id 값과 필요한 게시물의 개수를 받아서 ArticleService로 넘겨줍니다.

MemberSession.getId()는 현재 로그인한 사용자의 id를 세션에서 가져오는 로직입니다.
피드에 뿌려줄 게시물들을 특정하기 위함인데요.
Service 로직을 살펴보겠습니다.

// ArticleService.java

public List<ArticleResponse> fetchArticlePagesBy(Long lastArticleId, int size, Long loginMemberId) {
    Member loginMember = memberService.findById(loginMemberId); // 사용자 객체를 조회한다.
    List<Member> followers = findFollowersWithLoggedInMember(loginMemberId, loginMember); // 사용자를 포함하고, 사용자가 팔로우하고 있는 사람들을 가져온다.
    Page<Article> articles = fetchPages(lastArticleId, size, followers); // followers의 게시물들을 페이지네이션해서 가져온다.

    return ArticleAssembler.toDtos(articles.getContent(), loginMember);
}

private List<Member> findFollowersWithLoggedInMember(Long memberId, Member loginMember) {
    List<Long> followingsIds = followService.findFollowingsIds(memberId);
    List<Member> allMembers = memberService.findAllByIds(followingsIds);
    allMembers.add(loginMember);

    return allMembers;
}

private Page<Article> fetchPages(Long lastArticleId, int size, List<Member> followers) {
    PageRequest pageRequest = PageRequest.of(0, size); // 페이지네이션을 위한 PageRequest, 페이지는 0으로 고정한다.
    return articleRepository.findByIdLessThanAndAuthorInOrderByIdDesc(lastArticleId, followers, pageRequest); // JPA 쿼리 메소드
}

서비스에서는 필요에 따라 다른 서비스 Layer에 요청하기도 합니다.
먼저 로그인한 사용자의 id로 사용자 객체를 불러온 후에, 사용자가 팔로우하고 있는 사람들을 List로 가져옵니다.
이때, 피드에는 다른 사람의 게시물뿐만 아니라 내가 쓴 게시물도 보여야 하므로 해당 List에 로그인하고 있는 사용자 자신도 포함해줍니다.
이제 List를 기준으로 articleRepository에서 조건에 맞게 게시물들을 가져옵니다.

페이지네이션을 위해서는 Spring에서 제공하는 Pageable 인터페이스를 사용하면 되는데, PageRequest 구현체를 사용하면 됩니다.
PageRequest는 new 로 생성하는 것이 Deprecated라, static factory method인 of()로 생성하시면 됩니다.
파라미터에는 원하는 페이지 번호와 size를 넣으면 되는데, 우리는 페이지 번호를 0으로 고정하기로 했으니 페이지 번호는 0으로, size는 클라이언트로부터 받은 size를 넣어줍니다.

그리고 이 PageRequest를 JPA 쿼리 메소드의 마지막 파라미터로 넣어주시면 페이지네이션이 되어서 결과가 나옵니다.
참 편리하죠? 결과는 인터페이스인 Page<> 형태로 나오는데, Page는 Iterable 인터페이스를 상속 받고 있어서 List처럼 써도 되고, 정 불편하면 getContent() 메소드를 통해 List로 변환할 수도 있습니다.

쿼리 메소드 이름에서 보이다시피, 가져올 게시물들의 조건은 다음과 같이 세 가지입니다.

OrderByDesc : 내림차순으로
AuthorIn : followers List에 있는 author(Member) 이면서
findByIdLessThan : lastArticleId보다 작은 값의 id 중에서 게시물을 가져온다.

로직은 이게 끝입니다. 한번 결과를 볼까요.

인스타그램

페이지가 처음 로딩되면, 게시물이 하나도 없는 상태이기 때문에 가장 작은 id를 구할 수가 없습니다.
따라서 클라이언트에서 Number.MAX_SAFE_INTEGER로 가장 큰 수를 실어 보냈습니다.
id가 그 수를 넘어가는 상황을 고려한다면 완전하게 안전한 로직은 아니겠네요.

개발자 도구에서 Request URL을 보면 Query String으로 요청값을 실어 보내는 것을 볼 수 있습니다. 큰 수의 값은 무려 9000조네요...ㅋㅋ

그리고 스크롤을 끝까지 내리면, 다음 차례의 게시글들을 가져옵니다.

내가 만들었지만 잘 되니까 신기해..

사진을 자세히 보시면 스크롤 바의 길이가 짧아진 것을 보실 수 있습니다.
5개씩 잘 가져오네요. 가장 아래에 있었던 게시물의 id가 35였나 봅니다.

이렇게 해서 JPA의 페이지네이션을 이용해 무한 스크롤을 구현한 방법을 살펴봤습니다.
간단하지만 일반적인 페이지네이션과는 조금 달라서 포스팅으로 한번 남겨봅니다.
읽어주셔서 감사합니다. :)

최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday