티스토리 뷰

728x90
SMALL

배경

사실 예전부터 무한 스크롤에 관심이 있었다. 프로젝트를 만들다보면 몇가지 갈림길에 놓이는 경우가 많다. 내가 경험한 바로는 인증에 세션, 토큰(둘다 사용하기도 한다), 스타일링에 styled-components, scss, css module, 게시판 구현방식에 무한 스크롤, 페이징 등이 있다. 나머지는 다 해봤는데 무한 스크롤은 한번도 해본적이 없어서 꼭 해보고 싶었다.

무작정 생각해보기

서버

일단 어떻게 할까 생각을 해봤다. 서버부터 생각을 해보자. 정렬방법에는 여러가지가 있지만 일단은 가장 기본적인 pk desc로 생각을 해보자. 처음에 위에서부터 데이터를 가져오고 그다음에는 id를 받아서 where문에 id보다 작은값을 적용하면 끝날것같다. 그런데 이 방법으로하면 인기순같은 정렬을 해결할 수 없다. 그러면 페이징을 할때처럼 1페이지, 2페이지를 서버에 넘겨서 offset을 적용하면 어떨까?? 글이 무한 스크롤링 도중에 만들어진다면 만들어진 글이 겹치게 된다. 이해가안된다면 인기많은 사이트의 게시판에 들어가서 페이지를 넘겨보면 된다. 인기글은 잘 모르겠다. 일단은 가장 기본부터 해보자. 서버는 대충 끝난것같다.

프론트

이제 프론트를 생각해보자. 가장 기본적인 방법은 스크롤에 이벤트를 걸고 맨아래에 갔을 때 새로운 데이터를 불러오는 것이다. 어떻게 맨 아래에 있는지 확인하는 것은 어려운 문제가 아니다. 일단 머리속에 기본 틀을 잡자. 그런데 이렇게하면 디바운스 스로틀링을 생각해야 한다. 그것도 그렇고 스크롤 전체에 이벤트를 거는 것은 효율적인 방법은 아니다. 그러면 더 좋은 방법은 뭐가있을까??
구글링하다가 Intersection_Observer_API라는 것을 발견했다. 두 가지 방법 모두 구현해보자

https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API

 

Intersection Observer API - Web API | MDN

Intersection Observer API는 타겟 요소와 상위 요소 또는 최상위 document 의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 방법입니다.Intersection Observer API는 타겟 요소와 상위 요소 또는

developer.mozilla.org

구현하기

스크롤 이벤트

서버는 위에서 생각한 것을 그냥 그대로 구현하면 되서 생략한다.
먼저 가장 아래 스크롤을 불러오는 방법을 찾아보자. 웹에서 브라우저 창의 크기를 나타내는 방법은 정말 많고 조금씩 다르다. 추후에 글로 정리해서 올려야겠다.
(window.innerHeight + window.scrollY) >= document.body.offsetHeight 이 경우가 true이면 스크롤이 가장 아래에 있다고 볼 수 있다.

innerHeight는 브라우저의 높이이다. 순수한 높이이며 위의 즐겨찾기라던가 아래의 옵션창을 제외한 높이이다. 즉 뷰포트 높이다.
scrooY는 스크롤이 Y축으로 얼마나 스크롤됐는지 픽셀 단위로 나타낸다. 즉 스크롤이 맨 위에 있을때에는 0이고 아래로 내릴수록 값이 증가한다.
offHeight는 페이지의 전체 높이를 의미한다. borders, padding, scrollbars를 포함한다.

즉 현재의 y좌표값을 얻고 그 값이 페이지 맨 아래의 y좌표값 이상이면 이벤트를 발생시키는 것이다.

대충 만들어보면

window.onscroll = () => {
  if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
    fn(lastId)
  }
};

이런 식이 되겠다. 여기다가 디바운스 스로틀링 처리를 하면 끝이다.

IntersectionObserver

Intersection Observer API 의 IntersectionObserver 인터페이스는 대상 요소와 그 상위 요소 혹은 최상위 도큐먼트인 viewport와의 교차 영역에 대한 변화를 비동기적으로 감지할 수 있도록 도와줍니다.

  • 페이지가 스크롤 되는 도중에 발생하는 이미지나 다른 컨텐츠의 지연 로딩.
  • 스크롤 시에, 더 많은 컨텐츠가 로드 및 렌더링되어 사용자가 페이지를 이동하지 않아도 되게 하는 infinite-scroll 을 구현.
  • 광고 수익을 계산하기 위한 용도로 광고의 가시성 보고.
  • 사용자에게 결과가 표시되는 여부에 따라 작업이나 애니메이션을 수행할 지 여부를 결정.
    by mdn

나는 여기서 infinite-scroll을 구현할 것이다.
const observer = new IntersectionObserver(callback, options); 으로 처음에 인스턴스를 생성하자.

callback

let callback = (entries, observer) => {
  entries.forEach(entry => {
    // Each entry describes an intersection change for one observed
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
};

callback는 entries와 obseerver라는 파라미터를 가진다.

먼저 entries는 관찰하는 여러값들의 배열이다. 더 정확히는 IntersectionObserverEntry의 배열이다.
observer는 IntersectionObserver 객체를 반환한다.

다음은 IntersectionObserverEntry의 옵션이다.

  • boundingClientRect: 관찰 대상의 사각형 정보(DOMRectReadOnly)
  • intersectionRect: 관찰 대상의 교차한 영역 정보(DOMRectReadOnly)
  • intersectionRatio: 관찰 대상의 교차한 영역 백분율(intersectionRect 영역에서 boundingClientRect 영역까지 비율, Number)
  • isIntersecting: 관찰 대상의 교차 상태(Boolean)
  • rootBounds: 지정한 루트 요소의 사각형 정보(DOMRectReadOnly)
  • target: 관찰 대상 요소(Element)
  • time: 변경이 발생한 시간 정보(DOMHighResTimeStamp)
    https://heropy.blog/2019/10/27/intersection-observer/

여러가지 옵션이 있는데 나는 isIntersecting만 사용했다. 내가 원하는 요소가 보이면 true가 되어서 그때만 함수가 실행되는 것이다. 아래 옵션의 threshold에서 세부적인 설정을 할 수 있다.

options

let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
}
  • root의 기본값은 브라우저 뷰포트이며 대상 요소를 감시할 상위 요소이다.
  • rootMargin은 css의 margin과 똑같다. 기본값은 0 0 0 0이다.
  • threshold는 observer의 콜백이 실행될 대상 요소의 가시성 퍼센티지를 나타내는 단일 숫자 혹은 숫자 배열이다. 0.3이라면 30퍼센트가 보이면 콜백함수를 실행하는 것이다. 기본값은 0이다.

메소드

  • IntersectionObserver.disconnect() : IntersectionObserver 가 어떤 대상이라도 감시하는 것을 중지합니다.
  • IntersectionObserver.observe() : 대상 요소에 대한 감시를 시작합니다.
  • IntersectionObserver.takeRecords() : 모든 감시되는 대상의 배열 (IntersectionObserverEntry (en-US)) 을 리턴합니다.
  • IntersectionObserver.unobserve() : 특정 대상 요소를 감시하는 것을 중지합니다.

메소드는 상당히 직관적이라 추가 설명이 필요가 없다.

IntersectionObserver로 무한 스크롤 구현하기

이제 직접 코드로 들어가 보겠다. 나는 바닐라자바스크립트로 컴포넌트를 만들어서 리액트나 그냥 자바스크립트로 구현한것과는 조금 다르다. 하지만 개념은 클래스형 리액트와 비슷해서 알아볼 수 있을것이다.

import axios from 'axios';
import Component from '../../lib/component';
import userStore from '../../store/user-store';
import { parseTime } from '../../utils';
import { readPostListApi, readPostListByLastIdApi } from '../../utils/api/post';
import Button from '../common/button';
import './style.css';

class PostList extends Component {
  setup() {
    this.state = { user: undefined, postList: undefined };
    this.existPost = true;
    this.getDataForScroll = this.getDataForScroll.bind(this);
  }

  markup() {
    const { user, postList } = this.state;
    return /* html */ `
    ${user ? '<inside class="btn-wrtie-inline"></inside>' : ''}
    <ul class="post-list">
      ${
        postList
          ? postList
              .map(({ id, title, content, createdAt, user: { nickname } }) => {
                return /* html */ `
              <li class="post-list-item">
                <a href="/post/${id}">
                  <div class="icon image-ready"></div>
                  <div class="title">${title}</div>
                  <div class="content">${content}</div>
                  <div class="flex">
                    <div class="time">${parseTime(createdAt)}</div>
                    <div class="nickname">${nickname}</div>
                  </div>
                </a>
              </li>
              `;
              })
              .join('')
          : ''
      }
    </ul>
    `;
  }

  appendComponent(target) {
    const $btnWrite = target.querySelector('.btn-wrtie-inline');
    if (this.state.user) {
      new Button($btnWrite, { class: 'small right', href: '/write', text: '글쓰기' });
    }
  }

  async componentDidMount() {
    userStore.subscribe(() => this.setState({ user: userStore.state.user }, true));
    await this.getPostList();
    this.infiniteScroll();
  }

  async getPostList() {
    try {
      const {
        data: { postList },
      } = await readPostListApi();
      this.setState({ postList }, true);
    } catch (err) {
      if (axios.isAxiosError(err)) {
        console.log(err);
      } else {
        console.log('내부 에러');
      }
    }
  }

  infiniteScroll() {
    let [$li, lastId] = this.getDataForScroll();

    const io = new IntersectionObserver(
      async entries => {
        if (entries[0].isIntersecting) {
          io.unobserve($li);
          await this.getPostListByLastId(lastId);
          [$li, lastId] = this.getDataForScroll();
          io.observe($li);

          if (!this.existPost) {
            io.disconnect();
          }
        }
      },
      {
        threshold: 0.5,
      },
    );

    io.observe($li);
  }

  async getPostListByLastId(lastId) {
    try {
      const {
        data: { postList: nextPostList },
      } = await readPostListByLastIdApi({ lastId });
      if (nextPostList.length === 0) {
        this.existPost = false;
      } else {
        this.setState({ postList: [...this.state.postList, ...nextPostList] });
      }
    } catch (err) {
      if (axios.isAxiosError(err)) {
        console.log(err);
      } else {
        console.log('내부 에러');
      }
    }
  }

  getDataForScroll() {
    const $li = this.target.querySelector('.post-list-item:last-child');
    const hrefSplit = $li.firstElementChild.href.split('/');
    const lastId = hrefSplit[hrefSplit.length - 1];
    return [$li, lastId];
  }
}

export default PostList;

코드 전체를 읽을 필요는 없다. 아직 미구현인 부분도 많다. 먼저 componentDidMount에서 postlist를 가져오고 infiniteScroll 메서드를 실행한다. await를 사용한 이유는 postlist가 없으면 처음에 observe 할 수 없기 때문이다. 리덕스를 사용한다면 그때 dispatch를 일으킨다고 생각하면 된다. 리액트라면 componentdidupdate 또는 useEffect를 사용해서 할 수도 있다.

  infiniteScroll() {
    let [$li, lastId] = this.getDataForScroll();

    const io = new IntersectionObserver(
      async entries => {
        if (entries[0].isIntersecting) {
          io.unobserve($li);
          await this.getPostListByLastId(lastId);
          [$li, lastId] = this.getDataForScroll();
          io.observe($li);

          if (!this.existPost) {
            io.disconnect();
          }
        }
      },
      {
        threshold: 0.5,
      },
    );

    io.observe($li);
  }

이부분만 따로 살펴보자
먼저 마지막 li태그와 lastId를 불러오자. 그리고 마지막 글의 50퍼센트 이상이 보인다면 콜백함수가 실행된다. 이때 이전의 관찰은 중지하고 글을 추가로 불러온 후에 마지막 li태그와 lastId를 불러오는 것이다. 그리고 existPost라는 변수(리액트라면 그냥 state에 저장하면 된다)는 더이상 불러올 글이 없다면 false로 재할당한다. 이 경우에는 connect를 끊어버린다. 이렇게 하면 무한스크롤이 완성되었다.

728x90
LIST
댓글
공지사항