July 2, 2025

πŸ“ˆ κ³Όλ„ν•œ 이벀트 ν•Έλ“€λŸ¬ 호좜: λ””λ°”μš΄μŠ€/μ“°λ‘œν‹€λ§μ˜ λΆ€μž¬

JavaScript
React
μ„±λŠ₯
UX
λΉ„λ™κΈ°μ²˜λ¦¬
μ»΄ν¬λ„ŒνŠΈ

Summary

μž¦μ€ λΉˆλ„λ‘œ λ°œμƒν•˜λŠ” μ΄λ²€νŠΈμ— μ•„λ¬΄λŸ° μ œμ•½ 없이 ν•Έλ“€λŸ¬λ₯Ό μ—°κ²°ν•˜λ©΄ μ„±λŠ₯ μ €ν•˜μ™€ μ‚¬μš©μž κ²½ν—˜ μ•…ν™”λ₯Ό μ΄ˆλž˜ν•©λ‹ˆλ‹€. λ””λ°”μš΄μ‹±(Debouncing)κ³Ό μ“°λ‘œν‹€λ§(Throttling)을 μ‚¬μš©ν•˜μ—¬ 이벀트 ν•Έλ“€λŸ¬ μ‹€ν–‰ λΉˆλ„λ₯Ό μ΅œμ ν™”ν•¨μœΌλ‘œμ¨ μ„±λŠ₯을 κ°œμ„ ν•˜κ³  λΆ€λ“œλŸ¬μš΄ μ‚¬μš©μž κ²½ν—˜μ„ μ œκ³΅ν•΄μ•Ό ν•©λ‹ˆλ‹€.

Why Wrong?

μž¦μ€ λΉˆλ„λ‘œ λ°œμƒν•˜λŠ” 이벀트(예: scroll, resize, input, mousemove)에 μ•„λ¬΄λŸ° μ œμ•½ 없이 ν•Έλ“€λŸ¬λ₯Ό μ—°κ²°ν•˜λ©΄, 이벀트 λ°œμƒ μ‹œλ§ˆλ‹€ ν•΄λ‹Ή ν•Έλ“€λŸ¬κ°€ μ‹€ν–‰λ˜μ–΄ λ‹€μŒκ³Ό 같은 λ¬Έμ œλ“€μ„ μ•ΌκΈ°ν•©λ‹ˆλ‹€:

  1. μ„±λŠ₯ μ €ν•˜: 이벀트 ν•Έλ“€λŸ¬ λ‚΄λΆ€μ˜ DOM μ‘°μž‘, μƒνƒœ μ—…λ°μ΄νŠΈ, λ„€νŠΈμ›Œν¬ μš”μ²­ λ“± λΉ„μš©μ΄ 많이 λ“œλŠ” μž‘μ—…λ“€μ΄ 짧은 μ‹œκ°„ μ•ˆμ— 반볡적으둜 μ‹€ν–‰λ˜μ–΄ 메인 μŠ€λ ˆλ“œλ₯Ό κ³ΌλΆ€ν•˜μ‹œν‚€κ³  μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ˜ 응닡성을 λ–¨μ–΄λœ¨λ¦½λ‹ˆλ‹€. 특히 슀크둀 이벀트의 경우 μ΄ˆλ‹Ή μˆ˜μ‹­μ—μ„œ 수백 번 λ°œμƒν•  수 μžˆμŠ΅λ‹ˆλ‹€.
  2. Janky UI (λŠκΈ°λŠ” μ‚¬μš©μž κ²½ν—˜): κ³Όλ„ν•œ μ—°μ‚°μœΌλ‘œ 인해 λΈŒλΌμš°μ €μ˜ λ Œλ”λ§ νŒŒμ΄ν”„λΌμΈμ΄ λΈ”λ‘λ˜κ±°λ‚˜ μ§€μ—°λ˜μ–΄ UIκ°€ λŠκΈ°κ±°λ‚˜ λ²„λ²…κ±°λ¦¬λŠ” ν˜„μƒμ΄ λ°œμƒν•©λ‹ˆλ‹€. μ‚¬μš©μžλŠ” μ›Ή νŽ˜μ΄μ§€κ°€ λŠλ¦¬κ±°λ‚˜ λΆˆμ•ˆμ •ν•˜λ‹€κ³  느끼게 λ©λ‹ˆλ‹€.
  3. λΆˆν•„μš”ν•œ λ¦¬μ†ŒμŠ€ μ†ŒλΉ„: μ„œλ²„μ— λΆˆν•„μš”ν•œ μš”μ²­μ„ λ³΄λ‚΄κ±°λ‚˜, λΆˆν•„μš”ν•œ DOM μ—…λ°μ΄νŠΈ/νŽ˜μΈνŒ…μ„ μœ λ°œν•˜μ—¬ λ„€νŠΈμ›Œν¬ λŒ€μ—­ν­κ³Ό ν΄λΌμ΄μ–ΈνŠΈμ˜ CPU, λ©”λͺ¨λ¦¬ λ¦¬μ†ŒμŠ€λ₯Ό λ‚­λΉ„ν•©λ‹ˆλ‹€.
  4. 예츑 λΆˆκ°€λŠ₯ν•œ λ™μž‘: νŠΉμ • μƒνƒœ μ—…λ°μ΄νŠΈκ°€ μ—¬λŸ¬ 번 νŠΈλ¦¬κ±°λ˜κ±°λ‚˜, 비동기 μž‘μ—…μ΄ μ€‘λ³΅μœΌλ‘œ μ‹œμž‘λ˜μ–΄ 데이터 λΆˆμΌμΉ˜λ‚˜ μ˜ˆμƒμΉ˜ λͺ»ν•œ 버그λ₯Ό λ°œμƒμ‹œν‚¬ 수 μžˆμŠ΅λ‹ˆλ‹€.

How to Fix?

이벀트 ν•Έλ“€λŸ¬μ˜ μ‹€ν–‰ λΉˆλ„λ₯Ό μ΅œμ ν™”ν•˜κΈ° μœ„ν•΄ **λ””λ°”μš΄μ‹±(Debouncing)**κ³Ό μ“°λ‘œν‹€λ§(Throttling) 기법을 μ‚¬μš©ν•΄μ•Ό ν•©λ‹ˆλ‹€.

  • λ””λ°”μš΄μ‹± (Debouncing): νŠΉμ • μ΄λ²€νŠΈκ°€ 일정 μ‹œκ°„ λ™μ•ˆ λ‹€μ‹œ λ°œμƒν•˜μ§€ μ•Šμ„ λ•ŒκΉŒμ§€ ν•¨μˆ˜ 싀행을 μ§€μ—°μ‹œν‚΅λ‹ˆλ‹€. λ§ˆμ§€λ§‰ 이벀트 λ°œμƒ μ‹œμ μœΌλ‘œλΆ€ν„° μ§€μ •λœ delay μ‹œκ°„ λ™μ•ˆ μΆ”κ°€ μ΄λ²€νŠΈκ°€ μ—†μœΌλ©΄ ν•¨μˆ˜κ°€ μ‹€ν–‰λ©λ‹ˆλ‹€. 주둜 μž…λ ₯ ν•„λ“œ(검색 μžλ™ μ™„μ„±, μœ νš¨μ„± 검사), μœˆλ„μš° λ¦¬μ‚¬μ΄μ¦ˆ, 슀크둀 이벀트 μ’…λ£Œ μ‹œμ μ— μœ μš©ν•©λ‹ˆλ‹€.

  • μ“°λ‘œν‹€λ§ (Throttling): νŠΉμ • μ‹œκ°„(interval) λ™μ•ˆ ν•¨μˆ˜κ°€ μ΅œλŒ€ ν•œ 번만 μ‹€ν–‰λ˜λ„λ‘ μ œν•œν•©λ‹ˆλ‹€. μ΄λ²€νŠΈκ°€ 아무리 λΉ λ₯΄κ²Œ λ°œμƒν•΄λ„ μ§€μ •λœ μ‹œκ°„ 간격 λ‚΄μ—μ„œλŠ” ν•¨μˆ˜κ°€ ν•œ 번만 μ‹€ν–‰λ©λ‹ˆλ‹€. 주둜 슀크둀 이벀트(λ¬΄ν•œ 슀크둀), 마우슀 이동, κ²Œμž„μ˜ 곡격 λ²„νŠΌ 연타 λ“± 지속적인 μ΄λ²€νŠΈμ— μœ μš©ν•©λ‹ˆλ‹€.

κ΅¬ν˜„ 방법: 일반 JavaScript ν•¨μˆ˜λ‘œ 직접 κ΅¬ν˜„ν•˜κ±°λ‚˜, Lodash와 같은 λΌμ΄λΈŒλŸ¬λ¦¬μ—μ„œ μ œκ³΅ν•˜λŠ” μœ ν‹Έλ¦¬ν‹° ν•¨μˆ˜(_.debounce, _.throttle)λ₯Ό μ‚¬μš©ν•˜λŠ” 것이 μΌλ°˜μ μž…λ‹ˆλ‹€. React ν™˜κ²½μ—μ„œλŠ” useCallback ν›…κ³Ό useRefλ₯Ό ν•¨κ»˜ μ‚¬μš©ν•˜μ—¬ λ””λ°”μš΄μŠ€/μ“°λ‘œν‹€ ν•¨μˆ˜κ°€ μ»΄ν¬λ„ŒνŠΈ 라이프사이클 λ™μ•ˆ μΌκ΄€λ˜κ²Œ μœ μ§€λ˜λ„λ‘ 관리해야 ν•©λ‹ˆλ‹€.

Before Code (Bad)

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

function SearchInput() {
  const [searchText, setSearchText] = useState('');
  const [results, setResults] = useState([]);

  // 문제점: input 값이 변경될 λ•Œλ§ˆλ‹€(κΈ€μž ν•˜λ‚˜ν•˜λ‚˜ μž…λ ₯ μ‹œ) API 호좜
  // μ‚¬μš©μžκ°€ 'hello'λ₯Ό μž…λ ₯ν•˜λ©΄ 'h', 'he', 'hel', 'hell', 'hello' 총 5번 호좜 λ°œμƒ
  const fetchSearchResults = async (query) => {
    if (query.length < 2) {
      setResults([]);
      return;
    }
    console.log(`API 호좜: ${query}`);
    // μ‹€μ œ API 호좜 둜직 (μ˜ˆμ‹œ)
    const response = await fetch(`/api/search?q=${query}`);
    const data = await response.json();
    setResults(data);
  };

  const handleChange = (e) => {
    const value = e.target.value;
    setSearchText(value);
    fetchSearchResults(value); // 문제점: λ§€ μž…λ ₯λ§ˆλ‹€ 호좜
  };

  return (
    <div>
      <input
        type="text"
        value={searchText}
        onChange={handleChange}
        placeholder="검색어λ₯Ό μž…λ ₯ν•˜μ„Έμš”"
      />
      <ul>
        {results.map((item, index) => (
          <li key={index}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

export default SearchInput;
```

```jsx
import React, { useEffect, useState } from 'react';

function ScrollTracker() {
  const [scrollPos, setScrollPos] = useState(0);

  // 문제점: 슀크둀 μ‹œλ§ˆλ‹€ μ΄λ²€νŠΈκ°€ λ°œμƒν•˜κ³ , 이에 따라 μƒνƒœ μ—…λ°μ΄νŠΈ 및 λ¦¬λ Œλ”λ§μ΄ κ³Όλ„ν•˜κ²Œ λ°œμƒ
  const handleScroll = () => {
    setScrollPos(window.scrollY);
    // 슀크둀 μœ„μΉ˜μ— 따라 μΆ”κ°€ 둜직 (예: νŠΉμ • 슀크둀 μœ„μΉ˜μ—μ„œ UI λ³€κ²½ λ“±)
    console.log('Scroll position:', window.scrollY);
  };

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);

  return (
    <div style={{ height: '200vh', paddingTop: '50px' }}>
      <h1>슀크둀 ν…ŒμŠ€νŠΈ</h1>
      <p>ν˜„μž¬ 슀크둀 μœ„μΉ˜: {scrollPos}</p>
      <div style={{ height: '100vh', background: 'lightgray' }}>
        μŠ€ν¬λ‘€ν•˜μ—¬ 이 μ˜μ—­μ„ μ§€λ‚˜κ°€μ„Έμš”.
      </div>
    </div>
  );
}

export default ScrollTracker;

After Code (Good)

import React, { useState, useEffect, useCallback, useRef } from 'react';
import debounce from 'lodash.debounce'; // λ˜λŠ” 직접 κ΅¬ν˜„ν•œ debounce ν•¨μˆ˜

function SearchInputOptimized() {
  const [searchText, setSearchText] = useState('');
  const [results, setResults] = useState([]);

  const fetchSearchResults = async (query) => {
    if (query.length < 2) {
      setResults([]);
      return;
    }
    console.log(`API 호좜: ${query}`);
    const response = await fetch(`/api/search?q=${query}`);
    const data = await response.json();
    setResults(data);
  };

  // useRefλ₯Ό μ‚¬μš©ν•˜μ—¬ debounce ν•¨μˆ˜λ₯Ό μ»΄ν¬λ„ŒνŠΈ 생애주기 λ™μ•ˆ μœ μ§€
  // useEffect λ°–μ—μ„œ 직접 debounceλ₯Ό ν˜ΈμΆœν•˜λ©΄ λ Œλ”λ§ μ‹œλ§ˆλ‹€ μƒˆλ‘œμš΄ ν•¨μˆ˜κ°€ μƒμ„±λ˜μ–΄ debounceκ°€ μ œλŒ€λ‘œ λ™μž‘ν•˜μ§€ μ•Šμ„ 수 있음
  const debouncedFetchResults = useRef(
    debounce((query) => fetchSearchResults(query), 500)
  ).current;

  // μ»΄ν¬λ„ŒνŠΈ μ–Έλ§ˆμš΄νŠΈ μ‹œ debounce 타이머 클리어
  useEffect(() => {
    return () => {
      debouncedFetchResults.cancel(); // Lodash debounce의 cancel λ©”μ„œλ“œ
    };
  }, [debouncedFetchResults]);

  const handleChange = (e) => {
    const value = e.target.value;
    setSearchText(value);
    debouncedFetchResults(value); // λ””λ°”μš΄μŠ€λœ ν•¨μˆ˜ 호좜
  };

  return (
    <div>
      <input
        type="text"
        value={searchText}
        onChange={handleChange}
        placeholder="검색어λ₯Ό μž…λ ₯ν•˜μ„Έμš” (500ms 이후 검색)"
      />
      <ul>
        {results.map((item, index) => (
          <li key={item.id || index}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

export default SearchInputOptimized;
```

```jsx
import React, { useEffect, useState, useRef } from 'react';
import throttle from 'lodash.throttle'; // λ˜λŠ” 직접 κ΅¬ν˜„ν•œ throttle ν•¨μˆ˜

function ScrollTrackerOptimized() {
  const [scrollPos, setScrollPos] = useState(0);

  const handleScroll = () => {
    setScrollPos(window.scrollY);
    console.log('Scroll position:', window.scrollY);
  };

  // useRefλ₯Ό μ‚¬μš©ν•˜μ—¬ throttle ν•¨μˆ˜λ₯Ό μ»΄ν¬λ„ŒνŠΈ 생애주기 λ™μ•ˆ μœ μ§€
  const throttledHandleScroll = useRef(
    throttle(() => handleScroll(), 200)
  ).current; // 200msλ§ˆλ‹€ μ΅œλŒ€ 1번 μ‹€ν–‰

  useEffect(() => {
    window.addEventListener('scroll', throttledHandleScroll);
    return () => {
      window.removeEventListener('scroll', throttledHandleScroll);
      throttledHandleScroll.cancel(); // Lodash throttle의 cancel λ©”μ„œλ“œ
    };
  }, [throttledHandleScroll]);

  return (
    <div style={{ height: '200vh', paddingTop: '50px' }}>
      <h1>슀크둀 ν…ŒμŠ€νŠΈ (μ΅œμ ν™”λ¨)</h1>
      <p>ν˜„μž¬ 슀크둀 μœ„μΉ˜: {scrollPos}</p>
      <div style={{ height: '100vh', background: 'lightgray' }}>
        μŠ€ν¬λ‘€ν•˜μ—¬ 이 μ˜μ—­μ„ μ§€λ‚˜κ°€μ„Έμš”.
      </div>
    </div>
  );
}

export default ScrollTrackerOptimized;