Skip to content

🖱️무한스크롤 개발기

정다현 edited this page Dec 15, 2022 · 2 revisions

Intro

Knoticle 내에는 사용자들이 작성한 글과 책 중 원하는 컨텐츠를 검색할 수 있는 검색페이지가 있다. 검색 결과 요청 시 한 번에 모든 결과를 요청할 경우 시간이 오래 소요되므로 일정 개수만큼 요청해서 렌더링하기로 기획하였다. 페이지네이션과 무한스크롤 중, 무한스크롤이 UX에 더 좋다고 판단하여 Intersection Observer API를 통해 구현하였다. 이 과정에서 페이지가 보존되지 않는다는 문제점을 발견하여, 세션스토리지를 이용하여 검색페이지 보존을 구현한 방법까지 소개하려고 한다.

Intersection Observer를 활용한 무한스크롤

무한스크롤 기능을 구현하기 위해서는 사용자가 페이지의 바닥에 도달했는지를 감지해야 한다. 처음에 떠올린 방법은 다음과 같았다.

  1. 사용자의 현재 스크롤 위치와 페이지 높이를 비교하여 스크롤을 끝까지 내렸는지 판별

  2. 특정 요소를 페이지 가장 아래에 배치하고 이 요소가 뷰포트 내에 들어왔는지 getBoundingClientRect()를 통해 판별

첫 번째 방법은 스크롤 이벤트를 리스닝해야 하므로 스크롤 시 짧은 시간 내에 다수의 이벤트가 발생되어 메인스레드에 부하를 줄 수 있다는 단점이 있었고, 두 번째 방법은 리플로우가 발생할 수 있다는 문제점이 있었다.

📖 리플로우란?

document의 일부를 재렌더링하기 위해 DOM 요소의 위치 및 크기를 다시 계산하는 것을 말한다. 리플로우가 일어날 때 사용자는 페이지를 조작할 수 없게 되므로, 최대한 리플로우가 일어나지 않도록 하는 것이 중요하다.

  • 리플로우를 발생시킬 수 있는 요소들

    • elem.offsetLeftelem.offsetTopelem.offsetWidthelem.offsetHeightelem.offsetParent
    • elem.clientLeftelem.clientTopelem.clientWidthelem.clientHeight
    • elem.getClientRects()**elem.getBoundingClientRect()**

    스크롤 관련

    • elem.scrollBy()elem.scrollTo()
    • elem.scrollIntoView()elem.scrollIntoViewIfNeeded()
    • elem.scrollWidthelem.scrollHeight
    • elem.scrollLeftelem.scrollTop also, setting them

따라서 그 대신 Intersection Observer API를 활용하기로 결정했다.

Intersection Observer API

Intersection Observer API 는 루트 요소와 타겟 요소의 교차점을 관찰한다. 교차 시 비동기적으로 실행되며 가시성 구분 시 reflow를 발생시키지 않는다.

✔️ Intersection observer 생성하기

let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
}

let observer = new IntersectionObserver(callback, options);
  • Options
    • root: 타겟 요소의 가시성을 확인할 때 사용되는 루트 요소. 이것은 타겟 요소보다 상위 요소, 즉 요소의 조상 요소이어야 한다. 설정하지 않거나 root 값을 null로 주었을 때 기본 값으로 브라우저 뷰포트가 설정된다.
    • rootMargin: margin을 주어 루트 요소의 범위를 확장할 수 있다.
    • threshold: threshold: 1.0 은 대상 요소가 root에 지정된 요소 내에서 100% 보여질 때 콜백이 호출될 것을 의미한다.

useIntersectionObserver 훅 만들기

import { RefObject, useEffect, useState } from 'react';

const useIntersectionObserver = (elementRef: RefObject<HTMLDivElement>) => {
  const [isIntersecting, setIntersecting] = useState(false);

  const onIntersect = ([entry]: IntersectionObserverEntry[]) => {
    const isElementIntersecting = entry.isIntersecting;
    setIntersecting(isElementIntersecting);
  };

  useEffect(() => {
    const target = elementRef?.current;
    if (!target) return undefined;

    const observer = new IntersectionObserver(onIntersect, {
      threshold: 0.1,
      rootMargin: '300px',
    });

    observer.observe(target);

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

  return isIntersecting;
};

export default useIntersectionObserver;
  • 타겟 요소를 인수로 받아서, 타겟 요소가 뷰포트 내에 들어왔는지 여부(isIntersecting)를 반환하도록 구현했다.
  • IntersectionObserver 객체를 생성할 때, 루트 요소와 타겟 요소가 교차할 경우 isIntersecting 값을 true로 바꾸는 콜백 함수인 onIntersect를 넣어줬다.
  • IntersectionObserver를 생성할 때 옵션값(threshold, rootMargin)은 크게 변동될 것 같지 않아서 우선은 고정으로 두었다.

useIntersectionObserver 훅 적용하기

const target = useRef() as RefObject<HTMLDivElement>;
const isIntersecting = useIntersectionObserver(target);

useEffect(() => {
    if (!isIntersecting || !debouncedKeyword) return;

    // 추가 검색 결과 요청하기

  }, [isIntersecting]);
  • 검색 결과가 렌더링되는 부분의 가장 아래쪽에 target 요소를 두고, 이 요소를 useIntersectionObserver의 인수로 넘겨줬다.
  • useIntersectionObserver가 반환한 값이 true로 바뀌면 추가 검색 결과를 요청하도록 하였다.

검색 결과 보존하기: sessionStorage

기획 단계에서 검색을 모달이 아닌 페이지로 만들기로 했던 이유는, 유저가 검색 결과를 클릭하고 다시 뒤로가기를 눌렀을 때 검색 결과 및 스크롤 위치가 보존되었으면 했기 때문이다. 하지만 검색 기능을 구현하고 나서보니 아쉽게도 뒤로가기를 클릭했을 때 모든 검색 결과가 날아가서 검색 페이지에 첫 진입했을 때의 모습이 렌더링되었다.

그도 당연한 것이 검색 페이지에 다시 진입하게 되면 관련 컴포넌트가 새로 마운트되므로, 이전 검색 결과를 어딘가에 저장하고 이 값을 렌더링하지 않는 이상 보존되지 않을 수 밖에 없었다.

검색 결과를 보존하기 위해서는 크게 두 가지 방법이 있었는데,

  1. useRef 또는 전역 상태를 사용해서 메모리에 저장하거나

  2. 브라우저의 스토리지에 데이터를 저장해두는 방법이다.

  • 고민 결과 2)의 방법을 택했는데 그 이유는 검색페이지에서 유저가 실수로 새로고침을 눌러도 검색 결과가 보존되었으면 했기 때문이다.

  • 로컬스토리지 vs 세션스토리지

    로컬스토리지와 세션스토리지 중에서 세션스토리지를 선택했는데, 그 이유는 탭을 닫고 브라우저를 다시 실행했을 때는 오히려 검색 결과가 초기화되길 원했기 때문이다.

useSessionStorage 훅 만들기

기존에 검색 컴포넌트에서 아티클 데이터, 글 데이터 등을 리액트의 useState 훅을 이용해서 관리하고 있었는데, 여기서 다음의 로직이 추가된 훅을 만들고자 했다.

  • 초기값을 설정할 때 sessionStorage에 해당 상태가 저장되어 있는지 확인하고, 있다면 저장된 값으로 업데이트하고 없다면 초기값으로 설정해준다.
  • 상태를 업데이트할 때 setState를 해주면서 sessionStorage에도 변경된 상태를 저장해준다.

초깃값을 설정해주는 코드는 처음에는 아래와 같이 작성했었다.

const [value, setStateValue] = useState<T>((initialValue)=>{
			const savedValue = sessionStorage.getItem(key);
			return savedValue ? JSON.parse(savedValue) : initialValue;
);

🤔 하지만 이 훅을 적용했을 때 sessionStorage is not defined라는 오류가 발생했다. 그 이유는 sessionStorage는 window의 프로퍼티인데, Next.js가 서버 사이드에서 위 코드를 실행할 때에는 window 객체가 없는 상태이기 때문이다.

따라서 아래와 같이 window 객체가 있는지 여부를 체크해서 값을 설정해주는 코드를 useEffect 안에 넣었다.

다음은 구현된 코드이다.

import { useEffect, useState } from 'react';

const useSessionStorage = <T>(key: string, initialValue: T) => {
  const [value, setStateValue] = useState<T>(initialValue);
  const [isValueSet, setIsValueSet] = useState(false);

  useEffect(() => {
    if (typeof window !== 'undefined') {
      const savedValue = sessionStorage.getItem(key);
      if (savedValue) setStateValue(JSON.parse(savedValue));
      setIsValueSet(true);
    }
  }, [typeof window]);

  const setValue = (newValue: T) => {
    setStateValue(newValue);
    sessionStorage.setItem(key, JSON.stringify(newValue));
  };

  return { value, isValueSet, setValue };
};

export default useSessionStorage;

보존되어야 하는 값들은 useState 훅 대신 위 useSessionStorage 훅을 사용하니 정상적으로 세션 스토리지에 저장되고 반환되는 것을 확인할 수 있었다.

스크롤 위치 보존하기

스크롤 위치 보존 작업을 하기 전 이런 의문이 들었다. 페이지를 탐색하다가 뒤로가기를 누르면 보통 스크롤 위치가 보존되어 있던데, 일일이 저장해준 것일까?

  • history API의 scroll restoration 관련 문서에 따르면, 디폴트로 스크롤 위치가 보존된다고 한다.

🤔 그렇다면 왜 우리 서비스에서는 뒤로가기를 눌렀을 때 자동으로 스크롤이 이동하지 않았던 것일까?

이에 대한 답은 오늘의 집의 <오늘의 집 내 무한스크롤 개발기> 문서에서 찾을 수 있었다.

기본적으로 브라우저 스크롤 위치를 저장해주기 때문에 원래 위치로 정상적으로 돌아오게 되어 있습니다. 처음 그려지는 UI가 스크롤 위치로 갈 수 있을 만큼 높이가 충분하다면 그 위치로 바로 이동하고, 실제로 이때문에 무한 스크롤이 적용되지 않은 페이지는 정상적으로 동작합니다.

무한 스크롤로 불러온 데이터 페이지를 나갔다 돌아오면 전부 초기화되기 때문에, 브라우저가 기억했던 스크롤 위치가 이미 사라져버리고 없습니다. 즉, 아무 처리도 하지 않고 무한 스크롤을 구현하면 1페이지 맨 바닥까지만 스크롤이 이동하고 더 이상 돌아가지지 않는 것입니다.

따라서 스크롤 위치 복구는 브라우저에만 의존할 수 없으므로, sessionStorage에 저장해주기로 했다.

  1. 검색페이지가 렌더링된 후, 스크롤 이벤트가 발생했을 때 스크롤 값을 저장해주는 이벤트 리스너를 달아주자.
  2. 검색페이지에 진입하면 저장된 스크롤 위치 값을 불러와 이동해주자.

구현 과정에서 스크롤 값을 저장해주는 과정(1)은 쉽게 구현되었지만, 검색페이지에 진입하면 저장된 스크롤 위치 값을 불러와 이동해주는 작업(2)에서 어려움을 겪었다. 그 이유는,

  • 저장된 검색 결과가 모두 브라우저에 페인팅까지 되어야 스크롤 위치까지 이동할 수 있다.

    즉, 저장된 스크롤 값이 3000px이라면, 검색 결과가 페인팅 되어서 실제 페이지가 3000px 이상이 되어야 이동할 수 있다.

🤔 문제점: 검색 결과가 불러와졌는지는 감지할 수 있지만, 페인팅 되었는지는 어떻게 감지할 수 있을까?

✔️ 해결 방안: 페인팅 되었는지를 감지하는 것은 어렵다. 대신 스크롤 위치를 불러온 후 스크롤 위치 이상의 높이로 미리 DOM 요소의 height를 설정해놓자. 이렇게 하면 검색 결과가 페인팅되기 전에도 저장된 스크롤 위치 값으로 이동할 수 있다.

구현된 코드는 아래와 같다.

useEffect(() => {
    setInitialHeight(Number(sessionStorage.getItem('scroll')));
  }, []);

useEffect(() => {
  if (initialHeight !== 0) window.scrollTo(0, initialHeight);
}, [initialHeight]);

return (
    <>
      <SearchHead />
      <GNB />
      <PageWrapperWithHeight initialHeight={initialHeight}>
			...
  • 저장된 스크롤 위치 값으로 initialHeight를 설정해준다.

  • initialHeight을 PageWrapperWithHeight 의 props로 넘겨서, initialHeight이 설정되어 있다면 높이를 initialHeight + 600 (600px 만큼의 여유분을 두었다)로 설정해준다.

    아래는 관련 코드이다.

    export const PageWrapperWithHeight = styled.div<{ initialHeight: number }>`
      padding-top: 64px;
      background-color: var(--light-yellow-color);
      min-height: ${(props) =>
        props.initialHeight !== 0 ? `${props.initialHeight + 600}px` : 'calc(100vh - 131px)'};
    `;
  • initialHeight이 변화하면 저장된 스크롤 위치로 이동한다.

이러한 노력을 통해 사용자가 뒤로가기를 통해 다시 검색 페이지에 진입했거나 새로고침을 했을 때, 검색페이지를 이탈했을 때의 모습과 동일한 페이지를 렌더링할 수 있었다.

참고자료

Avoid Reflow & Repaint

React 무한 스크롤 구현하기 with Intersection Observer

useIntersectionObserver() react hook - usehooks-ts

History API - Scroll restoration - Chrome Developers

오늘의집 내 무한 스크롤 개발기 - 오늘의집 블로그

Clone this wiki locally