
기업에서 운용중인 서빙 로봇들을 관리하는 프로젝트를 진행하던 중 로봇에서 발생하는 에러 로그들을 보여주는 페이지를 구축하다가 몇 가지 문제가 발생했다.
우선 첫벙째로 서버로부터 넘어오는 에러 로그 데이터의 갯수가 너무나 많다는 것,,

사진으로는 3,000개까지 밖에 안 보이지만 실제로 넘어오는 데이터의 갯수는 10,000개가 넘어간다.
또한 이 에러 로그들을 모두 아래와 같이 리스트 형식으로 모든 에러들을 한 번에 리스팅하고 있기 때문에 웹 페이지에 엄청난 부하를 일으키며 페이지 진입 속도 또한 2초가 넘게 걸리는 상황이 발생했다.

2초라는 시간이 언뜻 보면 짧은 시간일 수도 있겠지만, 실제 통계상 유저가 페이지 로딩 속도가 2초가 넘어가는 시점부터는 엄청난 이탈률을 보인다고 한다.
당연히 무조건적으로 성능 최적화를 진행해야 했고 페이지네이션과 무한스크롤 중 선택해야 했는데, 빠르게 에러들을 확인해야 하는 프로젝트 특성 상 페이지네이션 보다는 무한스크롤이 더욱 적합할 것 같아 무한스크롤을 통해 최적화를 진행하기로 결정했다.
하지만 문제는 하나 더 있었으니,,
바로 서버에서 순차적인 데이터 패칭을 위해 페이지네이션 작업을 할 리소스가 부족하다는 것.
일반적인 무한스크롤의 경우 일정 스크롤 이상 내려가게 되면 클라이언트가 서버에게 첫 번째 데이터 묶음 주세요~ 그 다음 내려가면 두 번째 데이터 묶음 주세요~ 이런식으로 요청해서 데이터를 쌓아 나아가는 형식이다.
하지만 나의 경우 서버쪽에서 페이지네이션을 작업할 상황이 되지 않았고 클라이언트 단에서 데이터 패칭 대신 데이터를 자르고 붙여 넣는 작업을 진행해야 했다.
초기 구현 방식은 useInfiniteScroll이라는 커스텀 훅을 만들어 addEventListener()의 scroll 이벤트를 활용했다.

위 코드는 useInfiniteScroll 커스텀 훅에 있는 일부분인데, 에러 로그들을 리스팅하는 div에 ref를 걸어주고 스크롤 이벤트를 등록하여 특정 지점을 관찰하며 엘리먼트가 위치에 도달했을 때 실행할 함수를 넣어주었다.

스크롤 이벤트를 통한 무한스크롤은 잘 동작했지만 위의 사진과 같이 단 시간에 수백번, 수천번 호출될 수 있고 동기적으로 실행되기 때문에 reflow 야기, 이벤트 연속 호출, 렌더링 성능 저하 등의 문제가 있다.

무수히 일어나는 이벤트 이슈를 보고도 정신 못차려서 난 그래도 스크롤 이벤트 사용할래! 한다면 방법이 없는 것은 아니다.
바로 디바운스나 스로틀을 사용하면 불필요한 이벤트 호출을 제어하여 성능을 향상시킬 수 있다.
하지만 스로틀은 마지막 이벤트를 감지하지 못 하는 경우가 생기고 디바운스의 경우 너무 긴 딜레이 설정하면 사용자 경험에 영향을 줄 수 있기 때문에 적합하지 않다고 판단했다.
그래서 얼마 안 건드렸지만 싹 갈아엎고 Intersection Observer API로 무한스크롤을 구현하기로 결정했다.
Web API중 하나인 Intersection Observer API는 크롬 51버전부터 사용할수 있으며, 2016년 구글 개발자 페이지를 통해 소개되었다.
MDN에서는 Intersection Observer의 필요성을 아래와 같은 예를 들어 설명하고 있다.
- 페이지 스크롤 시 이미지를 Lazy-loading(지연 로딩)할 때
- Infinite scrolling(무한 스크롤)을 통해 스크롤할 때 새로운 콘텐츠를 불러올 때
- 광고의 수익을 계산하기 위해 광고의 가시성을 참고할 때
- 사용자가 결과를 볼 것인지에 따라 애니메이션 동작 여부를 결정할 때
즉, Intersection observer는 기본적으로 브라우저 뷰포트(Viewport)와 설정한 요소(Element)의 교차점을 관찰하며, 요소가 뷰포트에 포함되는지 포함되지 않는지, 더 쉽게는 사용자 화면에 지금 보이는 요소인지 아닌지를 구별하는 기능을 제공한다.
나는 에러 로그 리스트들을 감싸고 있는 상위 div아래에 observer가 관찰할 요소를 넣어놓고 만약 요소가 관찰되었다면 다음 리스트를 추가적으로 불러오는 작업을 통해 무한스크롤을 구현하려 한다.
결론부터 말하자면 scroll 이벤트를 활용한 초기 구현 방식에서 일어나던 문제를 해결할 수 있으며 성능 측면에서 이점을 가지기 때문이다.
기존 구현 방식은 scroll event를 감지하여 구현했기 때문에 결국 자바스크립트의 메인 엔진에서 실행이 되어야하므로 많은 부하가 걸린다.
이때 최적화를 위해 throttle 혹은 debounce와 같은 처리를 추가로 진행할 수 있지만 throttle 이나 debounce를 사용하는 경우 또한 쓰레드 메모리를 차지하고 성능에도 좋지 않으며 앞서 말했듯 사용자 경험을 해칠 수 있다는 문제가 있다.
이러한 문제들을 해결해 줄 수 있는 것이 바로 intersection observer API다.
이름에서도 알 수 있듯이 Web API 이기 때문에 스크롤이 일어날 때 마다 자바스크립트의 코드를 곧잘 돌려야하는 이전과는 달리 Intersection Observer API는 타겟 요소와 상위 요소 또는 최상위 document 의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰한다.
즉, Intersection Observer API를 사용하면 자바스크립트 엔진과는 상관없이 브라우저단에서만 무한스크롤 로직을 검사하고, 조건을 통과하는 경우에는 에러 로그 리스트를 추가로 불러오면 된다.
good. 바로 적용해보자.
IntersectionObserver 생성자는 두 가지의 매개변수를 받는다.
그 후 관찰하고 싶은 DOM을 observe 함수의 매개변수로 등록하여 관찰을 시작한다.
const observer = new IntersectionObserver(callback, options);
observer.observe(targetDOM);
entries는 IntersectionObserverEntry 인스턴스의 배열이다.
IntersectionObserverEntry는 읽기 전용(Read only)의 다음 속성들을 포함하는데 하나씩 살펴보자.
- boundingClientRect: 관찰 대상의 사각형 정보를 반환. (DOMRectReadOnly)
- intersectionRect: 관찰 대상과 루트의 교차한 영역 정보를 반환.(DOMRectReadOnly)
- intersectionRatio: 관찰 대상의 교차한 영역을 0~1 사이의 값으로 반환(intersectionRect 영역에서boundingClientRect 영역까지 비율, Number)
- isIntersecting: 관찰 대상이 현재 루트 안에 포함되어 있는지의 여부를 확인. 현재 포함되고 있는 중이면 true, 포함되지 않고 있는 중이면 false를 반환.(Boolean)
- rootBounds: 지정한 루트 요소의 사각형 정보를 반환.(DOMRectReadOnly)
- target: 현재 관찰 대상 요소를 반환.(Element)
- time: 관찰 대상의 변경이 발생한 시간 정보를 반환.(DOMHighResTimeStamp)
targetDOM의 가시성을 검사하기 위해 뷰포트 대신 사용할 요소 객체(루트 요소)를 지정한다.
타켓의 조상 요소이어야 하며 지정하지 않거나 null일 경우 브라우저의 뷰포트가 기본 사용된다. 기본값은 null.
const observer = new IntersectionObserver(callback, {
root: document.getElementById("root")
});
바깥 여백을 활용해 root의 범위를 지정한다.
CSS의 margin과 동일하게 적용할 수 있으며, px, % 단위를 활용할 수 있다.
const observer = new IntersectionObserver(callback, {
rootMargin: "20px 0px 10px 5px"
});
옵저버가 실행되기 위해 타겟의 가시성이 얼마나 필요한지 백분율로 표시한다.
기본값은 Array 타입의 [0]이지만 Number 타입의 단일 값으로도 작성할 수 있다.
const observer = new IntersectionObserver(callback, {
threshold: 0.3 // or `threshold: [0.3]`
})
먼저 나는 무한스크롤을 적용할 페이지가 여러 곳 있기 때문에 이를 커스텀 훅으로 만들어 모듈화를 진행했다.
//useInfiniteScroll을 호출하는 페이지
const RecentError = () => {
const [recentErrors, setRecentErrors] = useRecoilState(recentErrorsState);
const observerRef = useRef<HTMLDivElement>(null);
const { data, isLoading } = useInfiniteScroll(recentErrors, observerRef);
return (
<StRecentError>
<StBody>
{data.map((cur, idx) => (
<Error key={idx} {...cur} />
))}
{isLoading && (<Spinner />)} // 데이터를 추가로 불러오는 동안 보여줄 Spinner
<div ref={observerRef} /> //이 div가 보이는 순간 데이터를 추가로 불러옴
</StBody>
</StRecentError>
);
};
export default RecentError;
//useInfiniteScroll
const useInfiniteScroll = (initialData: IErrorNotice[], ref: RefObject<HTMLDivElement>) => {
const [isLoading, setisLoading] = useState<boolean>(false); // isLoading을 통해 Spinner을 보여줘야 하기 때문에 생성
const [data, setData] = useState<IErrorNotice[]>([]); //return할 에러 리스트. 초깃값은 빈 배열
useEffect(() => {
setData(initialData.slice(0, 20)); // 처음에는 20개만 보여줘야 하기 때문에 초기 데이터 설정하는 부분
}, []);
const loadMoreData = () => {
setisLoading(true); //로딩 시작
setTimeout(() => {
const updatedData = initialData.slice(0, data.length + 20); //기존 데이터 기준으로 20개 추가
setData(updatedData); // 업데이트 된 데이터를 return할 data에 setState
setisLoading(false); // 로딩 끝
}, 500); // 다음 데이터가 로딩되고 있다는 것을 보여주기 위해 의도적으로 넣은 setTimeout 함수
};
useEffect(() => {
const container = ref.current;
if (!container) return;
const options = {
rootMargin: '0px',
threshold: 1,
};
const handleObserver = (entries: IntersectionObserverEntry[]) => {
const target = entries[0];
if (target.isIntersecting && data.length < initialData.length) {
loadMoreData(); // 데이터 쌓는 함수
} // 지점이 교차되고 있고 && 원본 배열의 길이를 넘지 않았을 때만 실행되도록 조건문 추가
};
const observer = new IntersectionObserver(handleObserver, options); //콜백함수와 옵션 넣어주기
if (container) {
observer.observe(container); // Intersection Observer를 div에 연결
}
return () => {
if (container) {
observer.unobserve(container);// 컴포넌트가 언마운트되면 Observer 해제
}
};
}, [data]);
return { data, isLoading }; //리턴할 데이터와 isLoading
};
export default useInfiniteScroll;
위의 코드를 조금 더 상세히 설명하면
useInfiniteScroll - useState
- isLoading과 data 상태를 정의.
- isLoading은 데이터를 추가로 로드하는 동안 로딩 상태를 나타내는 불리언 값.
- data는 현재까지 렌더링 된 에러 로그 데이터 배열.
useInfiniteScroll - loadMoreData
- 추가 데이터를 로드하는 함수.
- 서버쪽에서 페이지네이션을 작업할 상황이 되지 않았기에 클라이언트 단에서 데이터 배열을 자르고 붙이는 형태로 구현.
- 로딩 상태를 true로 설정하고, 500ms의 딜레이 후 추가 데이터를 가져와서 data 상태를 업데이트 후 로딩 상태를 다시 false로 설정.
useInfiniteScroll - useEffect (1)
- 초기 데이터 설정을 위한 useEffect
- 처음에 유저에게 보여줄 20개의 데이터가 필요하기 때문에 initialData.slice(0,20) 으로 초기 데이터 렌더링
useInfiniteScroll - useEffect (2)
- ref를 사용하여 스크롤을 감지하고 무한 스크롤을 처리.
- container 변수를 통해 ref.current 값을 가져오며 만약 이 값이 없다면 함수를 종료.
useInfiniteScroll - useEffect (2) - handleObserver
- IntersectionObserver의 콜백 함수
- 관찰 대상의 isIntersecting 속성을 확인하여 보여지는 영역에 진입했는지 검사하고, data의 길이가 초기 데이터의 전체 길이보다 작을 때 데이터를 추가로 로드.(target.isIntersecting && data.length < initialData.length)
useInfiniteScroll - useEffect (2) - options
- rootMargin을 '0px'로 설정하여 관찰 대상 요소가 뷰포트와 교차하는 순간을 감지.
- threshold는 1로 설정하여 마지막 요소가 전부 보여졌을 때 다음 데이터가 로드되도록 설정.

스크롤이 바닥에 닿았을 때 스피너가 돌아가며 스피너가 사라짐과 동시에 추가적으로 리스트들이 생성되는 것을 확인할 수 있다!
지금까지 intersection Observer API를 활용해 무한스크롤을 구현해보았다.
onScroll 이벤트를 활용한 초기 구현 방식은 작동은 잘 되었지만 성능상 문제를 일으킨다는 점이 도저히 그대로 안고 갈 수가 없었다.
또 나름대로 debounce를 적용해 이벤트 이슈를 최소로 줄였지만 이 또한 단점이 존재했고 사용자 경험 관점에서 바라보았을 때 전혀 좋은 방법이 아니었기 때문에 intersection Observer API를 선택했다.
intersection Observer API를 활용하는 것이 현재 무한 스크롤을 구현하는 방법 중 적합한 방법이라고 대중적으로 알려져 있기 때문에 해당 방법을 선택하고 결정하는 과정에 있어 큰 고민과 어려움은 없었지만,
본인이 개발자라면 현재 본인 프로젝트에 가장 알맞고 적합한 방법을 선택하고 가려내는 안목이 정말 중요하다고 생각한다.
아직은 많이 부족하지만 더욱 더 날카롭고 좋은 안목을 가진 개발자가 되고 싶다!