api 와장창 호출하지 않는 법 (Feat. 디바운스)

­가은·2024년 1월 4일
147
post-thumbnail

당신이 개발자라면 디바운스와 스로틀링에 대해 한 번쯤은 들어본 적이 있을 것이다.
들어보지 못했다고? 그럼 지금부터 알아가면 되는 것!

난 디바운스와 스로틀링에 대해 개념적으로만 알고있었고, 실제로 코드에 적용해본 적은 없었다.
심지어 작년 디프만 12기 면접 때 디바운스와 스로틀링에 관한 질문을 받고 대답하지 못한 경험도 있다...
물론 합격은 했음. 🤔

아무튼 이번에 부스트캠프 8기 그룹프로젝트를 진행하며 디바운스를 적용해볼 기회가 생겼다.
일단은 디바운스와 스로틀링의 개념에 대해 간단히 공부한 후, 검색창에 디바운스를 적용해보자.




🍞 목표

디바운스와 스로틀링에 대해 알아보고,
검색창 구현에 디바운스를 적용해보자.


🍞 디바운스와 스로틀링

디바운스스로틀링과도한 이벤트 핸들러의 호출을 방지하는 프로그래밍 기법이다.
예를 들면, 우리가 마우스를 움직이거나 스크롤하면 연속적인 움직임이 발생할 것이다.
그러면 이벤트가 짧은 시간 내에 와장창 발생하게 된다.
이러한 이벤트는 이벤트 핸들러를 과도하게 호출하여 성능에 문제를 일으키게 된다.
디바운스와 스로틀링은 이렇게 짧은 시간 간격으로 연속해서 발생하는 이벤트를 그룹화하는 기법이다.
그럼 이제 디바운스와 스로틀링 각각에 대해 조금 더 알아보자.


🍞 디바운스

디바운스 (debounce)는 짧은 시간 간격으로 연속해서 이벤트가 발생하면 이벤트 핸들러를 호출하지 않다가, 일정 시간이 경과한 이후에 이벤트 핸들러가 한 번만 호출되도록 한다.
짧은 시간 간격으로 발생하는 이벤트를 그룹화해서 마지막 한 번만 이벤트 핸들러가 호출되도록 하는 것이다.
아래 그림을 보자.

출처: 자바스크립트 딥다이브 ch41

이렇게 이벤트 (e)가 연속해서 발생하면 이벤트 핸들러를 호출하지 않고 있다가,
일정 시간 (delay)동안 이벤트가 더 이상 발생하지 않으면 이벤트 핸들러 (f)를 호출한다.

디바운스를 적용하면 좋을 상황으로, 먼저 브라우저 창 크기를 조절 하는 경우 (resize 이벤트 처리)가 있다.
창 resize 시 실행되어야 하는 함수가 있다면, 브라우저 창 크기를 조절할 때마다 계속해서 해당 함수가 실행된다면 성능상 문제가 생길 수 있다.
이 때 디바운스를 적용해 브라우저 창 크기 조절이 완료된 후에 함수가 실행되도록 할 수 있다.

버튼 중복 클릭 방지 처리에도 적용할 수 있다.
우리가 인터넷상에 글을 쓴다고 생각해보자.
완료 버튼을 눌렀는데, 모종의 문제로 사이트가 느려져 글이 바로 업로드되지 않는 것이다...
그럼 그 상황이 답답한 한국 사람들은 완료 버튼을 두다다닥다닥 누를 것이고..
버튼을 10번 누르면 글이 10번 써지는 끔찍한 상황이 발생한다. ㅠㅠ
이 때 버튼을 한 번 누른 후에 버튼을 비활성화하는 방법도 있지만, 디바운스를 적용하여 해결할 수도 있다.
버튼을 연속으로 10번 눌러도 가장 마지막 클릭을 기준으로 한 번만 이벤트 핸들러를 호출하게 하는 것이다.

그리고 디바운스가 가장 자주 쓰이는 경우는 input에 값을 입력할 때이다.
이 부분은 밑에서 직접 적용하며 자세히 알아보자.

실제로는 디바운스를 직접 구현하는 것보다 Underscore의 debounce 함수Lodash의 debounce 함수 사용을 권장한다고 한다.
난 이번에 디바운스를 직접 구현해보고 싶어서 사용하지 않았지만, 나중에 사용해볼까 한다.


🍞 스로틀링

스로틀링 (throttling)은 짧은 시간 간격으로 이벤트가 연속해서 발생하더라도, 일정 시간 간격으로 이벤트 핸들러가 최대 한 번만 호출되도록 한다.
디바운스와의 차이점은,
디바운스는 짧은 시간 간격으로 발생하는 이벤트를 그룹화해서 마지막에 한 번만 이벤트 핸들러를 호출하는 방식이라면,
스로틀링은 짧은 시간 간격으로 발생하는 이벤트를 그룹화해서 호출 주기를 만들어 일정 시간 단위로 이벤트 핸들러를 호출한다.
그림으로 비교해서 보자.
위가 디바운스, 아래가 스로틀링이다.

출처: 자바스크립트 딥다이브 ch41

출처: 자바스크립트 딥다이브 ch41

스로틀링은 호출 주기, 즉 특정 시간 (delay)이 경과하기 이전에 이벤트 (e)가 발생하면 아무것도 하지 않는다.
그리고 delay 시간이 경과했을 때 이벤트가 발생하면 이벤트 핸들러 (f)를 호출하고 새로운 타이머를 재설정한다.
그러면 이렇게 delay 시간 간격으로 이벤트 핸들러가 호출되게 된다.

스로틀링을 사용하면 좋을 경우로는 scroll 이벤트 처리가 있다.
위에 잠시 언급했듯이 사용자가 스크롤할 때도 이벤트가 짧은 시간 간격으로 연속해서 발생하게 된다.
이 때 일정 시간 단위, 예를 들면 100ms 간격으로 이벤트 핸들러가 호출되도록 호출 주기를 만들 수 있다.

스로틀링의 경우도 Underscore의 throttle 함수Lodash의 throttle 함수를 사용하는 것을 권장한다고 한다.


🍞 검색에 디바운스 적용하기

내가 하는 프로젝트에서는 닉네임 검색창을 구현해야 했는데, 닉네임을 입력하면 하단에 해당 닉네임과 유사한 닉네임들이 보여져야 했다.
이 때 사용자가 검색창 input의 값을 변경할 때마다 서버단에서 유사 닉네임 목록을 새로 불러와야한다.

만약 사용자가 '도라에몽'을 검색하고 싶다면,
ㄷ -> 도 -> 돌 -> 도라 -> 도랑 -> 도라에 -> 도라엠 -> 도라에모 -> 도라에몽
과 같이 각 단계마다 api를 호출하게 된다.
결국 '도라에몽'이라는 단어 하나 입력하는 데만 api 호출을 9번 하게 되는 것이다.

이렇게 사용자가 input에 값을 입력할 때마다 이벤트 핸들러를 호출하게 되면 성능상 문제가 생길 수 있다.
디바운스를 적용해 사용자가 입력을 완료했을 때 한 번만 이벤트 핸들러가 동작하게 해보자.

사용자가 입력을 완료했는지 여부를 정확히 알 수 없으므로, 일정 시간 동안 input에 값을 입력하지 않으면 입력이 완료된 것으로 간주할 것이다.

먼저 디바운스를 적용하기 전 코드를 살펴보자.
디바운스 적용에 집중하기 위해 중요하지 않은 것들은 삭제하고 기본적인 코드만 남겨두었다.

export default function SearchBar() {
	const [searchValue, setSearchValue] = useState(''); // 검색창 input값
	const [searchResults, setSearchResults] = useState([]); // 검색창 input값과 유사한 닉네임 목록
  
  	useEffect(() => {
		fetchSearchResults();
	}, [searchValue]); // searchValue가 변경될 때마다 서버에서 searchValue와 유사한 닉네임 목록을 받아옴

	const fetchSearchResults = async () => {
      	if (!searchValue) {
			setSearchResults([]);
			return;
		}
      
		const nickNameDatas = await getNickNames(searchValue); // searchValue와 유사한 닉네임 목록 받아옴
		const nickNames = nickNameDatas
			.map((data: { nickname: string; id: number }) => data.nickname) // data 중에서 nickName만 사용
			.slice(0, 5); // 유사 닉네임은 5개까지만 보여줌

		setSearchResults(nickNames);
	};

	const handleSearchButton = async () => {
      // 검색 버튼 눌렀을 때 일어날 동작
	};

	return (
		<Search // 직접 만든 Search 컴포넌트 사용
			onSubmit={handleSearchButton} // 검색 버튼 눌렀을 때 실행할 함수
			inputState={searchValue} // input값 state
			setInputState={setSearchValue} // input값 setState
			placeholder="닉네임을 입력하세요" 
			results={searchResults} // input값과 유사한 닉네임 목록
			style={{width: '320px'}}
		/>
	);
}

이 때 동작화면을 살펴보자.

api를 호출할 때마다 콘솔이 찍히도록 해두었다.
위에서 말한 것과 같이 '도라에몽' 하나 입력하는 데 api가 쓸데없이 많이 호출되고 있다.

그럼 이제 사용자가 200ms동안 input에 값을 입력하지 않을 경우에만 api를 호출하도록 해보자.

사용자가 input 값을 변경할 때마다 setTimeout을 이용해 타이머를 설정하고,
200ms가 지나기 전에 input 값을 또다시 변경하면 이전 타이머를 취소하고 새로운 타이머를 설정한다.
input 입력이 없는 채로 200ms가 지나면 setTimeout으로 설정된 타이머가 자동으로 종료되면서 api를 호출하도록 할 것이다.

정리하자면 200ms보다 짧은 간격으로 input 값 입력 시에는 타이머가 계속 취소되고 새로 설정되다가, 200ms동안 입력이 없는 경우에만 타이머가 종료되면서 api를 호출하는 방식이다.

이를 위해서는 기존 input값을 담고있는searchValue에 디바운스를 적용한 debouncedSearchValue라는 state를 만들어줄 것이다.
200ms간 input 입력이 없어서 타이머가 종료되면 debouncedSearchValuesearchValue의 값을 넣는다.
그리고 useEffect를 이용해 debouncedSearchValue가 변경될 때마다 api를 호출하도록 한다.

export default function SearchBar() {
	const [searchValue, setSearchValue] = useState(''); 
	const [debouncedSearchValue, setDebouncedSearchValue] = useState(''); // searchValue에 디바운스가 적용된 값
	const [searchResults, setSearchResults] = useState([]);

	const DEBOUNCE_TIME = 200; // input에 200ms간 입력이 없으면 입력을 완료한 것으로 간주 

 	useEffect(() => {
    	const debounce = setTimeout(() => {
      		setDebouncedSearchValue(searchValue);
    	}, DEBOUNCE_TIME); // 새로운 타이머 설정

    	return () => clearTimeout(debounce); // 이전 타이머 취소하는 클린업 함수
	}, [searchValue]); // searchValue가 변경될 때마다 디바운스 처리

	useEffect(() => {
		fetchSearchResults();
	}, [debouncedSearchValue]); // debouncedSearchValue가 변경될 때마다 api 호출

	const fetchSearchResults = async () => {
		if (!debouncedSearchValue) { // searchValue에서 debouncedSearchValue로 변경
			setSearchResults([]);
			return;
		}

		const nickNameDatas = await getNickNames(debouncedSearchValue); // searchValue에서 debouncedSearchValue로 변경
		const nickNames = nickNameDatas
			.map((data: { nickname: string; id: number }) => data.nickname)
			.slice(0, 5);

		setSearchResults(nickNames);
	};

	const handleSearchButton = async () => {
      // 검색 버튼 눌렀을 때 일어날 동작
	};

	return (
		<Search
			onSubmit={handleSearchButton}
			inputState={searchValue}
			setInputState={setSearchValue}
			placeholder="닉네임을 입력하세요"
			results={searchResults}
            style={{width: '320px'}}
		/>
	);
}

코드는 위와 같다.

여기서 debouncedSearchValue() 함수 부분을 이해하려면 useEffect의 클린업 함수에 대해 알아야 한다.

useEffect에서 함수를 반환하면, 정리 (clean-up)가 필요할 때 해당 함수를 실행시킬 수 있다.
그렇다면 React는 언제 클린업을 실행할까?
React는 컴포넌트가 마운트 해제될 때 클린업을 실행한다.
컴포넌트가 DOM에서 제거될 때 클린업을 실행한다는 뜻이다.

또 useEffect의 콜백함수는 두 번째 인자가 변경될 때마다 실행되기 때문에, 다음 effect를 실행하기 전에 이전 렌더링에서 파생된 effect도 정리한다.

그럼 아래의 코드를 다시 살펴보자.

useEffect(() => {
    const debounce = setTimeout(() => {
      	setDebouncedSearchValue(searchValue);
    }, DEBOUNCE_TIME);

    return () => clearTimeout(debounce); 
}, [searchValue]); 

searchValue가 변경될 때마다 타이머를 설정하고, 타이머를 취소하는 clearTimeout 함수를 클린업 함수로 반환한다.
그리고 다시 searchValue가 변경되면 useEffect의 콜백함수를 실행하기 전, 클린업 함수인 clearTimeout을 실행하면서 이전 타이머를 취소한다.
만약 searchValue가 변경되지 않은 채로 200ms가 지난다면, 그 사이에 useEffect가 다시 실행되지 않기 때문에 클린업 함수도 실행되지 않는다.
그러므로 200ms가 지나 setTimeout이 자연스럽게 종료되고 debouncedSearchValue의 값이 변경되는 것이다.

흐름을 정리하면 다음과 같다.

input값이 변경될 때마다 searchValue이 input값으로 갱신됨
-> input값이 변경된 지 200ms가 지남
-> debouncedSearchValuesearchValue값으로 갱신됨
-> debouncedSearchValue 값으로 api 요청 보냄

이렇게 한 후 api 요청을 보낼 때마다 콘솔을 찍으면 아래와 같이 된다.

이전에 비해 api 호출 횟수가 눈에 띄게 줄어들었다.

전체 코드는 별 하나에 글 하나 github에서 볼 수 있다.




🍞 요약

  • 디바운스는 짧은 시간 간격으로 발생하는 이벤트를 그룹화해서 마지막에 한 번만 이벤트 핸들러를 호출함
  • 스로틀링은 짧은 시간 간격으로 발생하는 이벤트를 그룹화해서 호출 주기를 만들어 일정 시간 단위로 이벤트 핸들러를 호출함
  • 사용자가 검색창에 값을 입력할 때마다 api를 호출하면 성능상 이슈가 있음
  • 일정 시간 동안 검색창에 값이 입력되지 않으면 입력이 완료된 것으로 간주하고, 입력이 완료되었을 때만 api 호출
  • 일정 시간보다 짧은 간격으로 값이 입력되면 이전 타이머를 취소하고 새로 설정함


참고자료

13개의 댓글

comment-user-thumbnail
2024년 1월 5일

that was great
thank you for sharing
Slot Dana

답글 달기
comment-user-thumbnail
2024년 1월 10일

감사합니다

1개의 답글
comment-user-thumbnail
2024년 1월 13일

좋은 글 잘 봤습니다!

1개의 답글
comment-user-thumbnail
2024년 1월 14일

리액트 관련 공부를 하고 있지는 않지만 이 개념은 정말 유용한 거 같아요
좋은 포스트 감사합니다

1개의 답글
comment-user-thumbnail
2024년 1월 16일

한번쯤 고민하던 건데 좋은 정보네요!

1개의 답글
comment-user-thumbnail
2024년 1월 16일

글 잘봤습니다. 저는 앱 개발자지만, 썸네일에 내 얘긴가 싶은 모습이 보여 홀린듯 들어왔네요ㅋㅋ 스로틀링은 서버에서 처리되어야 하는줄로만 알고있었고, 디바운스는 들어본적도 없었는데, 덕분에 좋은내용 잘 얻어갑니다 :)

1개의 답글
comment-user-thumbnail
2024년 1월 24일

냠냠에몽

답글 달기
comment-user-thumbnail
2024년 1월 29일

It's eye-opening to learn about the environmental footprint of tire production and disposal. Tire Destruction Techniques Sustainable alternatives like recycled rubber offer a glimmer of hope for greener driving habits.

답글 달기