무한 스크롤은 기존에 페이지를 눌러 이동하는 방법과 다르게 사용자가 보고있는 화면이 가장 아래 도달하면 그 다음 페이지에 해당하는 아이템을 불러오는 방법이다. 사용자는 그저 스크롤만 내리면 되기 때문에 손가락으로 다른 버튼을 누르지 않아도 된다.모바일에서 메뉴나 버튼을 배치하는 것은 생각보다 어렵기 때문에 좋은 해결책이고 사용자에게 좋은 경험을 줄 수 있다. 하지만 만약에 사용자가 지나쳤던 다른 컨텐츠를 찾을때 불러온 컨텐츠의 수가 많으면 스크롤을 한참 올려야하는 단점이 있다. 그럼에도 불구하고 무한 스크롤은 이미지, 동영상과 같은 컨텐츠를 제공하는 서비스에서 심심치 않게 사용된다.
이번에 무한 스크롤을 구현해 볼 기회가 생겼다. 구현을 하면서 새롭게 알게된 사실과 맞딱뜨린 버그 그리고 개선한 방법을 적어보려고한다.
참고
이 글은 공부하면서 겪은 이야기를 작성하였습니다. 실전 Infinite Scroll with React - kakaoenterprise Thec&를 보고 무한 스크롤을 구현하였습니다. 이 글을 읽으시는 걸 추천드립니다.
스크롤 이벤트와 useQuery
스크롤 이벤트를 구현하기 위해 꼭 알아야 할 것들
스크롤 이벤트
스크롤 이벤트는 당연하게도 스크롤이 있어야 이벤트를 발생시킬 수 있다. 그렇기 때문에 초반에 스크롤 이벤트를 발생시킬 수 있도록 컨텐츠의 높이가 확보되어야 한다. 이벤트를 등록할 때는 자바스크립트의 메서드를 사용한다.
스크롤 이벤트는 마우스 스크롤을 했을 때마다 발생한다. 대표적으로 애플의 상품 소개 페이지가 있다. 비디오에서 동영상 탐색 바를 스크롤이라고 생각하고 사용자에게 보여주고 싶은 컨텐츠를 적절하게 배치하면 된다. 무한 스크롤을 구현하기 위해서는 스크롤 이벤트를 등록하는 방법과 브라우저의 창 사이즈를 자바스크립트로 읽는 방법을 이해하고 사용하면 된다.
화면의 높이
브라우저의 창 사이즈는 document.documentElement 객체에서 가져올 수 있다. javascript.info에 자세한 부분이 잘 정리되어있다.
브라우저 창의 높이는 documentElement.offsetHeight 객체를 통해 가져올 수 있다. offsetHeight는 사용자가 보고 있는 화면을 포함해서 컨텐츠 전체 높이를 알려준다.
현재 사용자가 보고있는 화면의 높이는 window.innerHeight 객체를 통해 가져올 수 있다. 이 값은 창 사이즈가 변경되면 변경된 값을 가져온다. 하지만 사용자가 새로고침을 해야하는데 새로고침 없이 창 사이즈가 변경될 때 변경된 값을 가져오려면 resize 이벤트를 등록해 사용하면 된다.
마지막으로 내가 보고 있는 화면의 위치를 알아야한다. 화면의 위치는 document.documentElement.scrollTop 객체 값을 통해 확인이 가능하다. 정확히 말하면 브라우저 컨텐츠 영역의 가장 윗 변의 위치다.
예제를 통해 각 값들을 콘솔에 출력해보면 다음과 같다.
scrollTop 값은 브라우저 가장 윗 변의 위치이기 때문에 스크롤을 전부 내려도 컨텐츠 전체 높이보다 작다. 그래서 innerHeight값과 더했을 때 컨텐츠 전체 높이와 같아지게 된다. 그럼 우리는 무한 스크롤을 구현할 수 있는 기초를 마련한 샘이다.
가장 끝에 도달 했을 때, 데이터를 가져도록 코드를 작성하면 된다.
offset과 limit
offset은 '배열의 어느 index에서 부터 불러올까?'라는 질문에 대한 답이라고 생각하면 된다. 예를들어 배열이 10개라면 첫번째 index는 0이고 끝의 index는 9다. offset을 0으로 설정하면 배열의 0번 index부터 데이터를 불러온다.
limit는 '몇 개를 보내줄 까?'에 대한 답이다. 배열이 100개인데 limit을 10으로 설정하면 데이터는 10개씩만 불러올 수 있다.
그럼 offset과 limit은 서버로 어떻게 보낼 수 있을까? url을 통해 query 문자열을 서버로 보낼 수 있다.
물음표 뒤의 문자열들은 값이름 = 값으로 쓰고 &으로 구분한다. 나는 express를 사용해서 백앤드를 구현했기 때문에 클라이언트에서 보낸 요청에 담겨있는 값을 req 파라미터를 통해 읽을 수 있다.
express를 통해 두 값을 읽었다면 데이터 베이스에 값을 넘겨주면 된다. 나는 MongoDB를 mongoose를 통해 제어하고 있기 때문에 다음과 같이 코드를 작성했다.
useQuery
React Query(TanStack Query)는 서버 상태를 관리하는데 편리한 기능을 많이 제공한다. react query에서 제공하는 useQuery 훅을 사용해서 데이터 패칭을 조금 편리하게 할 수 있다. 최근에는 fetch보다 axios와 함께 사용하는데 에러 처리를 조금 더 편리하게 할 수 있기 때문이다. 나는 useGetData라는 커스텀 훅을 만들어서 데이터를 가져왔다.
불러온 데이터를 recoil을 사용해 저장한 다음에 불러온 데이터를 recoil에 합치는 방법을 사용했다. 그런데 예상치 못한 에러를 맞이했다.
이렇게 구현한 결과물의 2가지 오류를 정리한다.
-
첫 번째 데이터를 가져온 다음에 두 번째 데이터를 패칭할 때, 처음에 가져왔던 데이터도 함께 불러온다. 그래서 사용자 화면에 데이터가 중복되어 보여진다.
-
해결
- 첫번째 에러는 recoil을 제거하고 useQuery에 offset값을 0으로 고정시켰다. 그리고 limit를 변경하는 방법으로 변경했다. 그러자 첫번 쨰 에러는 해결할 수 있었다.
-
한계
- 지금이야 데이터가 60개밖에 안되니까 별 문제 없지만 1000만개라고 하면 데이터를 불러오는데 많이 느려지지 않을까?(페이지 끝으로 갈 수록 데이터를 누적해서 불러오기 때문이다.)
-
-
사용자가 세부 사항을 보려고 아이템을 클릭하면 page가 unmount되면서 refetching이 일어난다. 그래서 세부 사항이 사라진다.(이 부분은 내가 디테일 컴포넌트를 구현 방법 때문에 문제가 생긴 것 같다.)
두 번째는 해결하지 못했다. 리스트가 사라져버린다. 예상컨데 컴포넌트가 언마운트 되면서 가지고 있던 데이터를 잃어버리는 것 같았다. 그리고 마운트 시에 limit와 offset이 초기화 되기 때문에 발생하는 문제인 것 같다. 함수형 컴포넌트는 실행 될 때마다 랜더링이 다시 발생하기 때문에 선언된 변수 값들이 초기화되기 때문이다. 하지만 분명 캐싱이 발생할 텐데... 왜 데이터를 잃어버릴까? 하지만 왜 그런지 아직 원인을 찾지 못했다.
Intersection Observer와 useInfiniteQuery
Intersection Observer는 무엇일까?
참고
Intersection Observer API - MDN
사용 예제
위 링크 - A Simple Example
Intersection Observer는 어떤 대상이 viewport에 들어와 있는지 없는지를 관찰한다.
위 링크의 예제 코드를 보고 어떻게 동작하는지 기본 컨셉을 익혔다.
IntersectionObserver 생성자 함수는 콜백 함수와 options를 받아 IntersectionObserver 객체를 생성한다.
options.root는 교차 영역 계산에 사용하는 바운딩 박스의 기준이 되는 Element이다. null일 경우 최상위 문서의 뷰포트를 사용한다.
options.rootMargin은 반드시 px단위로 값을 주어야한다. 계산 용도로 root의 범위를 늘리고 줄이기 위해 사용하는 값이다. px이나 %로만 지정 가능하다.
options.threshold는 콜백이 실행되기 전에 관찰 대상이 되는 element가 얼마나 보여야하는지를 백분율로 표시한 값이다.
observe는 관찰 대상이다.
IntersectionObserver의 예제를 직접 시연해보면서 지금까지 사용했던 스크롤 이벤트를 대체 해 볼 수도 있겠다는 생각을 하게 되었다.
useInfiniteQuery는 사용하기 어려웠다?
처음에 useInfiniteQuery를 생각하지 않은 것은 아니다. 그러니까 사실 무한 스크롤 구현의 시작점은 useInfiniteQuery였다. 그런데 typescript가 계속 불만을 표시했다. 그러다 결국 사용을 포기하고 돌아가던 찰나에 실전 Infinite Scroll with React를 만나 결국 시도에 성공할 수 있었다.
pageParam은 처음에 0으로 기본값을 넣었다.(자기가 넣고 싶은 갚을 넣으면 된다.) 0부터 불러오고 싶기 떄문이다. 옵션값에 getNextPageParam에서 pageNumber를 증가시키도록 하는데 증가가 되면 pageParam이 증가한다.
이렇게 받아온 값은 pages와 pageParams로 저장되어 넘어온다. 처음에 이해가 안됐던 것은 pages 값이 왜 array로 되어있냐는 것이었다. 그런데 페이지를 두번째 세번째 불러오니까 이해가 되었다.
useMemo는 이럴 때 사용할 수 있겠다!?
실전 Infinite Scroll with React를 보면서 useMemo는 이럴때 사용할 수 있겠다 싶었던 부분이 있었다.
params를 불러와 배열을 평탄화를 하는데 useMemo를 사용하였다. 무릎을 탁 치게 되는 순간?
함수가 재실행 될 때마다 함수 안에 있는 값들은 초기화가 되기 때문에 데이터를 상태에 담아 사용하면 초기화가 될 것이다. 하지만 useMemo를 사용하면 메모리에 특정 값을 저장하여 함수가 재실행 되어도 초기화되지 않는다. 그렇다면 아이템을 눌렀을 때 컴포넌트가 unmount, mount 상태로 변해도 초기화가 되지 않을 것이다.
- 최종 구현 화면
마무리
프론트 앤드 개발자라면 한 번씩은 다 해본다는 무한 스크롤을 구현해보았다. 그동안 직접 구현은 고사하고 다른 사람 코드를 복사해쓰기 바빴었다. 하지만 이번 기회를 통해서 원리를 하나하나 따져보고 사용해보려고 노력했다.
브라우저의 넓이와 높이, 컨텐츠의 넓이와 높이, 스크롤의 위치 등을 아는 것은 기술자에게 도구가 하나 늘어나는 것과 같다. 프론트 앤드 개발자는 어찌됐든 브라우저에 대해서 하나라도 더 이해하고 넘어가는 것이 중요한 것 같다. 여전히 모르는 것이 많은 것이 아쉬운 부분이지만 하나하나 채워 나가야할 것 같다.
Intersection Observer API는 유용한 점이 많다는 점을 알게 되었다. Intersection Observer API를 사용해서 기존에 스크롤 애니메이션으로 구현했던 부분을 한번 바꿔 구현해보는 시도를 해야봐야겠다. 또한 계속 만지작 거리는 메인 화면의 랜딩 페이지를 Intersection Observer API를 사용해서 애니메이션을 구현해보는 건 어떨까 하는 생각을 하게 되었다. 다음에 기회가 된다면 그 부분을 소개하고 싶다.
useMemo는 사용을 어떻게 해야할지 아직 감이 오지 않는 부분이 있다. 하지만 이번 기회에 조금은 감을 얻은 것 같다. 하지만 전역 상태 관리 도구를 사용한다면 굳이 useMemo가 필요할지는 아직 잘 모르겠다. useMemo는 성능 개선이라는 주제로 많이 나오지만 메모리에 특정 부분을 차지하기 때문에 꼭 '성능 개선'이라고 콕 집어서 말하기는 어렵다고 생각한다. 어떤 상황에서 사용 해야 할 지 고민하고 사용하면 될 것 같다.
성장하는 개발자가 되기 위해서 내가 가장 중요하게 생각하는 것은 게으르기 위해서 최상의 방법을 간구하는 것이다. 아이러니하게 그것을 추구하기 위해서 열심히 배워야한다. 게으르게 되는 순간은 딱 한 순간이다. 취업 준비를 하는 도중에 태국으로 오게 되었는데 그닥 놀지는 못하고 한국에서보다 더 열심히 공부를 하고 있다. 오히려 이곳에서 하루의 루틴을 잡아가고 있다. 영감의 대상은 무라카미 하루키다. 무라카미 하루키가 매일 똑같은 루틴으로 삶을 살아가는게 신기했었다. 그의 책을 읽을 때 그냥 '신기하다.' '멋있다.' 정도였는데 어느덧 하루키처럼 달리기를 매일 하더니 하루키처럼 아침에 일어나서 똑같은 시간에 무엇을 하는 것을 시도해보고있다. 어차피 지인을 만나는 것 아니면 주변에 태국어 말고는 들리는 말이 없으니 최상의 조건이 아닐까?