무한 스크롤을 구현할 때 처음에는 scroll event를 사용하여 구현하면 되겠지?
라는 생각으로 접근했었습니다.
그러나 Scroll이 발생할 때마다 스크롤 이벤트에 대한 콜백 함수가 여러 번 실행되는 것을 확인할 수 있습니다. 현재는 console.log를 사용하고 있지만, 만약 API 요청과 같이 많은 데이터를 다루는 상황이라면 엄청난 성능 저하가 발생할 수 있습니다.
디바운스는 연속적으로 발생하는 이벤트 중에서 마지막 이벤트가 발생한 후 일정 시간 동안 추가적인 이벤트가 없을 때에만 해당 이벤트를 처리하는 기법
입니다.
예를들어 사용자 입력에 따라 검색 요청을 보내는 경우에 디바운스를 적용할 수 있습니다. 사용자가 입력을 시작하면 타이머를 초기화하고, 일정 시간(예: 300ms)이 경과한 후에 검색 요청을 보내는 방식으로 구현할 수 있습니다. 이를 통해 사용자의 연속 입력에 따라 검색 요청이 제어되고 성능이 개선됩니다.
만약 디바운스를 사용하지 않았다면 디바운스라는 단어를 검색하기 위해 ㄷ, 디, 딥, 디바, ... 등 11번의 단어를 API 요청해야 했을 것이며, 이는 엄청난 성능 문제를 발생시킬 수 있습니다.
주로 자동완성, 버튼 중복 클릭 방지 처리 등에 유용하게 사용됩니다.
디바운스 구현 코드. (아직 typescript에 대해 많이 부족하여 무분별한 any를 사용한점 반성합니다.)
import styled from 'styled-components';
import MainPage from '../components/pages/MainPage';
const StyleContainer = styled.div`
height: 100vh;
`;
const debounce = (callback: (...args: any) => void, delay: number) => {
let timerId: number;
return (...args: any) => {
clearTimeout(timerId);
timerId = setTimeout(() => callback(...args), delay);
};
};
function InfinityScroll() {
const onChange = debounce((e) => {
console.log(e.target.value);
}, 300);
return (
<MainPage>
<StyleContainer>
<input onChange={onChange} />
</StyleContainer>
</MainPage>
);
}
export default InfinityScroll;
쓰로틀은 처음 이벤트가 발생하고, 일정한 주기로 반복적으로 발생하는 이벤트
의 실행 빈도를 제어하는 기법입니다.
디바운스가 마지막 이벤트 호출 이후 일정 시간이 지난 후에 함수를 실행하는 것과 달리, 쓰로틀은 일정한 간격으로 이벤트를 실행합니다.
주로 Scroll관련, 무한 스크롤에 사용 됩니다.
이번엔 lodash에서 제공하는 throttle을 통해 구현해봤습니다.
import styled from 'styled-components';
import MainPage from '../components/pages/MainPage';
import { throttle } from 'lodash';
const StyleContainer = styled.div`
height: 100vh;
`;
function InfinityScroll() {
const onChange = throttle((e) => {
console.log(e.target.value);
}, 2000);
return (
<MainPage>
<StyleContainer>
<input onChange={onChange} />
</StyleContainer>
</MainPage>
);
}
export default InfinityScroll;
무한 스크롤 구현을 쓰로틀기법이 있는데도 불구하고 많은 분들이 Intersection Observer API를 사용 하는 걸 찾아볼 수 있습니다.
왜 그럴까? 찾아보던 중 성능을 비교하는 글을 찾아볼 수 있었습니다.
Aggelos Arvanitakis ← 글 참조
아래와 같은 성능 차이가 있음을 알 수 있었습니다.
또한 chat GPT에게 물어봤습니다.
성능 차이뿐만 아니라 자동 감지, 유연한 관찰 옵션, 효율적인 리소스 사용 등 많은 이점들이 있는 것을 알 수 있었습니다.
import { useState } from 'react';
interface IImgList {
id: string;
urls: {
thumb: string;
};
alt_description: string;
}
const API_KEY = '나의 API Key';
const [keyword, setKeyword] = useState('');
const [list, setList] = useState<IImgList[]>([]);
const fetchRequest = async (keyword: string) => {
const UNSPLASH_URL = `https://api.unsplash.com/search/photos?page=1&query=${keyword}&client_id=${API_KEY}&per_page=10`;
const response = await fetch(UNSPLASH_URL);
const json = await response.json();
const result = json.results;
setList(result);
};
{list.map((item, i) => (
<div key={`${item.id}-${i}`}>
<img src={item.urls.thumb} alt={item.alt_description} />
</div>
))}
IntersectionObserver를 사용하여 구현 해보겠습니다. IntersectionObserver MDN 참조
사진을 뿌려주고, 화면 가장 아래에 로딩박스를 만들어 useRef를 통해 target을 지정해줍니다.
{loading && <StyleLoading ref={pageEnd}>로딩중...</StyleLoading>}
useEffect(() => {
if (loading) {
const observer = new IntersectionObserver(
(entries) => {
// 로딩박스가 나타나면 page값을 1 증가 시켜줍니다.
if (entries[0].isIntersecting) nextPage();
},
// 0~1까지 있으며 1은 100%가 다 화면에 나타날때를 의미합니다.
{ threshold: 1 }
);
// 타겟으로 지정해준 로딩박스를 관찰해줍니다.
if (pageEnd.current) observer.observe(pageEnd.current);
}
}, [loading]);
page를 증가시켜 주는 함수를 만들어줍니다.
const nextPage = () => {
setPage((prev) => prev + 1);
};
page가 변경 될 때마다 page를 증가시켜 API 요청을 보내 추가적인 사진을 받아옵니다.
const fetchNextRequest = async (keyword: string, page: number) => {
const UNSPLASH_URL = `https://api.unsplash.com/search/photos?page=${page}&query=${keyword}&client_id=${API_KEY}&per_page=10`;
const response = await fetch(UNSPLASH_URL);
const json = await response.json();
const result = json.results;
setList((prev) => [...prev, ...result]);
// 새로 받아오는 result가 없다면 loading state를 true로 만들어줍니다.
result.length === 0 ? setLoading(true) : setLoading(false);
};
useEffect(() => {
if (page !== 1) fetchNextRequest(keyword, page);
}, [page]);
테스트에도 용이하고, 유지보수를 높이기 위해 fetch 관련 함수를 분리해줍니다.
API 요청하는 코드를 따로 utils폴더 아래에 api.ts 파일을 만들어 분리를 해보겠습니다.
// api.ts
const API_KEY = '나의 API Key';
const fetchData = async (apiUrl: string) => {
try {
const response = await fetch(apiUrl);
const json = await response.json();
const result = json.results;
return result;
} catch (err) {
console.log(err);
}
};
export const fetchImgList = async (keyword: string) => {
const UNSPLASH_URL = `https://api.unsplash.com/search/photos?page=1&query=${keyword}&client_id=${API_KEY}&per_page=10`;
const result = await fetchData(UNSPLASH_URL);
return result;
};
export const fetchNextImgList = async (keyword: string, page: number) => {
const UNSPLASH_URL = `https://api.unsplash.com/search/photos?page=${page}&query=${keyword}&client_id=${API_KEY}&per_page=10`;
const result = await fetchData(UNSPLASH_URL);
return result;
};
// InfinityScroll.tsx
const updateImgList = async (keyword: string) => {
const result = await fetchImgList(keyword);
setList(result);
setLoading(true);
};
const updateNextImg = async (keyword: string, page: number) => {
const result = await fetchNextImgList(keyword, page);
setList((prev) => [...prev, ...result]);
result.length === 0 ? setLoading(false) : setLoading(true);
};
useEffect(() => {
if (keyword !== '') updateImgList(keyword);
}, [keyword]);
useEffect(() => {
if (page !== 1) updateNextImg(keyword, page);
}, [page]);
IntersectionObserver의 첫번째 인자인 콜백함수를 따로 handleObsever 함수로 분리시켜줍니다.
기존코드
const nextPage = () => {
setPage((prev) => prev + 1);
};
useEffect(() => {
if (loading) {
const observer = new IntersectionObserver(
(entries) => {
// 로딩박스가 나타나면 page값을 1 증가 시켜줍니다.
if (entries[0].isIntersecting) nextPage();
},
// 0~1까지 있으며 1은 100%가 다 화면에 나타날때를 의미합니다.
{ threshold: 1 }
);
// 타겟으로 지정해준 로딩박스를 관찰해줍니다.
if (pageEnd.current) observer.observe(pageEnd.current);
}
}, [loading]);
const handleObserver = (entries: any) => {
const target = entries[0];
if (target.isIntersecting) {
setPage((prevPage) => prevPage + 1);
}
};
useEffect(() => {
if (!loading) return;
const observer = new IntersectionObserver(handleObserver, { threshold: 1 });
if (pageEnd.current) observer.observe(pageEnd.current);
}, [loading]);