웹에서 사용자 경험은 중요하다는 말을 많이 들어봤에서 사용자 경험은 중요하다는 말을 많이 들어봤습니다.
페이지가 느리거나 반응이 느리면 사용자는 금세 흥미를 잃고 떠나기 때문인데요!
React는 효율적인 VDOM을 사용하고, 자동으로 최적화를 해주지만,
React가 기본적으로 해주는 최적화는 한계가 있습니다.
상태 업데이트가 필요하지 않은 컴포넌트가 렌더링되면,
렌더링이 불필요하게 발생하기 때문에 성능을 저하시킬 수 있습니다.
사용자가 타이핑을 빠르게 하거나, 스크롤을 계속 하게 된다면 렌더링 병목 현상이 발생할 수도 있습니다.
따라서 개발자는 React 성능 최적화를 고려하며 개발을 해야 합니다.
React Profiler로 성능 진단하기
React Profiler는 성능 문제를 시각적으로 파악할 수 있는 강력한 도구입니다.
특정 컴포넌트가 얼마나 자주 렌더링되는지, 그리고 렌더링에 얼마나 시간이 걸리는지 확인할 수 있습니다. 이를 통해 애플리케이션 성능을 분석하고 최적화의 우선순위를 정할 수 있습니다.
주의깊게 봐야 할 부분
- Commit Phase
React가 실제 DOM에 변경 사항을 반영하는 단계입니다.
성능 병목이 발생한다면, 이 단계에서 불필요한 DOM 업데이트를 확인해야 합니다.- Render Phase
React가 VDOM을 생성하는 단계입니다.
이 단계에서 비효율적인 코드가 존재할 경우, 성능 저하를 초래할 가능성이 큽니다.
불필요한 상태 업데이트나 과도한 렌더링이 원인일 수 있습니다.
무언가를 검색하다가 자동 제안이 표시되는 동안 끊기기 시작한 적이 있나요?
브라우저가 사용자가 입력한 키로 자동 제안 목록과 입력 상자를 업데이트하려고 하기 때문에 발생합니다.
오늘은 Debounce와 Throttle을 중점으로 어떻게 최적화할 수 있는지 알아보겠습니다.

Debouncing, in the context of programming, means to "batch" all operations requested during a specific interval into a single invocation.
MDN에 따르면 Debounce는 특정 간격 동안 요청된 모든 단일 호출로 "일괄 처리"하는 것을 의미합니다.
일반적으로 사용자가 타이핑하는 동안 UI가 지연되는 것을 방지하기 위해 다른 작업을 수행해서는 안되는데요,
사용자가 타이핑을 일시중지하면 결과 필터링, 제안 제공 등과 같이 입력처리를 시작할 수 있습니다.
function debounce(func, delay) {
let timeoutId; // 이전 타이머를 저장하기 위한 변수
return (...args) => {
clearTimeout(timeoutId); // 기존 타이머를 초기화 (기존 작업 취소)
// 새로운 타이머 설정 (delay 시간이 지난 후 func 실행)
timeoutId = setTimeout(() => {
func(...args); // 설정된 시간이 지나면 함수 실행
}, delay);
};
}
import { useCallback, useRef } from 'react';
function useDebounce(callback, delay) {
const timeoutRef = useRef(null); // 타이머 ID를 저장하는 Ref
return useCallback((...args) => {
// 기존 타이머를 취소하여 중복 실행 방지
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// 새로운 타이머 설정
timeoutRef.current = setTimeout(() => {
callback(...args); // 설정된 시간이 지나면 콜백 실행
}, delay);
}, [callback, delay]); // callback과 delay가 변경될 때만 새롭게 함수 생성
}
검색 입력, 폼 유효성 검증과 같은 작업은 사용자의 입력 빈도가 높기 때문에 제한하지 않으면 API 호출이 과도하게 발생할 수 있습니다.
Debounce는 연속 이벤트를 마지막 이벤트로 축약하여 리소스 낭비를 줄일 수 있습니다.

In the context of programming, it refers to slowing down a process such that an operation can only be performed at a certain rate.
MDN에 따르면 Throttle은 원래 장애물을 사용하여 유체 흐름 속도를 늦추는 것을 의미하는데,
프로그래밍 맥락에서는 특정 속도로만 작업을 수행할 수 있도록 프로세스를 늦추는 것을 말합니다.
function throttle(func, delay) {
let shouldWait = false; // 함수 실행 가능 여부
let waitingArgs = null; // 대기 중인 함수 호출의 인수
const timeoutFunc = () => {
if (waitingArgs == null) {
shouldWait = false; // 대기 중인 호출이 없으면 실행 가능 상태로 전환
} else {
func(...waitingArgs); // 대기 중이던 호출 실행
waitingArgs = null; // 대기 상태 초기화
setTimeout(timeoutFunc, delay); // 다음 호출을 예약
}
};
return (...args) => {
if (shouldWait) {
waitingArgs = args; // 호출을 대기 상태로 설정
return;
}
func(...args); // 함수 실행
shouldWait = true; // 실행 후 일정 시간 동안 대기 상태로 전환
setTimeout(timeoutFunc, delay); // delay 후 실행 가능 상태로 전환
};
}
import { useCallback, useRef } from 'react';
function useThrottle(callback, delay) {
const lastCall = useRef(0); // 마지막 호출 시간을 저장하는 Ref
return useCallback((...args) => {
const now = Date.now(); // 현재 시간
if (now - lastCall.current >= delay) { // 마지막 호출 시간이 delay를 초과했을 때 실행
lastCall.current = now; // 현재 시간을 업데이트
callback(...args); // 콜백 함수 실행
}
}, [callback, delay]); // callback과 delay가 변경될 때만 함수 새로 생성
}
스크롤, 마우스 이동, 리사이즈 이벤트 등은 사용자가 빠르게 연속적으로 발생시키는 경우가 많은데, 제어하지 않는다면 브라우저가 많은 리소스를 소모하며 성능 저하를 유발할 수 있습니다.
스크롤 이벤트는 트랙패드나 스크롤 휠로 느리게 움직여도 초당 30번, 모바일에서는 초당 100번까지 발생할 수 있습니다. 이러한 빈도에서 비효율적인 핸들러를 직접 연결하면 UI가 느려지고 사용자 경험이 저하될 가능성이 큽니다.
Throttle 와 Debounce 의 차이점은 이벤트를 언제 발생 시킬지의 시점 차이입니다.
따라서 작업물의 성격에 따라 사용방법이 달라질 수 있습니다.
대표적인 예로 자동완성 만들 경우,
일정 주기로 자동으로 완성되는 리스트를 보여주는 것에는
사용자 측면에서 Throttle (검색 되는 경험) 가 유리할 수 있지만,
성능상에서는 Debounce (1번만 호출) 가 훨씬 유리할 수 있습니다.
사용자가 타이핑을 멈춘 후 잠시 후에만 검색을 수행하는 것을 Debounce,
사용자가 타이핑하는 동안 주기적으로 검색을 수행하는 것을 Throttle이라고 합니다.
사용자가 긴 문구를 타이핑하고 있고 Debounce가 되고 있다면, API 호출을 하기 위해 타이핑을 기다리고 있기 때문에 사용자는 작동하지 않아 보이는 자동 제안을 대기하게 됩니다.
따라서 자동 제안이 고정된 시간 간격으로 업데이트되도록 Throttle을 사용해야 합니다.
Debounce와 Throttle은 유사하지만 다른 특징을 살려서 함께 자주 사용됩니다!
실제로 2011년에 Twitter는 아래로 피드를 스크롤하면 느리고 응답하지 않았습니다.
따라서 외부에서 250ms마다 루프를 실행하면서 해결했다고 합니다.
요즘에는 이런 이벤트 처리를 Debounce와 Throttle을 사용해서 조금 더 정교하게 다룰 수 있게 되었습니다!
현재 네이버나 구글의 검색창에 내용을 입력할 때는 아무것도 나오지 않다가
입력을 멈추면 연관 검색어가 뜨는 것도 Debounce로 구현했습니다.

추가로, 인스타그램에서 무한 스크롤을 구현할 때도 Throttle로 스크롤 이벤트를 최적화했다고 합니다.

A modern JavaScript utility library delivering modularity, performance & extras.
Lodash는 모듈화, 성능 및 기타 기능을 제공하는 자바스크립트 유틸리티 라이브러리입니다.
debounce와 throttle을 자바스크립트 코드로 작성하면 복잡한데,
해당 라이브러리를 사용하면 복잡한 클로저와 타이머 관리 코드를 직접 작성할 필요 없이 간단하게 구현할 수 있습니다.
🔗 debounce
🔗 throttle
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js">
</script>
<script defer src="main.js"></script>
</head>
<body>
<button>click</button>
<pre>일반 클릭 이벤트 카운터 <span class="normal-msg">0</span></pre>
<pre>디바운스 클릭 이벤트 카운터 <span class="debounce-msg">0</span></pre>
<pre>스로틀 클릭 이벤트 카운터 <span class="throttle-msg">0</span></pre>
</body>
</html>
const button = document.querySelector('button');
const normalMsg = document.querySelector('.normal-msg');
const debounceMsg = document.querySelector('.debounce-msg');
const throttleMsg = document.querySelector('.throttle-msg');
button.addEventListener('click', () => {
normalMsg.textContent = +normalMsg.textContent + 1;
});
button.addEventListener('click', _.debounce(() => {
debounceMsg.textContent = +debounceMsg.textContent + 1;
}, 500)); // 500ms 동안 버튼 클릭 이벤트를 취합하여 마지막 이벤트만 실행
button.addEventListener('click', _.throttle(() => {
throttleMsg.textContent = +throttleMsg.textContent + 1;
}, 500)); // 버튼 클릭 이벤트를 500ms 간격으로 제한
Throttle이 200ms로 설정되었지만, 이벤트가 1초동안 발생하지 않을 경우 콜백이 강제로 실행됩니다.
leading: 함수가 처음 호출될 때 즉시 실행trailing: 함수가 마지막 호출 후에도 한 번 실행1️⃣ 사용자가 타이핑을 멈춘 후 최종 검색어로 API 요청을 보내야 하는 경우
const handleSearch = _.debounce((query) => { console.log("Search query:", query); }, 300, { trailing: true });2️⃣ 브라우저 창 크기를 조정하는 Resize 이벤트에서 마지막 크기만 처리할 경우
const handleResize = _.debounce(() => { console.log("Resized:", window.innerWidth, window.innerHeight); }, 500, { trailing: true }); window.addEventListener("resize", handleResize);3️⃣ 스크롤 이벤트(무한 스크롤)
const handleScroll = _.throttle(() => { console.log("Fetching more data..."); }, 200, { leading: true, trailing: true }); window.addEventListener("scroll", handleScroll);
maxWait: 지정된 시간 내에 최소한 한 번은 실행되도록 보장무한 스크롤에서 데이터가 너무 늦게 로드되는 것을 방지
const throttledFunction = _.throttle(callback, 200, { maxWait: 1000 });
Tanstack Query(React Query)는 비동기 상태 관리를 간단히 하고, 데이터의 캐싱, 동기화, refetch 등을 자동화하는 강력한 라이브러리입니다.
하지만, 사용자가 빠르게 연속적인 이벤트(검색 요청, 필터 변경)를 발생시키면,
이벤트가 발생할 때마다, 쿼리키가 변경될 때마다 발생한 이벤트로 useQuery가 즉시 실행됩니다. 결과적으로 검색어가 변경될 때마다 API 요청이 가기 때문에 서버 부하와 클라이언트 성능 저하가 유발됩니다.
너무 많은 요청으로 인해 사용자가 검색 결과를 기다리는 동안 UI가 느리게 반응하거나 끊기는 것처럼 보일 수 있습니다.
그렇지만 Tanstack Query는 자체적으로 Debounce와 Throttle 기능을 제공해주지 않습니다.
따라서 useDebounce 또는 useThrottle과 함께 파라미터에 훅을 씌워서 사용해야 합니다.
1️⃣ Debounce
const SearchComponent= () => {
const [searchTerm, setSearchTerm] = useState('');
// debounce된 검색어
const debouncedSearchTerm = useDebounce(searchTerm, 300);
// Tanstack Query로 API 호출
const { data, error, isLoading } = useQuery(
['searchResults', debouncedSearchTerm],
() => fetchSearchResults(debouncedSearchTerm),
{
enabled: !!debouncedSearchTerm, // 검색어가 있을 때만 실행
}
);
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="검색어를 입력하세요"
/>
{isLoading && <p>Loading...</p>}
{error && <p>Error: {error.message}</p>}
<ul>
{data?.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
setSearchTerm으로 검색어 상태를 업데이트하고,useDebounce로 입력이 멈춘 후 300ms 동안 대기하여 검색어를 안정화합니다.debouncedSerachTerm)를 기반으로 Tanstack Query가 API를 호출합니다.2️⃣ Throttle
const SearchComponent= () => {
const [searchTerm, setSearchTerm] = useState('');
// throttle된 검색어
const throttledSearchTerm = useThrottle(searchTerm, 500);
// Tanstack Query로 API 호출
const { data, error, isLoading } = useQuery(
['searchResults', throttledSearchTerm],
() => fetchSearchResults(throttledSearchTerm),
{
enabled: !!throttledSearchTerm, // 검색어가 있을 때만 실행
}
);
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="검색어를 입력하세요"
/>
{isLoading && <p>Loading...</p>}
{error && <p>Error: {error.message}</p>}
<ul>
{data?.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
setSearchTerm으로 검색어 상태를 업데이트하고,useThrottle로 입력 이벤트가 발생하더라도 500ms마다 API 요청을 발생시킵니다.throttledSearchTerm)를 기반으로 데이터를 가져옵니다.3️⃣ Debounce + Throttle
Debounce는 사용자가 입력을 멈춘 뒤 일정 시간이 지난 후에만 실행되므로, 타이핑 도중 결과가 느리게 반응할 수 있습니다.
Throttle은 일정 간격으로 결과를 업데이트하므로, 타이핑 중간에도 실시간으로 데이터를 보여줄 수 있지만, 입력이 끝난 후에도 마지막 데이터를 즉시 반영하지 못할 수 있습니다.
따라서 실시간 업데이트와 최종 결과 반영을 위해 두가지를 조합해서 사용합니다.
const SearchComponent = () => {
const [searchTerm, setSearchTerm] = useState('');
// Debounce된 검색어: 입력이 멈춘 후 안정화된 검색어
const debouncedSearchTerm = useDebounce(searchTerm, 300);
// Throttle된 검색어: 입력 도중에도 일정 간격으로 업데이트
const throttledSearchTerm = useThrottle(searchTerm, 500);
// Tanstack Query로 API 호출
const { data, error, isLoading } = useQuery(
['searchResults', debouncedSearchTerm || throttledSearchTerm],
() => fetchSearchResults(debouncedSearchTerm || throttledSearchTerm),
{
enabled: !!(debouncedSearchTerm || throttledSearchTerm), // 검색어가 있을 때만 실행
}
);
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="검색어를 입력하세요"
/>
{isLoading && <p>Loading...</p>}
{error && <p>Error: {error.message}</p>}
<ul>
{data?.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
};
Debounce와 Throttle은 내부적으로 클로저를 사용해 timeoutId와 같은 변수를 캡처해서 상태를 유지합니다.
이때 문제가 발생할 수 있습니다.
따라서 컴포넌트 언마운트 시 타이머를 꼭 정리해줘야 합니다.
Lodash를 사용한다면 cancel() 메서드를 활용하거나
useEffect(() => {
return () => {
handleClick.cancel && handleClick.cancel(); // Lodash의 cancel 메서드 활용
};
}, []);
직접 구현했다면 clearTimeout(), clearInterval()로 명시적으로 정리해야 합니다.
useEffect(() => {
return () => {
clearTimeout(timeoutRef.current); // 타이머 명시적 해제
};
}, []);
useRef를 사용해서 안전하게 타이머를 관리하는 방법도 있습니다.
리렌더링 간 타이머 ID를 안전하게 보관할 수 있습니다.
const timeoutRef = useRef(null);
function useDebounce(callback, delay) {
return (...args) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => callback(...args), delay);
};
}
Debounce와 Throttle을 구현할 때, 매 이벤트마다 새로운 클로저를 생성하면 메모리와 CPU 리소스가 불필요하게 낭비됩니다.
const handleClick = () => {
debounce(() => {
console.log("Clicked");
}, 300)();
};
예시 코드는debounce 함수가 매 이벤트 호출시마다 새로 생성됩니다.
useRef를 사용해서 debouncedClick을 렌더링 간 동일한 객체로 유지한다면,
클로저를 재사용하면서 이 문제를 해결할 수 있습니다.
const debouncedClick = useRef(
debounce(() => {
console.log("Clicked");
}, 300)
);
// ...
<button onClick={debouncedClick.current}>Click Me</button>;
이벤트 핸들러 밖에서 Debounce/Throttle 함수의 인스턴스를 생성해서 재사용할 수 있습니다.
메모리 최적화를 하기 위해서는 리렌더링마다 불필요한 함수를 재생성하지 않도록 해야 합니다.
먼저 잘못 작성된 예시 코드를 확인해봅시다!
function App() {
const [count, setCount] = useState(0);
const debouncedFn = debounce(
() => {
setCount((c) => c + 1);
},
1000,
{ trailing: false, leading: true }, // leading 설정으로 즉시 실행
);
return (
<div>
<button type="button" onClick={debouncedFn}>
add count
</button>
</div>
);
}
1초 동안 setCount 실행을 방지하기 위해 debounce를 적용했지만,
함수가 실행될 때 count가 증가하면서 리렌더링이 발생하고,
렌더링마다 debounce가 재실행되면서 새로운 debouncedFn함수가 생성되어 debounce의 타이머 처리가 무의미해집니다.
debouncedFn의 재생성
- 함수 내부에서 선언되었기 때문에, 매번 렌더링 시 새로운 함수가 생성됨
- Debounce는 내부적으로 타이머를 관리하지만, 매 렌더링마다 새로운 타이머가 생성되기 때문에 이전 타이머가 의미가 없음
따라서 useRef로 debouncedFn을 캐싱해서 재생성을 방지해야 합니다.
const debouncedFn = useRef(
debounce(() => {
setCount((c) => c + 1);
}, 1000, { trailing: false, leading: true })
).current;
useRef는 렌더링 간 동일한 객체를 유지하기 때문에, 한번만 생성되고 재사용이 가능하고 타이머가 정상으로 관리됩니다.
정리해보자면,
1. 컴포넌트 언마운트 시 반드시 타이머를 정리해야 합니다.
2. Debounce/Throttle 함수 재생성되지 않도록 방지해야 합니다.
3. 이벤트 핸들러 외부에서 Debounce/Throttle 함수를 생성해서 재사용하여 불필요한 클로저를 생성하지 않습니다.
Debounce와 Throttle로 성능을 최적화할 수 있지만,
지나치게 긴 delay를 설정하면 사용자 반응이 느려져서 즉각 반영되지 않아 "버벅임"으로 착각할 수 있어 사용자 경험을 오히려 저하시킬 수 있습니다.
Throttle을 사용하면 이벤트 발생 빈도를 제한하기 때문에 중요한 실시간 데이터에서는 적합합지 않고, 또 이벤트가 누락될 가능성도 있기 때문에 너무 남용하는 것은 좋지 않다고 합니다!
꼭 필요한 상황인지 고민하고 도입하는 것이 좋을 것 같습니다!
Throttle 이외에도 requestAnimationFrame을 활용하는 방법도 있다고 하는데,
고빈도 이벤트에서 부드럽고 효율적인 애니메이션을 구현할 수 있다고 합니다.
| 특징 | requestAnimationFrame | Throttle |
|---|---|---|
| 호출 빈도 | 브라우저 리프레시 속도에 맞춰 호출 (보통 60fps) | 설정된 고정 시간 간격마다 호출 |
| 주요 사용 사례 | 애니메이션, 스크롤 최적화 | API 호출 제한, 리소스 절약 |
| 장점 | 부드럽고 정확한 타이밍 | 구현 간단, 이벤트 제어 용이 |
| 단점 | 렌더링 작업에 적합하지 않은 경우 비효율적 | 고정된 시간 간격으로 인해 덜 자연스러움 |
브라우저의 리프레시 주기에 맞춰서 함수를 실행해서 성능을 최적화하고 렌더링 병목 현상을 줄일 수 있어서 RAF를 주제로 공부를 해보고 싶습니다!!
https://developer.mozilla.org/en-US/docs/Glossary/Debounce
https://developer.mozilla.org/en-US/docs/Glossary/Throttle
https://css-tricks.com/debouncing-throttling-explained-examples/
https://velog.io/@chaevivi/JS-lodash%EC%9D%98-debounce%EC%99%80-throttle%EB%A1%9C-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%ED%95%98%EA%B8%B0
https://itchallenger.tistory.com/568
안녕하세요! 아티클 작성하시느라 고생하셨습니다. 아티클을 읽으면서 새로 알게 된 점이 많았는데요! useRef를 사용해 debouncedClick을 렌더링 간 동일한 객체로 유지함으로써 이벤트마다 새로운 클로저가 생성되는 문제를 방지할 수 있다는 점을 알게 되었고, requestAnimationFrame도 처음 들어보는데 Throttle과의 차이를 표로 잘 보여주셔서 비교가 잘 되네요. 또한, 네이버 검색창, 인스타그램 무한 스크롤, Tanstack Query 등의 예를 통해 실제 적용 사례를 보여주셔서 어떻게 적용해야 할지 감이 좀 잡히는 것 같아요. 수고하셨습니당!
안녕하세요 채현님 이번 6주차 아티클 잘 읽었습니다!
비슷한듯 다른 debounce와 throttle에 대해 각각의 장단점과 실제 사용 예시를 바탕으로 잘 설명해주셔서 이해하기 수월했습니다!
Lodash 라이브러리를 통한 사용 방법과 tanstack query에서 어떻게 사용해야 할지 상세하게 적어주신 덕분에 추후에도 이 아티클을 다시 참고할 순간이 올 것 같아요!! 양질의 아티클 작성해주시느라 고생 많으셨습니다😄