본문 바로가기
Developer/Javascript 해부학

무한스크롤에서 가상스크롤까지 (무한스크롤편)

by 해적거북 2024. 10. 13.
728x90

목차

  1. 무한스크롤 알아보기
    1. 배경
    2. GPT에게 물어보자
  2. 무한스크롤 만들기
    1. 설계하기
    2. 코드
  3. 더 생각해보기
    1. 스크롤 위치 기억하기
    2. 보이는 요소만 그리기

무한스크롤 알아보기

배경

회사는 현재 서비스 개발 환경을 개선하기 위해 RN에서 Next.js로 전환 중입니다. 이 과정에서 흩어져 있던 컴포넌트를 모아 공통 컴포넌트를 추출하고 구현하는 작업도 함께 진행하고 있습니다. 그 중 제가 담당한 무한 스크롤 개발을 하면서 알게 된 내용을 정리해 이 글을 작성하게 되었습니다.

GPT에게 물어보자

무한 스크롤이란 무엇이며, 어디서부터 시작된 걸까? GPT에게 물어봤더니 용어 설명과 배경뿐만 아니라 특징과 추가로 고려해야 할 점까지 답변해주었다. 이 글에서는 먼저 무한 스크롤의 개념과 배경을 알아보고, 특징과 더 생각해볼 부분은 구현 후에 따로 살펴보겠다.

👤: What is 'infinite scroll'? Please explain the term and its background to me.

🤖: Infinite scroll is a web design technique that allows users to scroll through content continuously without needing to click on pagination links. As users reach the bottom of the visible content, more content is automatically loaded and appended, giving the appearance of an endless page. It's commonly used in social media feeds, news websites, and e-commerce platforms to keep users engaged by providing a smooth and uninterrupted browsing experience.
무한 스크롤은 사용자가 페이지 매김 링크를 클릭할 필요 없이 콘텐츠를 계속 스크롤할 수 있는 웹 디자인 기법입니다. 사용자가 보이는 콘텐츠의 하단에 도달하면 자동으로 더 많은 콘텐츠가 로드되고 추가되어 끝없는 페이지처럼 보이게 됩니다. 일반적으로 소셜 미디어 피드, 뉴스 웹사이트, 이커머스 플랫폼에서 원활하고 끊김 없는 브라우징 경험을 제공하여 사용자의 참여를 유도하는 데 사용됩니다.
Backgrounds: Infinite scroll became popular with the rise of social media platforms like Facebook, Twitter, and Instagram. As these platforms aimed to provide a seamless user experience, they adopted infinite scrolling to deliver content dynamically, without requiring users to reload pages or manually navigate to the next set of items.
무한 스크롤은 페이스북, 트위터, 인스타그램과 같은 소셜 미디어 플랫폼의 부상과 함께 인기를 얻었습니다. 이러한 플랫폼은 원활한 사용자 경험을 제공하기 위해 무한 스크롤을 도입하여 사용자가 페이지를 다시 로드하거나 다음 항목으로 수동으로 이동할 필요 없이 콘텐츠를 동적으로 전달할 수 있도록 했습니다.

무한 스크롤은 눈에 보이는 형태가 아닌 하나의 기법이라고 합니다. '그렇다면 개발 방법도 여러 가지가 있을 수 있겠네'라는 생각이 들었습니다. 또, 배경에 대해서는 무한 스크롤이 처음 활용되기 시작한 사례도 함께 답변해주었습니다. 그런데 이런 배경보다도, 이 개념을 처음 제안한 사람이 누구인지 궁금해졌습니다. 그래서 다시 한번 GPT에게 물어봤습니다.

처음에는 이 용어를 특정한 누가 만들었는지 물었고, 답변은 계속해서 '특정 인물에게 귀속되지 않는다'는 식이었습니다. 하지만 위키백과를 확인해보니 무한 스크롤과 관련된 인물로 Aza Raskin이 언급되어 있었습니다. 이 이름을 힌트로 다시 질문하니, 만족스러운 답변을 얻을 수 있었습니다.

👤: Do you know 'Aza Raskin'? Does he relate to this term?

🤖: Yes, Aza Raskin is indeed connected to the concept of "infinite scroll." He is often credited with inventing infinite scroll in 2006 while he was working at the Humanized design agency. Raskin introduced the idea as a user-friendly way to avoid the interruption of clicking "next" to load more content, making browsing smoother and more seamless.
네, 아자 라스킨은 실제로 “무한 스크롤”이라는 개념과 관련이 있습니다. 그는 휴먼라이즈드 디자인 에이전시에서 일하던 2006년 무한 스크롤을 발명한 것으로 알려져 있습니다. 더 많은 콘텐츠를 불러오기 위해 '다음'을 클릭해야 하는 번거로움을 피하고 보다 원활하고 매끄럽게 탐색할 수 있는 사용자 친화적인 방법으로 이 아이디어를 도입했습니다.
While the term "infinite scrolling" likely evolved in parallel with the adoption of this design pattern, Aza Raskin is recognized as the person who formalized and popularized the concept, particularly through his work on user interfaces.
'무한 스크롤'이라는 용어는 이 디자인 패턴의 채택과 함께 발전한 것으로 보이지만, 특히 사용자 인터페이스 작업을 통해 이 개념을 공식화하고 대중화시킨 인물로 아자 라스킨이 인정받고 있습니다.
Interestingly, in later interviews, Aza Raskin has expressed concerns about the addictive nature of infinite scrolling. He has pointed out that while it was designed to enhance user experience, it can contribute to excessive consumption of content on platforms like social media, fueling what is now commonly referred to as "doomscrolling."
흥미롭게도 이후 인터뷰에서 아자 래스킨은 무한 스크롤의 중독성에 대해 우려를 표명했습니다. 그는 무한 스크롤이 사용자 경험을 향상시키기 위해 설계되었지만 소셜 미디어와 같은 플랫폼에서 과도한 콘텐츠 소비를 유발하여 현재 흔히 “둠스 스크롤”이라고 불리는 현상을 부추길 수 있다고 지적했습니다.
So, yes, Aza Raskin is directly related to the creation and popularization of infinite scroll.
즉, 아자 라스킨은 무한 스크롤의 탄생과 대중화에 직접적인 관련이 있는 인물입니다.

실제로 그를 위키에서 검색해보면 'Reportedly developing infinite scrolling'이라고 적혀 있습니다. 요약하자면, 무한 스크롤은 페이지 숫자나 이전, 다음 버튼을 클릭해야 하는 사용자의 행동을 단순히 스크롤 동작만으로 대체하는 기법입니다. 이 기법은 특히 모바일 화면에 적합하며, 끝없이 이어지는 콘텐츠를 제공하는 SNS나 이커머스 플랫폼에서 널리 활용되었습니다. 무한 스크롤을 고안한 Aza Raskin은 이 기법이 사용자 경험을 개선하기 위해 설계되었지만, 동시에 중독성에 대한 우려도 제기한 바 있습니다.

 

무한 스크롤 만들기

설계하기

무한 스크롤은 사용자가 화면에서 콘텐츠 목록의 하단에 도달하면 자동으로 다음 콘텐츠를 불러오는 기법입니다. 이를 구현하기 위해서는 다음 두 가지 질문에 답할 수 있어야 합니다.

  • 사용자의 화면이 콘텐츠 목록의 하단에 도달했는가?
  • 사용자의 화면이 콘텐츠 목록 하단에 도달했는지 언제 확인할 것인가?

이제 첫 번째 질문에 대한 답을 찾아보겠습니다.

브라우저는 다양한 웹 API를 제공하는데, 그중 scrollTop을 사용하면 현재 화면의 스크롤 위치를 확인할 수 있습니다. 그리고 scrollHeightclientHeight를 이용하면 사용자가 화면에서 콘텐츠 목록의 하단에 도달했는지를 판단할 수 있습니다.

scrollTop + clientHeight = scrollHeight

  • scrollTop: 요소의 콘텐츠가 상단 가장자리에서 스크롤되는 픽셀 수 (맨위부터 얼만큼 떨어져있는가)
  • clientHeight: 요소의 내부 높이 (화면에 보여지는 높이는 얼마인가)
  • scrollHeight: overflow로 인해 화면에 표시되지 않는 콘텐츠를 포함하여 요소 콘텐츠의 높이 (전체 스크롤의 높이는 얼마인가)

결국 화면이 콘텐츠 목록 하단에 도달했는지를 판단하는 조건식을 찾을 수 있습니다.

  • scrollTop + clientHeight = scrollHeight
  • 상단으로부터 스크롤된 높이 + 화면에 보여지는 높이 = 전체 스크롤 높이

다음 두번째 질문의 답을 찾아보겠습니다.

사용자가 콘텐츠 목록의 하단에 도달했다는 것은 그들이 상호작용하고 있다는 것을 의미합니다. 일반적으로 이 상호작용은 스크롤을 통해 발생합니다. 따라서 스크롤 이벤트 리스너를 활용하면 사용자의 스크롤 행동을 감지할 수 있을 것입니다.

const handleScroll = () => {
    // ..

    const isEndScroll =
    ref.current.scrollTop + ref.current.clientHeight ===
    ref.current.scrollHeight;
  if (isEndScroll) {
    getNextPageProductList(currentPage + 1);
  }
}

useEffect(() => {
    if (!ref.current) {
        return;
    }

    const target = ref.current;
  target.addEventListener("scroll", handleScroll);

  return () => target.removeEventListener("scroll", handleScroll);
}, [handleScroll])

<div ref={ref}>
    {목록}
</div>

하지만 스크롤 이벤트를 사용하면 상당한 리소스를 소모하게 됩니다. 스크롤 속도와 관계없이 이벤트가 지속적으로 발생하기 때문에 조건문이 짧은 시간에 매우 빈번하게 실행됩니다.

throttle을 사용하더라도 횟수만 줄어들뿐 화면이 목록 하단에 도달하지 않더라도 계속 확인을 합니다.

 

그렇다면 마지막에 도달했을 때만 스크롤을 감지하는 방법은 없을까요? 이와 같은 비슷한 고민을 해결하기 위해 웹에서는 Intersection Observer API를 도입하기 시작했습니다. 이 API는 대부분의 브라우저에서 2016년 이후로 지원되는 기능입니다. Intersection Observer는 특정 요소와 그 상위 요소 간의 상대적 가시성을 비동기적으로 관찰할 수 있도록 해줍니다. 이를 무한 스크롤에 적용하면, 특정 요소가 사용자 화면에 나타나는지를 감지할 수 있는 기능이 됩니다. 그렇다면 우리는 어떤 요소를 감지해야 할지 고민하게 됩니다. 감지할 대상은 여러 가지가 될 수 있습니다.

  • 콘텐츠 목록의 마지막 요소
  • 콘텐츠 목록 바로 밑 요소 (빈 요소)
  • ...

저는 콘텐츠 목록 바로 아래에 빈 요소를 추가하여 이를 감지하도록 구현해보겠습니다.

// InfiniteScrollWithIntersectionObserver.tsx

const { ref: observerRef } = useIntersectionObserver({
  onIntersect: () => getNextPageProductList(currentPage + 1),
});

return (
    <div>
      {productList.map((product) => <div key={key}>{product.title}</div>)}

      {hasNextPage &&
        (isLoading ? <div>Loading...</div> : <div ref={observerRef} />)}
    </div>
);
// useIntersectionObserver.tsx

export function useIntersectionObserver({ onIntersect }: UseIntersectionObserver) {
  const ref = useRef<HTMLDivElement | null>(null);
  const observer = useRef<IntersectionObserver | null>(null);

  useEffect(() => {
    if (!ref.current) {
      return;
    }

    observer.current = new IntersectionObserver((entries) => {
      entries.forEach((entry) => entry.isIntersecting && onIntersect());
    });

    observe();

    return () => {
      observer.current?.disconnect();
    };
  }, [ref.current]);

  const observe = () => {
    if (ref.current) {
      observer.current?.observe(ref.current);
    }
  };

  const unobserve = () => {
    if (ref.current) {
      observer.current?.unobserve(ref.current);
    }
  };

  return {
    ref,
    observe,
    unobserve,
  };
}

감지 대상이 화면에 나타나는 순간 onIntersect가 실행됩니다. 스크롤 이벤트에 대한 처리가 없기 때문에 리소스 측면에서도 훨씬 더 효율적입니다.


이렇게 두 가지 질문에 대한 답을 찾아가며 무한 스크롤을 구현하였습니다.

구현 코드

react, vite, typescript 환경에서 구현하였고, intersection observer를 이용한 코드만 작성하였습니다. 또한 설계와 구현 로직에 집중하실 수 있도록 스타일과 타입 선언 코드는 제외하였습니다. scroll event를 이용한 구현 코드나 더 자세한 코드를 보고 싶으신 경우 이 github 링크를 참고하시면 되겠습니다.

// src/pages/InfiniteScroll/WithIntersectionObserver/index.tsx

import { useIntersectionObserver } from "../../../hooks";
import { genCL } from "../../../utils";
import css from "../index.module.css";
import { useProductList } from "../useProductList";

export function InfiniteScrollWithIntersectionObserver() {
  const { isLoading, currentPage, productList, getNextPageProductList } =
    useProductList();

  const { ref: observerRef } = useIntersectionObserver({
    onIntersect: () => getNextPageProductList(currentPage + 1),
  });

  return (
    <div className={genCL(css, "flex-column")}>
      {productList.map((product, index) => {
        const key = `${product.id}-${index}`;

        return (
          <div className={genCL(css, "product-item-container")} key={key}>
            {`${product.id} - ${product.title}`}
          </div>
        );
      })}

      {currentPage < 10 &&
        (isLoading ? <div>Loading...</div> : <div ref={observerRef} />)}
    </div>
  );
}
// src/pages/InfiniteScroll/useProductList.ts

import { useEffect, useState } from "react";
import { getProductList } from "./api";
import { Product } from "./type";

export function useProductList() {
  const [productList, setProductList] = useState<Product[]>([]);
  const [currentPage, setCurrentPage] = useState(0);
  const [isLoading, setIsLoading] = useState(false);

  const getNextPageProductList = async (newPage: number) => {
    try {
      setIsLoading(true);
      const { products } = await getProductList(newPage);

      setProductList((prev) => [...prev, ...products]);
      setCurrentPage(newPage);
    } catch (error) {
      console.log(error);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    getNextPageProductList(0);
  }, []);

  return {
    productList,
    currentPage,
    isLoading,
    getNextPageProductList,
  };
}
// src/hooks/useIntersectionObserver.ts

import { useEffect, useRef } from "react";

export function useIntersectionObserver({
  onIntersect,
}: UseIntersectionObserver) {
  const ref = useRef<HTMLDivElement | null>(null);
  const observer = useRef<IntersectionObserver | null>(null);

  useEffect(() => {
    if (!ref.current) {
      return;
    }

    observer.current = new IntersectionObserver((entries) => {
      entries.forEach((entry) => entry.isIntersecting && onIntersect());
    });

    observe();

    return () => {
      observer.current?.disconnect();
    };
  }, [ref.current]);

  const observe = () => {
    if (ref.current) {
      observer.current?.observe(ref.current);
    }
  };

  const unobserve = () => {
    if (ref.current) {
      observer.current?.unobserve(ref.current);
    }
  };

  return {
    ref,
    observe,
    unobserve,
  };
}

더 생각해보기

스크롤 위치 기억하기

콘텐츠 목록 중 하나를 클릭하여 상세 콘텐츠 화면으로 이동한다고 가정해 보겠습니다. 이제 상세 콘텐츠 화면에서 뒤로 가기를 누르면 콘텐츠 목록의 스크롤은 어디로 돌아갈까요? 처음으로 돌아가게 됩니다. 그 이유는 무한 스크롤 목록의 처음에는 아무것도 없기 때문입니다. GPT에게 질문해도 비슷한 답변을 얻을 수 있습니다.

👤: 근데 브라우저에서 뒤로가기 했을때 이전 스크롤 위치를 유지하도록 도와주니?

🤖: 브라우저에서 기본적으로 뒤로가기를 했을 때 스크롤 위치를 자동으로 기억하고 복원해주는 기능이 대부분의 현대 브라우저에서 내장되어 있습니다. 즉, 사용자가 뒤로가기를 했을 때 이전에 보고 있던 페이지의 스크롤 위치가 자동으로 유지됩니다.
하지만 몇 가지 상황에서는 이 기능이 제대로 작동하지 않을 수 있습니다:

  1. 싱글 페이지 애플리케이션(SPA): React와 같은 라이브러리를 사용해 SPA 구조로 만든 경우, 페이지를 다시 로드하지 않고 컴포넌트만 갱신되기 때문에 브라우저의 기본 스크롤 복원 기능이 작동하지 않을 수 있습니다.
  2. CSR(Client-Side Rendering): 클라이언트 측에서만 렌더링되는 경우, 뒤로가기를 했을 때 상태가 초기화되거나 페이지가 다시 렌더링될 수 있어 스크롤 위치가 유지되지 않을 수 있습니다.
  3. 강제 스크롤 제어: 페이지 이동 시 scrollTo(0, 0) 등으로 스크롤 위치를 강제로 제어하는 경우, 이전 스크롤 위치가 유지되지 않습니다.

보이는 요소만 그리기

서두에서 무한 스크롤의 중독성에 대한 위험성을 언급했습니다. 무한 스크롤의 중독성은 사용자가 끊임없이 스크롤하게 만든다는 점에서, 컴퓨터나 스마트폰 같은 기기 관점에서도 위험을 초래할 수 있습니다. 가령 사용자가 지나치게 스크롤했다고 가정해 봅시다. 이럴 경우, 무한 스크롤 목록에는 많은 요소가 쌓여 있고, 이 모든 요소가 DOM에 그려져야 합니다. 그런데 과연 모든 요소를 DOM에 그려야 할 필요가 있을까요? 사용자가 보고 있는 요소만 그려도 되지 않을까요? 이 질문에 대한 답변은 다음 가상 스크롤 편에서 이어서 설명드리겠습니다.

ios 상태바 touch 기능

iOS 기기에서는 상태바를 터치하면 목록의 최상단으로 이동하는 기능이 있습니다. 브라우저에서는 이 기능이 body의 스크롤을 가장 최상단으로 끌어올리는 방식으로 작동합니다.

스크롤 이벤트를 활용한 코드는 body가 아닌 크기가 제한된 요소의 스크롤을 사용합니다. 반면에, Intersection Observer를 활용한 코드는 body의 스크롤을 이용하기 때문에 iOS의 상태바 터치 기능을 사용할 수 있습니다. 그러나 Intersection Observer를 사용하는 컴포넌트를 크기 제한된 요소로 감싸면 이 기능을 활용할 수 없게 됩니다. 마찬가지로, 스크롤 이벤트를 활용한 코드도 body에서 스크롤 이벤트를 감지하도록 변경하면 iOS 상태바 터치 기능을 사용할 수 있습니다.

따라서 무한 스크롤 컴포넌트가 body의 하위 요소인지 아니면 크기 제한된 요소의 하위 요소인지 판단할 필요가 있습니다. 이와 관련된 내용은 다음 가상 스크롤 편에서 적용하여 설명드리겠습니다.


참고

728x90

댓글