부스트 캠프에서 가계부 서비스를 개발하며 겪었던 난관 중 하나를 해결하는 과정에 대해 공유하고자 합니다. 프로젝트를 진행하며, 한 달간의 거래내역 페이지에서 거래내역 data의 양이 많을 때 랜더링 속도가 매우 느린 것을 관찰하였고 이를 개선하고자 무한스크롤을 구현하려고 도전하였습니다. Intersection Observer와 관련된 글들을 찾아보며 Typescript로 작성된 글이 없어서 작성하였습니다.
문제상황
기술스택
처음에 시도했던 방식은 Scroll Event를 등록하여 화면 상의 scroll Top의 비율을 통해 일정 비율을 넘었을 때 랜더링하는 방식으로 구현하려 하였습니다. (지금 생각해보면 scroll 의 위치가 가장 밑으로 내려왔을 때 event를 발생시키는 방식이 더 나았을 것 같네요..)
이때 발생가능한 문제는 해당하는 기준이 device 마다 차이가 있으면 동작의 일관성이 유지되지 않는다는 점이였습니다.
추가적으로, 부스트캠프에서 저희 프로젝트 팀이 관심있게 가졌던 주제가 최적화였는데 scroll event 방식은 한 번의 스크롤에 scroll event가 여러번 발생하므로 throttle
또는 debounce
를 적용하여 이러한 event가 무한히 발생하지 않도록 관리해줘야 한다는 점이었는 데, 이점에서 scroll event 자체가 매력적으로 느껴지지 않았습니다.
scroll event의 대안을 찾다가 Intersection Observer
라는 것을 알게 되었습니다.
IntersectionObserver(교차 관찰자 API)
는 타겟 엘레멘트와 타겟의 부모 혹은 상위 엘레멘트의 뷰포트가 교차되는 부분을 비동기적으로 관찰하는 API입니다.
ViewPort
는 사용자에게 보여지는 화면이라 생각하면 쉽습니다. 사용자의 화면과 element가 얼마나 교차되는지 비율이 계산되고, 그 비율을 threshold
를 통해 콜백함수의 trigger 기준으로 정할 수 있습니다.
Intersection Observer 에 대한 설명은 MDN 과 다른 글들을 참고하시면 쉽게 이해하실 수 있습니다!
import React from 'react';
import { useSelector } from 'react-redux';
import TransactionListItem from '@/components/transaction/ListItem';
import { RootState } from '@modules/index';
import { TransactionModel } from '@/commons/types/transaction';
import EmptyStateComponent from '@/components/transaction/EmptyState';
import * as S from './styles';
const TransactionListContainer = (): JSX.Element => {
const { transaction } = useSelector((state: RootState) => state);
return (
<>
{transaction.transactionDetailsByDate.length !== 0 ? (
transaction.transactionDetailsByDate.map(([date, transactionDetails]) => (
<S.DateContainer key={`transaction_box_${date}`}>
<S.DateLabel>{date}일</S.DateLabel>
{transactionDetails.map((transactionDetail) => (
<S.TransactionListItemWrapper>
<TransactionListItem
key={`transaction_${transactionDetail.tid}`}
transaction={transactionDetail}
/>
</S.TransactionListItemWrapper>
))}
</S.DateContainer>
))
) : (
<EmptyStateComponent />
)}
</>
);
};
export default TransactionListContainer;
const [renderedTransaction, setRenderedTransaction] = useState([] as [number, TransactionModel[]][]);
maxLength
를 store에서 가져온 거래내역 list에서 구하고 useRef
를 이용하여 length
를 정의하여 현재 랜더링된 상태(얼마나 랜더링이 되었는지)를 계산 수 있도록 설정const length = useRef(1);
const maxLength = transaction.aggregationByDate.length / 5;
useEffect(() => {
if (!transaction.loading) {
length.current = 1;
if (transaction.transactionDetailsByDate.length < 5) {
setRenderedTransaction(transaction.transactionDetailsByDate);
} else {
setRenderedTransaction(transaction.transactionDetailsByDate.slice(0, 5));
}
}
}, [transaction]);
DateContainer
에 대해 이루어진다는 것을 발견할 수 있는데 제 생각에는 계속해서 컴포넌트가 랜더링 되면서 기존의 ref가 덮어씌워지고 마지막에 랜더링이 되는 element에 ref에 대한 참조가 이루어지는 것 같습니다. const target = useRef<HTMLDivElement>(null);
...
return (
<>
{transaction.transactionDetailsByDate.length !== 0 ? (
renderedTransaction.map(([date, transactionDetails]) => (
<S.DateContainer key={`t_box_${date}${ren}`} ref={target}>
<S.DateLabel>{date}일</S.DateLabel>
{transactionDetails.map((transactionDetail) => (
<S.TransactionListItemWrapper key={`t_Wrap${transactionDetail.tid}`}>
<TransactionListItem
key={`t_${transactionDetail.tid}`}
transaction={transactionDetail}
/>
</S.TransactionListItemWrapper>
))}
</S.DateContainer>
))
) : (
<EmptyStateComponent />
)}
</>
);
Intersection Observer 정의
useEffect(() => {
let observer: IntersectionObserver;
if (target.current) {
observer = new IntersectionObserver(onIntersect, { threshold: 0.5 });
observer.observe(target.current as Element);
}
return () => observer && observer.disconnect();
}, [transaction, renderedTransaction]);
교차시 발생할 동작 정의
entry.isIntersecting === true
) length
)가 maxLength
보다 크지 않다면 observer를 unobserve 하고 랜더링할 상태에 배열 5개를 추가하였습니다. const changeExtraTransaction = () => {
const newrenderedTransaction = renderedTransaction.concat(
transaction.transactionDetailsByDate.slice(5 * length.current, 5 * length.current + 5),
);
length.current += 1;
setRenderedTransaction(newrenderedTransaction);
};
const onIntersect: IntersectionObserverCallback = (entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting && length.current < maxLength) {
observer.unobserve(entry.target);
changeExtraTransaction();
}
});
};
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import TransactionListItem from '@/components/transaction/ListItem';
import { RootState } from '@modules/index';
import { TransactionModel } from '@/commons/types/transaction';
import EmptyStateComponent from '@/components/transaction/EmptyState';
import * as S from './styles';
const TransactionListContainer = (): JSX.Element => {
const { transaction } = useSelector((state: RootState) => state);
const length = useRef(1);
const target = useRef<HTMLDivElement>(null);
const [renderedTransaction, setRenderedTransaction] = useState(
[] as [number, TransactionModel[]][],
);
const maxLength = transaction.aggregationByDate.length / 5;
useEffect(() => {
if (!transaction.loading) {
length.current = 1;
if (transaction.transactionDetailsByDate.length < 5) {
setRenderedTransaction(transaction.transactionDetailsByDate);
} else {
setRenderedTransaction(transaction.transactionDetailsByDate.slice(0, 5));
}
}
}, [transaction]);
const changeExtraTransaction = () => {
const newrenderedTransaction = renderedTransaction.concat(
transaction.transactionDetailsByDate.slice(5 * length.current, 5 * length.current + 5),
);
length.current += 1;
setRenderedTransaction(newrenderedTransaction);
};
const onIntersect: IntersectionObserverCallback = (entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting && length.current < maxLength) {
observer.unobserve(entry.target);
changeExtraTransaction();
}
});
};
useEffect(() => {
let observer: IntersectionObserver;
if (target.current) {
observer = new IntersectionObserver(onIntersect, { threshold: 0.5 });
observer.observe(target.current as Element);
}
return () => observer && observer.disconnect();
}, [transaction, renderedTransaction]);
return (
<>
{transaction.transactionDetailsByDate.length !== 0 ? (
renderedTransaction.map(([date, transactionDetails]) => (
<S.DateContainer key={`t_box_${date}${ren}`} ref={target}>
<S.DateLabel>{date}일</S.DateLabel>
{transactionDetails.map((transactionDetail) => (
<S.TransactionListItemWrapper key={`t_Wrap${transactionDetail.tid}`}>
<TransactionListItem
key={`t_${transactionDetail.tid}`}
transaction={transactionDetail}
/>
</S.TransactionListItemWrapper>
))}
</S.DateContainer>
))
) : (
<EmptyStateComponent />
)}
</>
);
};
export default TransactionListContainer;
참고자료
좀 치시네요 남다님?