카테고리 없음

es-toolkit의 throttle로 성능 최적화한 Scroll To Top 버튼 구현

엄성준 2025. 2. 16. 16:58

Autowini Shipping-Schdule 화면

 

화면 좌측 하단이나 우측 하단에 자주 보이는 Scroll To Top 버튼을 몇 번 구현한 적이 있습니다.

 

하지만 매번 스크롤 이벤트를 어떻게 활용해야 성능적으로 최적화할 수 있을까? 고민했었던 것 같습니다.

 

이번에는 그런 고민을 명쾌하게 해결하고, 더 깔끔한 방식으로 구현했기에 경험을 공유하려 합니다.

 

현재 저는 오토위니 모바일의 일부 JSP 레거시 페이지를 React 프로젝트로 마이그레이션 하는 작업을 진행 중입니다.

 

여러 페이지 중 해외 운송 정보를 보여주는 "Shipping Schedule" 페이지에 Scroll To Top 버튼을 적용했는데, 이번에는 기존 방식보다 더 최적화된 방식으로 개발할 수 있었습니다.

 

#  기존 구현 방식의 비효율성

기존에 Scroll To Top 버튼을 구현할 때마다 스크롤 이벤트를 매번 직접 감지하여 상태를 관리하는 방식을 사용했습니다.

 

하지만 이 방식은 불필요하게 이벤트가 과도하게 호출되어 성능에 부담을 줄 수 있습니다.

 

그러던 중, 과거 한 기업의 과제를 수행하면서 학습했던 debounce와 throttle 개념이 떠올랐습니다.

개념 동작 방식 주로 사용하는 경우 예시
Debounce 이벤트가 연속적으로 발생할 때, 마지막 이벤트가 발생한 후 일정 시간이 지나면 실행 사용자의 입력이 끝난 후 API 요청을 보낼 때 검색어 자동완성, 폼 입력 유효성 검사
Throttle 일정 시간 간격으로 이벤트 실행을 제한 빈번한 이벤트 호출을 제어하여 성능을 최적화할 때 스크롤 이벤트, 리사이즈 이벤트

 

 

이번 Scroll To Top 버튼에서는 throttle을 적용하는 것이 적절하다고 판단했습니다.

 

#  왜 es-toolkit을 선택했을까?

Toss에서 개발한 오픈소스 라이브러리인 es-toolkit에 꾸준히 관심을 가지고 있었고, 오픈소스 기여에 대한 열정도 있었기 때문에 이번 프로젝트에서 활용해 보기로 결정했습니다.

 

공식 문서에 따르면, es-toolkit은 현대적인 구현 방식을 채택하여 번들 사이즈가 매우 작고 성능이 뛰어나다는 강점을 가지고 있습니다.

 

 

번들 사이즈 절감


es-toolkit은 lodash와 비교했을 때, 함수에 따라 최대 97% 작은 크기를 자랑합니다. 일부 유틸리티 함수는 100바이트보다 작은 크기로 제공되기 때문에, 번들 사이즈를 최소화하는 데 최적의 선택이었습니다.

 

성능 향상


성능을 우선적으로 고려하여 설계된 es-toolkit은 lodash 대비 평균 2배 빠른 성능을 보여주며, 특정 함수에서는 최대 11배까지 속도 차이가 납니다. 이는 최신 JavaScript API를 적극 활용하여 최적화되었기 때문입니다.

 

이러한 이유로, 번들 크기를 최소화하고 성능을 극대화하기 위해 es-toolkit의 throttle 기능을 적용했습니다.

 

https://es-toolkit.slash.page/ko/reference/function/throttle.html

 

throttle | es-toolkit

 

es-toolkit.slash.page

 

 

#  구현 코드

import { useEffect, useState } from 'react';
import styled from 'styled-components';
import { throttle } from 'es-toolkit';

export default function ScrollToTop() {
  const [isVisible, setIsVisible] = useState(false);

  /* 스크롤 이벤트가 너무 자주 호출되는 것을 방지하기 위해 throttle 함수를 사용 */
  const toggleVisibility = throttle(
    () => {
      if (isVisible) return;
      setIsVisible(true);
      setTimeout(() => {
        setIsVisible(false);
      }, 2000);
    },
    2000, // 2000ms 간격으로 스크롤 이벤트를 처리
    { edges: ['leading'] } // trailing 옵션을 사용하지 않음
    /* 
    'leading'이 포함되면, 스로틀링된 함수를 처음으로 호출했을 때 즉시 원래 함수를 실행해요.
    'trailing'이 포함되면, 마지막 스로틀링된 함수 호출로부터 throttleMs 밀리세컨드가 지나면 원래 함수를 실행해요.
    */
  );

  /* 스크롤을 최상단으로 이동시키는 함수 */
  const handleScrollToTop = () => {
    window.scrollTo({
      top: 0,
      behavior: 'smooth',
    });
  };

  useEffect(() => {
    window.addEventListener('scroll', toggleVisibility);
    return () => {
      window.removeEventListener('scroll', toggleVisibility);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return <Button onClick={handleScrollToTop} $isVisible={isVisible} />;
}

const Button = styled.button<{ $isVisible: boolean }>`
  position: fixed;
  bottom: 120px;
  right: 10px;
  width: 95px;
  aspect-ratio: 1;
  border: none;
  border-radius: 10px;
  background-repeat: no-repeat;
  background-image: url(${import.meta.env
    .VITE_FILE_BROWSER_URL}/bg_arrow_top.png);
  cursor: pointer;
  opacity: ${(props) => (props.$isVisible ? '0.8' : '0')};
  visibility: ${(props) => (props.$isVisible ? 'visible' : 'hidden')};
  transition: opacity 0.3s, visibility 0.3s;
  z-index: 10;
`;