Emotion.js로 풀페이지 스크롤 구현하기 (feat. 디바운스)

Derhon·2023년 4월 7일
1
post-thumbnail

랜딩 페이지를 구성하면서 풀 페이지 스크롤을 제작하게 되었다.
풀 페이지 스크롤의 경우에는 보통 라이브러리로 사용하지만, 나는 커스텀도 많이 해야하고 이게 라이브러리 쓸 만큼 복잡한 일인가? (복잡한 일이었다.) 라는 생각에 그냥 직접 구현하게 되었다.

그리고 마침 내가 너무 애정하는 데보션의 4월 첫주차 미션이 '과제 수행기' 이다.
어떻게 이렇게 타이밍 좋게 고민거리가 찾아왔는지!
너무 좋은 기회다 싶어서 블로그에 바로 정리해야겠다고 다짐했다.

개발 환경은 Next.js 13 Typescript Emotion.js 기반이다. 사실 여기선 Next.js에서만 사용할 수 있는 것들이 나오지 않기에, React라고 생각해도 좋을 것 같다.

풀 페이지 스크롤이 뭔데?

백문이 불여일견, fullPage.js에서 볼 수 있다.

이렇게 간단한 스냅만으로 페이지가 섹션별로 스크롤 되는 것을 풀 페이지 스크롤 혹은 원 페이지 스크롤이라고 한다.
보자마자 어떻게 구현해야할지 머릿속에 촤르르륵 떠오른다면 당신은 나의 선생님...

❌ fullPage.js

꽤 유명한 라이브러리이다. JQuery 기반으로 만들어져있고, 오픈소스로 공개는 되어있으나 라이선스 상 상업적 목적을 위해서라면 추가 결제가 필요하다.
정말 좋은 라이브러리라면 돈으로 딱히 고민하지 않겠지만, 뭔가 이 정도면 나도 할 수 있지 않을까 싶었다.
물론 할 수 있긴 했다...

❌ CSS scroll-snap

이전 프로젝트에서 풀 페이지 스크롤을 이 속성으로 구현한 경험이 있다.
트랙패드를 사용하는 마우스 휠의 경우에는 어느정도 원하는 인터랙션이 구현되지만, 일반 마우스로는 전혀 안됐었다. 이게 트랙패드 문제인지... 브라우저 문제인지...
서치해보니까 크롬에서 해당 속성이 제대로 적용 안된다는 이야기가 있었다.

🫶 mouseWheel event

그럼에도 다행인건 브라우저에서 마우스 휠 이벤트를 아주 상세하게 전달해준다.
이벤트를 받아서 콜백함수를 호출하는 커스텀 훅을 제작하고, 직접 프로젝트에 적용해보자!

풀 페이지 스크롤 직접 구현하기

1️⃣ 섹션 분류하기

가장 먼저 한 것은 디자인 시안 속 어디부터 어디까지를 한 화면에 담을 것인지 섹션을 분리하는 작업이었다.

섹션이 분리되었다면 우선 로직을 구상해야한다.
로직은 그래도 간단한 편이다.

  1. 특정 영역에서 마우스 휠 이벤트를 받는다.
  2. 이벤트 속 deltaYscrollTop을 확인한다.
    • deltaY는 휠이 이동한 방향 중, Y축(세로축) 방향으로의 벡터 값이다.
    • scrollTop은 현재 휠이 스크롤 된 영역에서, 최 상단의 좌표 값이다.
  3. scrollTop으로 현재 어느 영역에 위치하고 있는지 알아낸다.
  4. deltaY로 어느 섹션으로 이동하고 싶은지 알아낸다.
  5. ref.scrollTo를 이용하여 특정 섹션으로 이동한다.

섹션은 각각 Wrapper 컴포넌트로 구성한다.

2️⃣ useWheel

마우스 휠 이벤트를 감지해서 콜백함수를 처리하는 DOM을 반환하는 커스텀 훅을 제작한다.

import { useEffect, useRef } from "react";

type CallbackFunction = (
  ref: React.RefObject<HTMLDivElement>,
  deltaY: number,
  scrollTop: number
) => void;

/**
 * 마우스 휠을 감지하여 콜백함수를 처리할 수 있는 ref 객체를 반환하는 커스텀 훅.
 * 디바운스 기법을 적용하여 트랙패드에서 많은 이벤트 발생해도 한 번만 처리함.
 * @param callback 마우스 휠 이후 처리될 콜백함수 (deltaY, scrollTop)을 인자로 받는다.
 */
const useWheel = (
  callback: CallbackFunction
): React.RefObject<HTMLDivElement> => {
  const ref = useRef<HTMLDivElement>(null);

  const handleMouseWheel = (event: WheelEvent) => {
    event.preventDefault();
    callback(ref, event.deltaY, ref.current?.scrollTop!);
  };

  useEffect(() => {
    const currentRef = ref.current;
    if (currentRef) {
      currentRef.addEventListener("wheel", handleMouseWheel);
    }
    return () => {
      //메모리 누수 방지를 위한 이벤트 삭제
      if (currentRef) {
        currentRef.removeEventListener("wheel", handleMouseWheel);
      }
    };
  }, [ref, callback]);

  return ref;
};

export default useWheel;

useRef를 이용해서 휠 이벤트 리스너를 등록하고, 이벤트가 발생할 때 마다 콜백 함수를 실행한다.
주의 깊게 봐야할 점은, 메모리 누수(memory leak)를 방지하기 위해 currentRef에 등록된 마우스 휠 이벤트를 제거한다는 것이다.

React 컴포넌트가 언마운트될 때, 해당 컴포넌트에 등록된 이벤트 핸들러들도 모두 제거 되어야 한다.
하지만 이벤트 핸들러가 등록된 DOM 요소가 제거되지 않는 경우, 이벤트 핸들러도 함께 남아 메모리 누수가 발생할 수 있다.

결국 어플리케이션 성능 개선을 위해 수행하는 작업이라고 할 수 있다.
추가적으로 이러한 이벤트 핸들러 제거 작업은 컴포넌트의 생명주기 함수 중 componentWillUnmount 함수에서 수행된다.

3️⃣ Wrapper 계산 후 적용

import { css } from "@emotion/react";
import useWheel from "@/hooks/useWheel";
import { SECTIONS } from "./Sections";
import { useCallback, useState } from "react";

...

  const wheelHandler = useCallback(
    (
      ref: React.RefObject<HTMLDivElement>,
      deltaY: number,
      scrollTop: number
    ) => {
      /**
       * 뷰포트 높이 값(100vh)
       */
      const pageHeight = window.innerHeight;
      if (deltaY > 0) {
        //스크롤 내릴 때
        const dest = Math.floor(scrollTop / pageHeight) + 1;
        setCurSection(dest);
        ref.current?.scrollTo({
          top: pageHeight * dest + DIVIDER_HEIGHT * dest,
          left: 0,
          behavior: "smooth",
        });
      } else {
        // 스크롤 올릴 때
        const dest = Math.floor(scrollTop / pageHeight) - 1;
        setCurSection(dest);
        ref.current?.scrollTo({
          top: pageHeight * dest + DIVIDER_HEIGHT * dest,
          left: 0,
          behavior: "smooth",
        });
      }
    },
    []
  );

  const containerRef = useWheel(wheelHandler);

  return (
    <div css={containerStyle} ref={containerRef}>
      {SECTIONS.map((SECTION, idx) => (
        <SECTION key={`${idx + 1}-section`} curSection={curSection} />
      ))}

...

const containerStyle = css`
  width: 100vw;
  height: 100vh;
  overflow: hidden;
`;

...

콜백함수로 적용되는 wheelHandler는 변경되는 함수가 아니기 때문에, useCallback을 사용하여 메모이제이션하였다. 결과적으로 이 역시 성능 향상을 위한 작업이다.

4️⃣ 디바운스(debounced)

✅ 디바운스란 무엇이냐

디바운스는, 자바스크립트에서 이벤트가 연속적으로 발생할 때 함수가 동시에 여러번 호출되는 것을 막고 일정 시간이 지나기 전 마지막 이벤트만을 처리하는 성능 개선을 위한 방법이다.
이렇게 불필요한 이벤트 호출을 방지하는 방법으로 동시에 스로틀도 있다.

✅ 스로틀이란 무엇이냐

스로틀은 이벤트가 발생한 훈 일정 시간 간격으로 이벤트가 실행되도록 제한하는 방법이다.
위의 그림과 같이, 이벤트가 연속적으로 발생했을 대 일정 시간 간격으로 실행되도록 한다.

✅ 둘의 차이점은?

공통점은, 둘 다 성능개선을 위한 방법이라는 점.
차이점은 실행되는 시점의 차이!

디바운스는 이벤트가 발생한 후 일정 시간이 지나고 실행된다.
하지만 스로틀은 이벤트를 일정 간격으로 처리한다.

그림을 보면 쉽게 이해할 수 있다.

디바운스는 마지막 이벤트만을 처리하기 때문에 이벤트 처리 속도가 빠르지만, 스로틀은 이벤트를 일정 간격으로 처리하기 때문에 느리다.
여기서 말하는 빠르고 느림은 장점과 단점으로 구분되는 것이 아니라, 특징의 차이이다.
즉 상황에 따라 필요한 기법을 적용하는 것이 중요하다는 말!

✅ 풀 페이지 스크롤에서 사용된 기법은?

디바운스 기법을 사용하였다.

일반 마우스 휠의 경우 한 틱씩 스크롤 할 때마다 1회의 이벤트가 발생한다. 즉 마우스만을 사용했을 때에는 디바운스나 스로틀을 적용할 필요가 전혀 없다.
이후 테스트를 진행하며 알게 된 것은, 트랙패드나 매직패드를 사용하면 무수히 많은 이벤트가 발생한다는 점이었다.
때문에 이벤트 핸들링 성능 개선을 하지 않으면 수백번의 콜백함수가 동시에 실행되어 어플리케이션이 버벅거린다는 것을 확인했다.

5️⃣ 개선된 useWheel

import { useEffect, useRef } from "react";

type CallbackFunction = (
  ref: React.RefObject<HTMLDivElement>,
  deltaY: number,
  scrollTop: number
) => void;

/**
 * 마우스 휠을 감지하여 콜백함수를 처리할 수 있는 ref 객체를 반환하는 커스텀 훅.
 * 디바운스 기법을 적용하여 트랙패드에서 많은 이벤트 발생해도 한 번만 처리함.
 * @param callback 마우스 휠 이후 처리될 콜백함수 (deltaY, scrollTop)을 인자로 받는다.
 */
const useWheel = (
  callback: CallbackFunction
): React.RefObject<HTMLDivElement> => {
  const ref = useRef<HTMLDivElement>(null);
  const timeoutRef = useRef<number | null>(null);

  const handleMouseWheel = (event: WheelEvent) => {
    event.preventDefault();
    if (!timeoutRef.current) {
      callback(ref, event.deltaY, ref.current?.scrollTop!);
      timeoutRef.current = window.setTimeout(() => {
        timeoutRef.current = null;
      }, 1500);
    }
  };

  useEffect(() => {
    const currentRef = ref.current;
    if (currentRef) {
      currentRef.addEventListener("wheel", handleMouseWheel);
    }
    return () => {
      //메모리 누수 방지를 위한 이벤트 삭제
      if (currentRef) {
        currentRef.removeEventListener("wheel", handleMouseWheel);
      }
      if (timeoutRef.current) {
        window.clearTimeout(timeoutRef.current);
      }
    };
  }, [ref, callback]);

  return ref;
};

export default useWheel;

timeoutRef를 생성하여 일정 시간 동안 이벤트를 모아 한 번에 실행하도록 구현하였다.
이제 부드럽고 성능 좋은 풀 페이지 스크롤이 완성 되었다.
디바운스 때문에 중간에 골머리를 앓았지만...

이정도면 돈내고 쓰는 fullPage.js 보다 나은 결과..물이라고 생각한다.

Reference

리액트(react) 전체화면 넘기기 스크롤링(full page scroll)
Debounce et throttle : limiter les appels successifs à une fonction Javascript

profile
🧑‍🚀 이사했어요 ⮕ https://99uulog.tistory.com/

0개의 댓글