Frontend

무한 스크롤을 구현하는 두 가지 방법

date
Jul 20, 2023
thumbnail
infinite-scroll-thumbnailpng.png
slug
infinite-scroll
author
status
Public
tags
TIL
Javascript
IntersectionObserver
summary
Scroll 이벤트 방식과 Intersection Observer 방식으로 무한 스크롤을 구현해보자
type
Post
category
Frontend
 

무한 스크롤이란?


콘텐츠 페이지네이션(pagination) 기법 중 하나로, 아래로 스크롤하다 컨텐츠의 마지막 요소를 볼 즈음 다음 컨텐츠가 있으면 불러오는 방식이다. 영문으로는 Infinite Scroll이라고 한다. Facebook, Twitter, Instagram 등 SNS에서 주로 사용되었다.
 
최근 법적인 이슈가 조금 있는 방식이다. 미국 특정 주에서 이 방식이 슬롯머신과 같이 사용자들을 중독시킬 수 있다는 우려가 나와 금지가 될 가능성이 있다고 한다.
 
notion image
 

구현 방식


  1. 전통적인 방식 window 전역 객체의 scroll 이벤트를 활용하는 방식은, 스크롤링이 일어날 때마다 화면 전체의 height와 스크롤 위치를 통해 스크롤이 콘텐츠의 끝 즈음에 다다랐는지 체크해서 처리하는 방식이다.
  1. 비교적 최근 방식 Intersection Obserser API를 활용하는 방식은, 매번 스크롤을 감지하는 것이 아니라 내가 지정한 DOM 객체가 사용자가 보고 있는 시야를 기준으로 판단하여 구현하는 방식이다.
 
 
 

Scroll Event 활용 방식


컴포넌트의 구조

notion image
 
PhotoList 컴포넌트가 그리고 있는 화면에서 스크롤을 내려 화면 가장 아래 끝에 거의 닿았다면 onScrollEnded 콜백을 호출하여 App 컴포넌트에게 제어권을 넘겨준다.
 

사용할 API

🚀
GET https://***-*****.***********.**.**/cat-photos?_limit=5&_start=0
  • 사진을 받아오는 간단한 API
  • limit: 한번에 가져올 개수
  • start: 어디부터 가져올지 결정
 
예시
const API_URL = `https://***.****.*****/cat-photos?_limit=${limit}$_start=${start}`
 

디렉토리 구조

notion image
 
 

주요 코드

window.addEventListener('scroll', () => { const { isLoading, totalCount, photos } = this.state // NOTE 수식이 들어있는 값들은 변수 안에 담는 습관을 가질 것 // NOTE 스크롤 할 때마다 계속 수식을 계산하기 때문에 퍼포먼스 이슈가 있음 const isScrollEnded = (window.innerHeight + window.scrollY) + 100 >= document.body.offsetHeight // isLoading을 체크해서 한번씩만 불러오게끔 한다. // 불러온 사진 배열의 길이가 전체 데이터 개수보다 작을 때에만 가져온다 if (isScrollEnded && !isLoading && photos.length < totalCount) { // console.log(`infinite scroll load`) console.log(photos.length) console.log(totalCount) onScrollEnded() } })
  • PhotoList 컴포넌트 내에 window 전역 객체에 이벤트 리스너를 생성한다.
  • 사용자가 스크롤을 할 경우, isScrollEnded라는 변수 안에 “사용자의 현재 스크롤 위치가 페이지의 맨 아래에 도달했는지를 체크”하는 값을 넣어준다.
    • window.innerHeight는 현재 브라우저 창의 높이를 나타낸다.
    • window.scrollY는 현재 스크롤된 Y축 위치를 나타낸다.
    • document.body.offsetHeight는 페이지 전체의 높이를 나타내며, 스크롤되지 않은 부분까지 포함된 전체 높이를 의미한다.
    • 페이지의 끝에서 100px 정도 위에 위치하더라도 체크될 수 있도록 여유를 주었다.
  • onScrollEnded()가 동작하는 조건으로 3가지를 준다.
    • 스크롤 위치가 페이지의 맨 아래에 도달한 상태인지
    • 상위 컴포넌트 App의 상태로 설정한 isLoading 값에서 로딩 중이 아닌 경우
    • 현재 불러온 사진 개수가 서버 내 전체 사진 개수보다 적을 때
 
 
onScrollEnded()는 아래와 같이 데이터를 불러오는 콜백 함수이다.
const photoListComponent = new PhotoList({ $target, initialState: { isLoading: this.state.isLoading, photos: this.state.photos, totalCount: this.state.totalCount }, onScrollEnded: async () => { await fetchPhotos() } })
const fetchPhotos = async () => { this.setState({ ...this.state, isLoading: true }) const { limit, nextStart } = this.state const photos = await request(`/cat-photos?_limit=${limit}&_start=${nextStart}`) this.setState({ ...this.state, nextStart: nextStart + limit, photos: this.state.photos.concat(photos), // this.state.photos 배열에 합쳐진 새로운 배열을 만든다. // photos: [ ...this.state.photos, ...photos ], isLoading: false }) }
 
 
 

intersectionObserver API 활용 방식


  • IE에서는 지원하지 않기 때문에 폴리필(Polyfill)을 적용해야 동작한다.
  • caniuse에서 브라우저 호환성을 확인해보자. 오래된 브라우저들은 지원하지 않는다.
 
이미지 지연 로딩과 같은 기능에도 intersection Observer를 사용하여 구현하기도 한다.
 
 

주요 코드

// PhotoList.js // ... const observer = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.isIntersecting && !this.state.isLoading) { console.log('화면 끝!!', entry) if (this.state.photos.length < this.state.totalCount) { onScrollEnded() } } }) }, { // root: null, // null이거나 적지 않으면 기본값이 브라우저 viewport가 된다. // rootMargin: '?px' // 화면에 보이는 것보다 여백을 더 주고 싶은 경우에 사용 // 루트 마진이 음수일 경우 설정 값보다 여백을 더 넘겨야만 콜백 호출 threshold: 0.5 // 교차점 비율 옵션이다. 0이면 뷰포트에 걸리자마자, // 1이면 뷰포트 내에 전체가 다 들어왔을 때 동작 (0~1 사이 값) }) let $lastLi = null // 처음엔 렌더링이 안되어있으니 null this.render = () => { //... 사진 li 추가하는 로직 const $nextLi = $photos.querySelector('li:last-child') if ($nextLi !== null) { if ($lastLi !== null) { observer.unobserve($lastLi) } $lastLi = $nextLi observer.observe($lastLi) // 이 lastLi를 감시하도록 지시 } } // ...
  • PhotoList 컴포넌트에 ‘scroll’ 이벤트 리스터 등록 부분을 삭제하고, IntersectionObserver 객체를 만든다.
  • $nextLi$lastLi를 통해 observer가 감시할 요소를 observe, unobserve 등으로 관리한다.
 
 
 
min-height에 대해서 이미지의 사이즈가 정 사이즈가 아니라 사이즈가 각각 다르다면, 어떻게 요소의 min-height를 설정해서 요소의 크기를 일정하게 유지할 수 있을까? 1. API에서 이미지의 사이즈를 함께 보내준다 (가장 무난) 2. 이미지 홀더를 넣어 이미지가 화면에 보이기 전에 디폴트 이미지를 보여주다가, 이미지가 로딩된 후 바꿔치기 (또한 많이 사용됨)
 
 
threshhold에 대해서 0 ~ 1 사이의 값을 옵션으로 줄 수 있다. 교차점 비율에 대한 옵션으로, 감시하고 있는 요소가 화면에 얼마나 보여졌을 때 콜백 함수를 호출할 지 결정할 수 있다. 0일 경우 화면에 해당 요소가 보이자 마자 호출하고, 1일 경우 화면에 해당 요소 전체가 다 렌더링 되었을 때 호출한다.
 
 

정리


무한 스크롤 UI를 구현하는 방법은 크게 두 가지
  • Scroll 이벤트를 이용하여 계산
  • Intersection Observer API 사용
    • observe, unobserve에 주의
    • threshold 값으로 observe 대상이 얼마나 노출 정도에 따라 동작 설정
    • 이미지 지연 로딩 기능 알아보기
 
상황에 따라 무한 스크롤 UI보다는 직접 더보기 버튼 등으로 불러오는 인터랙션을 통해 로딩하는 것이 훨씬 나은 경우도 있다는 점을 기억하자!