[React 심화] UX향상 - throttling & debouncing

조아영·2025년 3월 25일

◼ Throttling이란?

Throttling은 짧은 시간 간격으로 연속 발생한 이벤트를 일정 시간 단위(delay)로 제한하는 기법. 지정한 시간 동안 처음 또는 마지막 이벤트만 실행 가능.
주로 무한스크롤, 스크롤 이벤트, 버튼 연속 클릭 방지 등에 사용.

이벤트 반복 발생 시 처리 방식은 다음과 같이 구분 가능.

타입설명예시
Leading Edge이벤트가 처음 발생할 때 핸들러가 실행됨. 이후 지정 시간 동안 추가 이벤트 무시.사용자가 스크롤을 시작할 때 처음에만 API 호출이 이루어지고, 일정 시간 동안 추가 호출이 무시됨
Trailing Edge이벤트가 반복적으로 실행될 때, 주어진 시간(delay)이 지나면 마지막 이벤트를 실행.Leading Edge와 비슷하지만 주어진 시간의 마지막 이벤트에 API 호출이 이루어짐.
Leading & Trailing Edge처음 이벤트 때 핸들러가 실행되고, 주어진 시간이 지나면 마지막 이벤트도 실행.사용자가 버튼을 여러 번 클릭할 때 처음 클릭 시 바로 API 호출이 이루어지고, 주어진 시간의 마지막 이벤트에도 API 호출이 이루어짐.

◼ Debouncing란?

Debouncing은 짧은 시간 간격으로 연속 발생하는 이벤트가 멈출 때까지 기다렸다가, 마지막 이벤트 기준으로 일정 시간(delay) 이후 한 번만 실행하는 기법.
주로 입력값 실시간 검색, 자동완성 기능, 화면 resize 이벤트 등에 사용.
서버에 대한 불필요한 API 호출을 줄이고 짧은 시간에 많은 이벤트가 발생하는 상황에서 UI 과부하 방지.

◼ 메모리 누수(Memory Leak)란?

필요하지 않은 메모리를 계속 점유하는 현상.

setTimeout이 메모리 누수를 유발하는가?

쓰로틀링과 디바운싱에서 setTimeout을 자주 사용하기 때문에, 이 함수의 사용으로 인한 메모리 누수 가능성을 이해하는 것이 중요. 상황에 따라 메모리 누수를 일으킬 수도 있고 아닐 수도 있음.

하나의 페이지에서 페이지 이동 없이 setTimeout을 동작시키고 타이머 함수가 종료될 때까지 기다린다면 메모리 누수는 없음.
React 기반 SPA에서는 페이지 이동 시 컴포넌트가 언마운트됨. 페이지 이동 전에 setTimeout 으로 인해 타이머가 동작중인데 clearTimeout을 호출하지 않고 페이지를 이동한다면, 컴포넌트는 언마운트 되었음에도 불구하고 타이머는 여전히 메모리를 차지하고 동작하고 있음. 이 경우 메모리 누수(Memory Leak)에 해당.

따라서 cleanup 함수에서 반드시 clearTimeout 처리 필요.

◼ 실습

직접 만들어보는 throttling & debouncing

프로젝트 생성

npm create vite thro-debo-app --template react

페이지 이동 테스트를 위한 react-router-dom 설치

npm install react-router-dom

App 컴포넌트 작성

// src/App.jsx

import { BrowserRouter, Route, Routes } from "react-router-dom";
import Home from "pages/Home";
import Company from "pages/Company";

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
				<Route path="/company" element={<Company />} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

Home 컴포넌트 작성

throttling, debouncing 동작에 집중.
페이지 이동 시 cleanup이 없다면 타이머가 계속 동작. 이 경우 메모리 누수 발생 가능.

// src/pages/Home.jsx

import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";

export default function Home() {
  // const [state, setState] = useState(false);
  const navigate = useNavigate();
  let timerId = null;

  // Leading Edge Throttling
  const throttle = (delay) => {
    if (timerId) {
      // timerId가 있으면 바로 함수 종료
      return;
    }
    // setState(!state);
    console.log(`API요청 실행! ${delay}ms 동안 추가요청 안받음`);
    timerId = setTimeout(() => {
      console.log(`${delay}ms 지남 추가요청 받음`);
      // alert("Home / 쓰로틀링 쪽 API호출!");
      timerId = null;
    }, delay);
  };

  // Trailing Edge Debouncing
  const debounce = (delay) => {
    if (timerId) {
      // 할당되어 있는 timerId에 해당하는 타이머 제거
      clearTimeout(timerId);
    }
    timerId = setTimeout(() => {
      // timerId에 새로운 타이머 할당
      console.log(`마지막 요청으로부터 ${delay}ms지났으므로 API요청 실행`);
      timerId = null;
    }, delay);
  };

  useEffect(() => {
    return () => {
      // 페이지 이동 시 실행
      if (timerId) {
        // 메모리 누수 방지
        clearTimeout(timerId);
      }
    };
  }, [timerId]);

  return (
    <div style={{ paddingLeft: 20, paddingRight: 20 }}>
      <h1>Button 이벤트 예제</h1>
      <button onClick={() => throttle(2000)}>쓰로틀링 버튼</button>
      <button onClick={() => debounce(2000)}>디바운싱 버튼</button>
			<div>
        <button onClick={() => navigate("/company")}>페이지 이동</button>
      </div>
    </div>
  );
}

lodash 활용

lodash는 JavaScript 유틸리티 라이브러리로, 배열, 객체, 문자열 등의 데이터 조작을 쉽게 할 수 있는 다양한 함수들을 제공.
성능 최적화와 코드 가독성을 높이는 데 유용. 특히, throttle과 debounce 같은 함수도 포함되어 있어 편리.

lodash를 활용한 debouncing 테스트

// src/pages/Home.jsx

import { useState, useCallback } from "react";
import _ from "lodash";

function Home() {
  const [searchText, setSearchText] = useState("");
  const [inputText, setInputText] = useState("");

  const handleSearchText = _.debounce((text) => setSearchText(text), 2000);

  const handleChange = (e) => {
    setInputText(e.target.value);
    handleSearchText(e.target.value);
  };

  return (
    <div
      style={{
        paddingLeft: 20,
        paddingRight: 20,
      }}
    >
      <h1>디바운싱 예제</h1>
      <br />
      <input
        placeholder="입력값을 넣고 디바운싱 테스트를 해보세요."
        style={{ width: "300px" }}
        onChange={handleChange}
        type="text"
      />
      <p>Search Text: {searchText}</p>
      <p>Input Text: {inputText}</p>
    </div>
  );
}

export default Home;

문제 발생

input에서 입력한 값이 delay 간격으로 한 글자씩 반영되는 현상 발생.
원인 : 컴포넌트에서 리렌더링이 일어나면서 debounce 함수를 계속해서 생성하기 때문.

const handleSearchText = _.debounce((text) => setSearchText(text), 2000);

해결 방법

useCallback으로 함수를 memoization.

  const handleSearchText = useCallback(
    _.debounce((text) => setSearchText(text), 2000),
    []
  );

0개의 댓글