본문 바로가기
개발 관련 개념들

[React.js] 리액트에서 무한 스크롤 구현 + 모바일 문제 해결

by 코곰 2021. 5. 14.

캣츄 프로젝트의 게시판 목록에서, 페이지네이션의 일종으로 무한 스크롤을 구현하기로 했다.

 

1. 페이지네이션 (Pagination)이란?

백엔드에 저장된 모든 정보를 한 번에 보여주지 않고, 단위갯수별로 보여주는 방법이다.

티스토리 블로그 게시글이 page별로 보여지는 것도 페이지네이션이고,

인스타그램 피드처럼 스크롤을 내릴 때 새로운 글들이 불러와지는 것도 페이지네이션이다.

 

페이지네이션을 제대로 구현하려면 백엔드 API의 구현도, 프론트엔드의 로직도 그에 맞게 설계되어야 한다.

 

페이지네이션 방법

페이지네이션에는 크게 두 방법이 있다.

1. Offset-Based Pagination (오프셋 기반)

: Offset & Limit  이용.

 

* 데이터를 불러오기 시작할 시작점과, 읽어들일 정보의 수를 각각 OFFSET과 LIMIT으로  표현한다!

이미지 출처 - https://www.codeproject.com/Articles/1001614/Using-OFFSET-and-FETCH-with-the-ORDER-BY-clause

 

* 위와 같은 data가 있을 때, 한 페이지 당 5개씩의 정보를 보여주고 싶다고 하자.

  > 1 페이지는 OFFSET (시작점) = 0, LIMIT (읽어들일 정보의 수) = 5

  > 2 페이지는 OFFSET = 5, LIMIT = 5,

  > 3 페이지는 위의 이미지에서처럼, OFFSET = 10, LIMIT = 5가 될 것이다.

 

* 하지만 비교적 구현이 쉬운 이 방법에는 성능 문제가 있는데,

 

(1) 불필요한 메모리 사용 - 느림!!!

* OFFSET, LIMIT을 이용할 때 데이터베이스는 Full Table Scan (순차접근)을 한다.

* 원하는 데이터에 도달하기까지의 정보를 하나 하나 읽고, 원하는 데이터를 반환하는 것이다!

출처 - https://ivopereira.net/efficient-pagination-dont-use-offset-limit

* 예를 들어 OFFSET = 50,000,000, LIMIT = 20일 때는, 앞의 50,000,000의 데이터가 필요 없음에도 불구하고,

원하는 20개의 데이터를 반환하기 위해 여전히 50,000,020개의 데이터를 읽어야한다는 뜻이다!

 

(2) 중첩된 내용 반환의 가능성

* 어떠한 게시판을 사용할 때, 1페이지에서 5개의 글을 읽었다고 생각해보자.

* 2페이지로 가면, OFFSET = 5, LIMIT = 5을 통해 '그 다음' 5개의 글을 읽고 싶을 것이다!

 

* 하지만 그 게시판이 정말 활발하여, 내가 1페이지의 글을 읽고 2페이지로 가기 전에 새롭게 5개의 글이 추가되었다면,

* 2페이지에 가도 내가 1페이지에서 읽었던 5개의 글이 그대로 보여질 것이다.

 

2. Cursor-Based Pagination (커서 기반)

: lastPostId & Size 이용.

 

* 그래서 요즘 많이 사용되는 방법이, lastPostId와 Size를 이용한 Cursor-Based Pagination이겠다!

이미지 출처 - https://dev.to/jefferyhus/graphql-pagination-5d5b

 

* lastPostId, 즉 내가 현 페이지에서 읽은 포스트의 가장 마지막 글의 Id와

* 그 Id로부터 시작해 다음 몇 개의 포스트를 읽어들일 지를 알려주는 SIZE를 매개변수로,

* 다음 페이지의 글들을 불러오는 것이다.

 

* 이 방법을 사용하면, 중간에 게시글의 추가나 삭제가 일어나더라도, 내가 전 페이지에서 읽었던 게시글 이후의 것들을 다음 페이지에서 보는 것이 보장되고,

* 앞에서부터 원하는 데이터의 장소까지 쭉 읽어야하는 상황도 발생하지 않는다.

 

* 정말 설명 잘 해주신 블로그 - https://wbluke.tistory.com/18

 

JPA Pagination을 이용한 무한 스크롤 구현기

무한 스크롤 지난 여름, 우아한 테크코스에서 Step 2 과정으로 3주 동안 미니 프로젝트를 진행했습니다. 저희 팀의 주제는 인스타그램 이었는데요. Spring Boot와 JPA를 기반으로 만들어보고 싶었던

wbluke.tistory.com

 

 

2. 그럼 어떻게 사용자가 얼마만큼 스크롤했는 지 아나요?

 

* 무한 스크롤 방법으로 구현하기에, 스크롤에 대한 이해가 먼저였다.

   > window.scrollY  - 얼마나 내렸는 지

   > document.documentElement.clientHeight - 화면 보이는 길이

   > document.documentElement.scrollHeight - 총 길이

   위의 세 가지를 이용하면 알 수 있다.

 

이미지  출처 -  https://stackoverflow.com/questions/2940006/can-someone-canonically-differentiate-between-scrolltop-and-scrollheight 

console.log(window.scrollY, document.documentElement.clientHeight, document.documentElement.scrollHeight);

을 통해 화면을 직접 보면 이해가 쉬운데,

와 같이,

맨 밑까지 내렸을 때 (위 이미지의 세번째 줄 경우)

 

window.scrollY + document.documentElement.clientHeight = document.documentElement.scrollHeight;

 

이 되는 것이다.

 

3. 오키, 그래서 React에서 어떻게 구현했나요?

* 일단 백엔드 팀에서 API를 잘 수정해주셨다!

API request 할 때 lastPostId와 size를 보내주게 되어있다.

 

* 리액트 코드는 제로초님 강의와 구글링을 통해 구현했다. 나의 경우 postList  페이지에 로직을 구현했고, 최초 컴포넌트 마운트 시 보여질 게시글들을 이렇게 구현했다

 

const PostList = () => {
    const dispatch = useDispatch();
    // mainPosts : 게시글 불러와 보관해 줄 중앙 저장소
    // hasMorePosts : 불러올 게시글이 남았는 지 판단해주는 변수
    // listPostLoading : 현재 비동기 액션을 통해 게시글을 불러오고 있는 지 저장해주는 변수
    const {mainPosts, hasMorePosts, listPostLoading} = useSelector((state) => state.post);
	// 한 번에 불러올 게시글 갯수
    const SIZE = 5
    
    // 맨 처음 컴포넌트가 마운트 되었을 때 실행
    // 처음 다섯 개의 게시글
    useEffect(() => {
        // 새롭게 처음 다섯 개의 게시글 불러옴
        dispatch({
            type: LIST_POST_REQUEST,
            data: {
                size: SIZE,
                lastPostId: Number.MAX_SAFE_INTEGER  //맨 처음에는, 읽었던 게시글 중 마지막 것의
                									// 아이디 (lastPostId)를 아는 것이 불가능하다
                                                    // 따라서 입력할 수 있는 최대의 숫자를 넣어주면
                                                    // 그 숫자보다 낮은 숫자를 id로 가진 포스트 중
                                                    // 처음 5개를 불러오므로
                                                    // 이렇게 초기화할 수 있다.
            }
        })
    }, []);
    // 두 번째 파라미터로 빈 배열이 오므로 최초에 컴포넌트가 마운트 되었을 때만 실행

 

* 그리구 실제적 무한스크롤 부분은  다음과 같이 구현을 하였다!

 

useEffect(() => {
        function onScroll(){
            // 많이 쓰는 세 가지
            // scrollY: 얼마나 내렸는 지
            // clientHeight: 화면 보이는 길이
            // scrollHeight: 총 길이
            // 따라서 끝까지 내렸을 때
            // scrollY + clientHeight=scrollHeight!!
            
            // 내 경우 스크롤이 화면에 밑에서 10px 위까지 왔을 때 새로 게시글 호출함
            if (window.scrollY + document.documentElement.clientHeight > document.documentElement.scrollHeight  -10) {
                if (hasMorePosts && !listPostLoading && mainPosts){
                    dispatch({
                        type: LIST_POST_REQUEST,
                        data: {
                            lastPostId: mainPosts[mainPosts.length-1].id, // 지금 보고 있는 게시글 중
                            												// 맨 마지막 게시글 id
                            size: SIZE
                        }
                    });
                }
            }
        }
        window.addEventListener('scroll', onScroll);
        //useEffect에서 window함수 쓸 때 중요한 건
        //이렇게 해제해주는 것
        //메모리 누수 방지
        return() => {
            window.removeEventListener('scroll', onScroll);
        };
    },  [mainPosts, hasMorePosts, listPostLoading]);

 

* hasMorePosts는 불러올 게시글이 더 있나를 판단해주는 변수인데,

* post 리듀서 쪽에서 initialState는 true로 초기화를 해주고,

case LOAD_POST_SUCCESS:
	...
	draft.hasMorePosts = action.data.length === 5;
    ...
    break;

 

* 새로 게시글을 불러올 때마다, 내가 지정한 게시글 갯수만큼 불러와지는 지로 true/false를 판별하면 될 것이다.

EX. SIZE = 5로 설정했는데, 불러와진 게시글의 갯수가 3개라면, 5개를 불러올 수 없기에 = 게시글이 더 없기에 3개만 불러와진 것. 따라서 이 경우 hasMorePosts는 false로 설정하면 된다!

 

 

짜잔!! ㅎㅎㅎ

완성본! 스크롤을 내릴수록 게시글을 불러오는 요청이 새로 나가는 것을 확인할 수 있다! ㅎㅎ

 

 

+ 추가 (210521)

모바일 기기에서 확인하니, 무한 스크롤이 구현되지 않았다.

구글링 후 해결한 결과론,

 

1) document.documentElement.ClientHeight이 모종의 이유로 데스크톱에서와 다르게 나온다.2) 터치 기기에서는 'touchmove'라는 특별한 이벤트가 있다.

 

따라서 위의 코드에서

 

1) document.documentElement.ClientHeight  ->  window.innerHeight

(데스크톱에서도, 우리 앱은 화면 전체 높이를 차지하니 이렇게 바꿔도 문제가 없다)

 

2) window.addEventListener('touchmove', onScroll)과 window.removeEventListener('touchmove', onScroll)추가

 

이렇게 바꿔주니 문제가 해결되었다

댓글