
React를 공부하면서,, 감성일기장을 만들면서 느낀 궁금증이 있었다.
데이터에 map 순회하면서 컴포넌트로 감싸서 반환하면 리스트를 화면에 보여주는게 div 특성상 쉽다는 것을 느꼈다.
반복되는 컴포넌트인데 최적화는..?
iOS에서처럼 UIKit의 reusableDequeue component같이 화면에 보이는 개수 + 스크롤 시 갑자기 발생될 리스트 컴포넌트 +-3개정도로 기존 할당된 컴포넌트들에 데이터만 바뀌어가면서 재할당하는 그런 개념은 React에서 제공은 안하나?
그렇게 react-window 라이브러리를 알게 되었다.
바로 실험을 해봤다.
export function makeDummyDiaries(count) {
const now = Date.now();
return Array.from({ length: count }, (_, i) => {
const id = i + 1;
return {
id,
createdDate: now - i * 60_000,
emotionId: (i % 5) + 1,
content: `테스트 일기 ${id} - ${"내용 ".repeat((i % 20) + 1)}`,
};
});
}
위 함수에서 1만개를 생성해서 데이터를 활용할 것이다. 이 함수를 호출할 때 시간이 걸리는데 물론 실제로는 서버에서 비동기로 데이터 리스트를 page에 따라서 몇십개씩 받아올테지만,,
1. key + map을 활용해 1만개 데이터 보여주기
export default function MapBaseline({ items, sortType }) {
const sorted = items.toSorted((p, n) => {
return ("sortType === oldest") ? ... : ...
});
return (
<div className="List_wrapper">
{sorted.map((item) => (
<DiaryItem key={item.id} {...item} />
))}
</div>
);
}
간단 + 편함

처음 화면 전환이 일어나고, makeDummyDiaries(10000) 함수 호출로 인해 처음 보이는 노란색 구간에서 사용된 scripting은 1만 개의 데이터를 만드는데 걸린 까지 포함해서 볼 수 있다.
1. 1만개의 더미 데이터 생성
2. React의 [render phase]
3. 1만개의 DiaryItems 컴포넌트 생서
4. React reconciliation (diff로 무엇이 바꼈능가?)
5. DOM 노드 10000개 실제 삽입 [commit phase]
이 과정에서는 DOM 트리 갱신, 스타일 개선, Layout(박스 위치 계산), Paint(그리기)를 수행해야 함
이후 스크롤할 때는 이미 DOM 구조를 만들었기 때문에 JS(Scripting) 부담은 크지 않은 편이다.
왜냐면 이미 DOM은 생성됬고, 스크롤은 단순히 viewport이동이고 대부분의 경우엔 Layout 다시 크게 발생 안함

그러나 주목해야할 부분은 아래 Frames구간이다. 뻘건색(e.g. 10.0ms)은 그렇게 좋지 않은 것이다.
브라우저가 60fps를 유지해야 한다면, 한 프레임 16ms 안에서 처리해야 할 작업들이 있는데 지연되다가 뭉텅이로 처리하면서 버벅임이 발생한것.
스크롤 시 왜 프레임 드랍이 생기나?

브라우저의 스크롤은 단순히 viewport를 이동시키는 것처럼 보이지만, 실제로는 아래 과정이 섞여서 실행된다.
- 스크롤 이벤트/입력 처리 (Scripting)
- 스타일 계산(Recalculate Style) / 레이아웃(Layout)
- sticky 요소, 폰트 렌더링, 이미지 크기 계산, flex 레이아웃 등
- 페인트(Paint) / 합성(Composite)
- 특히 box-shadow, border-radius, 투명도, 큰 이미지 등이 있으면 paint 비용이 커질 수 있음
DOM에 최소아이템만 10,000개가 있는거니까 스타일, 레이아웃 관점에서 매 프레임마다 부담해야하는 총량 자체가 크다.
GC 등등.. 작업을 미루다가 한번에 처리하니까 310ms가 나올 가능성도 있음.
2. React.memo + useMemo 활용한 경우
1만 데이터를 items로 받았다 가정하면,
const MemoDiaryItem = React.memo(DiaryItemwithHooks);
export default function DiaryItemwithHooks({ items, sortType }) {
const sorted = useMemo(() => {
const copy = [...items];
copy.sort((p, n) => { return ("sortType === oldest") ? ... : ... });
return copy;
}, [items, sortType]);
return (
<div className="List_wrapper">
{sorted.map((item) => (
<MemoDiaryItem key={item.id} {...item} />
))}
</div>
);
}
추가적이 최적화를 위해..
sorted 연산에 useMemo를 사용하면 items, sortType이 변경되지 않는 한 정렬 로직은 다시 수행되지 않는다.
그러나 상위에서 items가 매번 새로운 배열로 생성된다면, deps(items)는 매번 바뀌므로 useMemo는 효과없이 정렬은 다시 수행된다.
React.memo로 컴포넌트를 메모해보자.... (props가 이전과 동일하다면, DiaryItem 함수 호출을 스킵한다.)
부모 컴포넌트가 리렌더링 된다면?
어찌되었건 sorted.map(...)은 여전히 10,000번 실행된다.
하지만 React.memo(DiaryItemWithHooks) 를 사용했기에 props가 동일한 경우에 MemoDiaryItem 함수 호출 자체를 스킵한다.
즉, MemoDiaryItem의 실제 렌더 함수는 호출되지 않을 수 있다.
<MemoDiaryItem onClick={()=>handleClick(item.id)}/>
만약 위에 코드처럼 props의 이벤트 함수를 작성한다면, 이 함수의 로직은 여전히 동일하지만 부모가 리렌더링 발생 시 매번 새 함수가 생성되어 onClick에 전달되기 때문에 props는 매번 이전 props과 달라진다.
이를 방지하려면 useCallback을 쓰거나, 참조가 고정된 헨들러 구조 쓰도록 해야함.

여전히 DOM 트리에서는 DiaryItem 10,000개가 있다.
즉 memo는 CPU사용량은 줄여줄 수 있으나, 메모리, GC, layout비용은 그대로다.
3. react-window를 활용해보자 !
map을 활용해서 리스트 아이템 컴포넌트인 DiaryItem 컴포넌트에 key를 할당하면,
A B C D
↓
D C B A
key는 렌더 호출을 직접 줄여주는 도구가 아니라, DOM의 컴포넌트에 key가 있다면 불필요한 재 마운트 방지
-> diff 정확도(뭐가 달라졌는가?) 높여주는 식별자임
DOM 총량 감소가 아니다.
react-window는!!
화면에 보이는 컴포넌트 개수 + 위 아래로 갑작스럽게 스크롤시 임시로 보여질 컴포넌트 overscanCount 개수를 통해서만 리스트의 아이템들을 재활용하자는 개념이다.
visible range(화면에 보이는 아이템들) + overscan range(위로 스캔시 보여질 아이템들)
에 해당하는 index만 렌더한다.
예를들어 화면에 10개보임, overscanCount = 3이라면
실제 DOM에 존재하는 row 개수는
위로 스크롤시 보여질 3 + 현재 10 + 아래로 스크롤시 보여질 3) = 16개
- 특정 index 범위만 렌더링
- 범위를 벗어나면 언마운트,
- 새 index가 들어오면 새로 마운트
즉 DOM의 총량을 제한하는 전략임.
그러면?
- Layout 후보 감소
- Paint 후보 감소
- GC 부담 감소
react-window list 호출 로직
function DiaryRow({ index, style, items }) {
// 1 index에 따라서 특정 아이템 얻어오ㄱ기
const item = items[index];
if (!item) return <div style={style} />;
// 2. 원하는 리스트의 아이템 컴포넌트 반환
return (
<div style={{ ...style, boxSizing: "border-box", padding: "0 7px 0 0" }}>
<DiaryItem {...item} />
</div>
);
}
export default function Virtualized({ items }) {
const rowProps = useMemo(() => ({ items }), [items]);
return (
<List
rowCount={items.length}
rowHeight={120}
rowComponent={DiaryRow}
rowProps={rowProps}
overscanCount={3}
/>
);
}
버전 2.x.x이고 JS코드이다. v2 List API관점이다. 사용 예시는 react-window 리드미에도 있고 여기에도 있다.
- rowComponent: 반복 렌더 컴포넌트
- rowCount: 총 개수
- rowProps: row에서 참조할 데이터
- rowHeight: 고정 높이
- overscanCount: 버퍼(상 하 스크롤시 화면에 보여질 임시 아이템 개수)
- react-window README.md에 Optional props보면 더 많은 매개변수들 있다. [ 공식문서 README.md 링크 ]
여기서 가장~~~~ 중요한 점은 List가 화면에 보여질 전체 높이, rowHeight가 명확해야 한다는 점이다. style={{width:... , height:...}}이런식으로 List의 option에 넣어줘야함.
즉 viewport 크기(height/width)가 있어야 지금 화면에 몇개가 보여지는지 visibleCount 계산이 가능하다는 점이다.
만약 react-window를 사용했는데 List컴포넌트와 상관 없는 계층의 헤더 컴포넌트나 상위 컴포넌트들이 보이지 않는다면 List의 width, height 계산을 못해먹고 있는거라고 짐작도 해봐야 한다.
react-virtualized-auto-sizer 라이브러리의 AutoSizer를 활용하면 부모 컨테이너의 실제 픽셀 widht, height를 구해다가 그 값을 전달해 줄 수 있음.
부모 style이 height: 100vh, flex:1
그렇지만 이런 느낌으로 부모 컨테이너 확정 높이가 필요함.
그런데 또 뽀인트는 상위 컴포넌트의 높이, width가 정해져 있다면 굳이 List의 style에 width, height지정 안해도됨.
DiaryRow 함수에서처럼 컴포넌트 return할 때 <div style={style} ...> 이런 형식으로 style을 넣어줘야 한다.

그럼 이렇게 1만개의 데이터가 있어도 DOM에 추가되는 컴포넌트 개수는 화면에 보이는 개수 + overscanCount개수만큼 적어진다.
그런데 사용하면서 좀 아쉬웠던 점도 있었다..

저 파랑색 element가 List인데 overflow-y : auto이다.
react-window(또는 v2 List)는 대략 이런 느낌으로 동작됨:
- 이 컨테이너에서 스크롤이 발생한다!
- 스크롤 이벤트에서 e.currentTarget.scrollTop을 읽는다
- scrollTop과 rowHeight로 현재 보여야 할 index 범위를 계산한다
- startIndex = Math.floor(scrollTop / rowHeight) - 그 범위(+overscan)만 렌더한다
그러니까 List는 “자기 자신이 스크롤을 소유” 해야 렌더 범위 계산이 가능하다.
overflow-y를 visible로 바꾼다거나 그러면 스크롤이 List바깥에서 일어날 수 있기에 렌더범위연산이 깨져서 스크롤에 에러가남...
그래서 List 컴포넌트 컨테이너 밖으로 나가는 자식 요소의 무엇이든.. (내 경우는 box-shadow) 클리핑함(자식 컴포넌트가 부모 컴포넌트 영역 벗어나서 보여져야 하지만, 부모영역 벗어나면 화면에 안보여 줄꺼야 이런 느낌)
여기서는 간단하게 실험을 위해 작성한 로직인데,
map을 활용해서 DiaryItem(리스트에 보여질 아이템)들을 리스트로 보여줄 때는 아이템의 맨 우측 버튼인 수정하기 버튼의 쉐도우가 그대로 보여졌다.
List를 활용할 때는 overflow-y:auto때매 자식의 요소 일부가 짤렸기에 임시 방편으로 아이템에 padding right를 좀 줬다.
그런데 프로젝트에서 사용한다면 리스트의 경우에는 #root에 공통적으로 주는 padding에서 제외하도록 하고 List는 내부에서 자체로 제어하는 그런 방법을 써야할듯하다. 내 경우 #root에서 공통적으로 left, right padding 16px으로 줬기 때문
각 row를 position: absolute로 꽂아넣는것도 있긴해가지구 position: sticky header가 기대처럼 동작 안할 수도 있다.
List자체가 scroll container인 점 row가 absolute positioning인점.. 부모 overflow: auto인졈,,
그럼에도 좋은것 같다.
perfLab코드
https://github.com/SHcommit/OneBite-React/tree/master/emotion-diary/src/pages/PerfLab
OneBite-React/emotion-diary/src/pages/PerfLab at master · SHcommit/OneBite-React
Contribute to SHcommit/OneBite-React development by creating an account on GitHub.
github.com
References:
https://ko.legacy.reactjs.org/docs/reconciliation.html
재조정 (Reconciliation) – React
A JavaScript library for building user interfaces
ko.legacy.reactjs.org
https://github.com/bvaughn/react-window
GitHub - bvaughn/react-window: React components for efficiently rendering large lists and tabular data
React components for efficiently rendering large lists and tabular data - bvaughn/react-window
github.com