위와 같이 생긴 테이블에, 페이지네이션 없이 3천 개가 넘는 행을 출력해야 하는 상황이 있었습니다.
당연히 3천개나 되는 행을 깡으로 렌더링하면 시간이 오래 걸리는 이슈가 생깁니다.
3000여개의 행을 아무런 요령 없이 출력하니까 4.6초나 걸립니다. 처참하군요...
현재 렌더링되는 3천개의 행 중, 보이는 행은 10개도 채 되지 않습니다. 즉 현재 방식으로는, 나머지 2990개의 행은 아직 보이지도 않으면서, 자신을 보여줄 준비를 미리 하고 있는 셈입니다. 하지만 사용자가 밑에까지 스크롤하지 않아서 끝내 보이지 않는다면? 렌더링을 허투루 하게 된 것이죠.
이 설레발 치는 행(row)님들에게 헛된 희망을 버리게 할 필요가 있었습니다. 가시권에 들어올 때만 렌더링이 되게 할 수는 없을지 고민하다가, 무한 스크롤에 활용했던 IntersectionObserver
가 생각이 났습니다.
Intsersection Observer API는 특정 요소가 부모 요소와 교차하는지 관찰하는 API입니다.
이를 활용하면 특정 행이 부모 요소와 교차하고 있는지 확인할 수 있을 것입니다.
const SomeRow = (props: Props) => {
const {
name,
...
} = props;
...
const [show, setShow] = useState(false);
const divRef = useRef<HTMLDivElement>(null);
// 렌더 최적화 - 화면에서 벗어나면 내용 렌더 안하도록
useEffect(() => {
const div = divRef.current;
if (!div) return;
// Intersection Observer 생성
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
setShow(entry.isIntersecting);
});
},
{ threshold: 0 }
);
observer.observe(divRef.current);
return () => {
// 클린업
observer.unobserve(div);
};
}, []);
return (
<div className={cx('row')} ref={divRef}>
{show && (
<>
...각종 렌더링
</>
)}
</div>
)
}
useEffect
를 통해 IntersectionObserver
를 생성하고, 알맞은 옵션을 부여합니다.entry.isIntersecting
속성은 타깃 요소와 루트 요소가 교차된 상태인지를 나타내는 boolean
입니다. 즉 isIntersecting
이 true
이면, 현재 화면에서 보이는 상태입니다. 이 속성을 show
상태로 지정해주고, 요소의 내용 렌더링 여부를 이 show
상태로 분기처리 합니다.unobserve
를 통해 클린업 해주는 것을 잊지 말자!최적화 이전에는 4.6초라는 긴 시간이 걸렸지만, IntersectionObserver
를 활용해 화면에 보이는 행만 정상적으로 렌더링 하고, 가려져 있는 행은 껍데기만 남기고 렌더링을 하지 않음으로써 0.1초로 단축시킬 수 있었습니다. (무려 97.8% 개선! 와!!)
이 방식의 문제점은, 화면에 보이지 않는 '껍데기' 요소를 여전히 렌더링 하고 있다는 점입니다.
껍데기 요소마저 렌더링하지 않는다면 각 행의 위치가 틀어지므로, 이를 일단 유지하기 위해서 위 방식을 취했습니다.
더 나아가, 클릭 후 화면에 반영되기까지 0.1초가 걸린다면 예민한 사용자에겐 충분히 체감될 시간이기도 합니다.
각 행의 높이가 일정하다면, 아래와 같은 방식으로도 개선이 가능할 것 같습니다.
position
을 absolute
로 지정하기top
을 할당해주기IntersectionObserver
를 활용하는 것이 아닌, 리스트를 감싸는 요소의 높이 + 스크롤 위치를 활용하여 보이지 않는 행의 렌더링 생략하기오버엔지니어링이 아닐까 싶었지만, 일단 시도해 보았습니다.
먼저, 리스트 컴포넌트에, 보여줄 행만 렌더링하기 위한 코드를 추가해줍니다.
const ROW_HEIGHT = 56;
const ListView = (props: Props) => {
...
const listWrapRef = useRef<HTMLDivElement>(null);
const [sliceStart, setSliceStart] = useState(0);
const [sliceEnd, setSliceEnd] = useState(0);
const setSliceRange = (start: number, end: number) => {
setSliceStart(start);
setSliceEnd(end);
};
useEffect(() => {
const body = listWrapRef.current;
if (!body) return;
const height = body.clientHeight;
setSliceRange(Math.floor(body.scrollTop / ROW_HEIGHT), Math.ceil((body.scrollTop + height) / ROW_HEIGHT));
const scrollHandler = function (this: HTMLDivElement) {
setSliceRange(Math.floor(this.scrollTop / ROW_HEIGHT), Math.ceil((this.scrollTop + height) / ROW_HEIGHT));
};
body.addEventListener('scroll', scrollHandler);
return () => {
body.removeEventListener('scroll', scrollHandler);
};
}, [list]);
return (
<div className={cx('recipient-list-wrap', className)}>
...
{!!list.length && (
// 리스트를 담고있는 스크롤 가능한 요소
<div className={cx('recipient-list-body', 'modal-scroll')} ref={listWrapRef}>
{/* 리스트를 감싸는 요소. (행의 높이 * 리스트 항목 개수)로 높이를 고정한다*/}
<div className={cx('list-wrap')} style={{ height: list.length * ROW_HEIGHT }}>
{/* 현재 보이는 행만 slice 하여 렌더링 한다. */}
{list.slice(sliceStart, sliceEnd + 1).map((recipient, index) => {
return (
// index + sliceStart는 리스트 전체에서의 인덱스를 나타낸다.
<Row
index={index + sliceStart}
...
/>
);
})}
</div>
</div>
)}
...
);
};
slice
해줍니다.index
를 전달해줍니다.const Row = (props: Props) => {
const {
...
index,
} = props;
return (
// index * 행의 높이로, 행의 절대위치를 잡아준다.
<div className={cx('row')} style={{ top: index * ROW_HEIGHT }}>
...
)
}
추가로, 행 스타일에 position: absolute
를 추가하고, top
에 index * ROW_HEIGHT
를 부여해줌으로써 각 Row의 절대적인 위치를 지정해줍니다.
이제 딱 필요한 만큼만 렌더링 되는것을 확인할 수 있습니다.
중간 개선 결과인 88ms에서 3.3ms로,훨씬 더 줄일 수 있었습니다. (야호!)
최종적으로, 4.5초(4500ms)에서 3.3ms까지 획기적으로 줄임으로써, 사용자가 의식하지 못할 수준의 속도로 렌더링을 마칠 수 있게 됐습니다.
추후 이런 상황이 또다시 발생할 것을 고려하여, 위와 같은 Lazy Loading 기능이 담긴 컴포넌트를 별도로 만들어 다른 곳에서도 사용할 수 있게 모듈화 했습니다.
interface RowRenderProps<T> {
style: CSSProperties;
data: T;
}
interface Props<T> extends Omit<ComponentPropsWithoutRef<'div'>, 'children'> {
rowHeight: number;
gap?: number;
children: FC<RowRenderProps<T>>;
dataList: T[];
rowKey: keyof T;
}
const LazyList = <T,>(props: Props<T>) => {
const { rowHeight, gap = 0, children, rowKey, dataList, ...htmlAttr } = props;
const listWrapRef = useRef<HTMLDivElement>(null);
const [sliceStart, setSliceStart] = useState(0);
const [sliceEnd, setSliceEnd] = useState(0);
const Row = useMemo(() => memo(children, (prevProps, nextProps) => deepEquals(prevProps, nextProps)), [children]);
const setSliceRange = (start: number, end: number) => {
setSliceStart(start);
setSliceEnd(end);
};
useEffect(() => {
const body = listWrapRef.current;
if (!body) return;
const height = body.clientHeight;
setSliceRange(
Math.floor(body.scrollTop / (rowHeight + gap)),
Math.ceil((body.scrollTop + height) / (rowHeight + gap))
);
const scrollHandler = function (this: HTMLDivElement) {
setSliceRange(
Math.floor(this.scrollTop / (rowHeight + gap)),
Math.ceil((this.scrollTop + height) / (rowHeight + gap))
);
};
body.addEventListener('scroll', scrollHandler);
return () => {
body.removeEventListener('scroll', scrollHandler);
};
}, [dataList, gap, rowHeight]);
return (
<div {...htmlAttr} ref={listWrapRef}>
<div style={{ height: dataList.length * rowHeight + (dataList.length - 1) * gap, position: 'relative' }}>
{dataList.slice(sliceStart, sliceEnd + 1).map((data, index) => (
<Row
key={String(data[rowKey])}
style={{ top: (index + sliceStart) * (rowHeight + gap), position: 'absolute' }}
data={data}
/>
))}
</div>
</div>
);
};
props
로 행의 높이, 간격, 행의 렌더링 함수, 데이터 배열, map
에서 key
로 사용할 키값을 전달받습니다.top
스타일 속성과 data
를 렌더 함수에 전달합니다.아래의 방식으로 컴포넌트를 사용하면 기존과 같이 동작합니다.
<LazyList
rowKey={'id'}
dataList={list}
rowHeight={ROW_HEIGHT}
>
{({ data, style }) => (
<div style={style}>
...data를 활용한 각종 렌더링
</div>
)}
</LazyList>
data
파라미터를 활용하여 원하는 형태의 행을 반환하는 함수를 children
으로 전달합니다.LazyList
에서 계산한 style
을 렌더단의 최상단 요소에 적용해줍니다.