3.2FlashList
FlatList의 상위호환을 지향하는 RN 리스트 컴포넌트 라이브러리
TL;DR
추억의 쪽지 시험
FlashList란?
RN의 대표적 API 중 하나인 FlatList는 가상화를 통해 많은 데이터를 스크롤하면서 볼 수 있는 고성능 리스트이다.
하지만 세상에 워낙 다양한 성능의 기기들이 있고, 그 모든 기기들에 엄청나게 많은 데이터를 보여줘야 하는 앱들이 존재하다 보니 이보다 더 좋은 성능의 스크롤할 수 있는 리스트에 대한 수요가 있어왔다. 특히, native 언어로 개발했다가 이후 생산성 향상 등의 목적으로 RN으로 옮기던 회사들은 안 좋은 기기에서도 native급 성능을 가진 스크롤 리스트를 필요로 했는데 Shopify가 그 중 하나였다.
Shopify 개발팀 중 React Native Foundations team은 이러한 필요성에 따라 FlashList라고 불리는 라이브러리를 만들어 공개했는데, 글 작성일 현재(25.10.11) 기준 월 53만건이 넘는 npm 다운로드를 자랑할 정도로 애용받는 라이브러리가 되었다. 심지어 RN 공식문서에서도 FlatList가 너무 느리거나 스크롤 성능이 좋지 못하다면 FlashList나 또다른 라이브러리인 Legend List를 쓰라고 권유한다.
FlashList 성능의 비결
강력한 가상화에 더불어 셀 재사용(recycling) 전략으로 메모리 사용을 줄인 것이 핵심이다. 여기서 말하는 재사용이란, 뷰포트에서 아이템이 보이지 않게 될 때 해당 아이템을 보여주던 뷰 인스턴스를 없애는 대신 해당 뷰 인스턴스에 데이터를 갈아끼우는 방식이다.
사용법
If you know FlatList, you already know FlashList.
FlashList에서 자랑하는 것 중 하나가 FlatList API에서 그냥 컴포넌트 이름만 바꿔도 될 정도로 마이그레이션이 편하다는 점이다.
물론 이는 약간의 과장이 있는데, 실제로는 앞서 언급한 셀 재사용과 관련되어 체크해야할 몇몇 부분과, 추가적으로 보다 좋은 성능을 얻기 위한 작업이 필요하다.
체크 1. renderItem 트리에서 명시적 key 제거
React의 key는 인스턴스 식별자로, 만약 renderItem의 루트 요소에 key가 있다면 FlashList가 해당 요소를 재사용하면서 데이터를 갈아 끼울 시 React가 컴포넌트가 바뀐것으로 보고 해당 컴포넌트를 다시 마운트시켜 버그가 발생할 수 있다.
따라서 아이템 식별은 keyExtractor로만 해야하고, .map()을 꼭 써야할 경우 FlashList에서 제공하는 useMappingHelper라는 훅을 사용해야 한다.
tsx<FlashListdata={data}keyExtractor={(i) => i.id} // 아이템을 FlashList가 식별한다renderItem={({item}) => <Row item={item} />}/><FlashListdata={data}renderItem={(item)=> <View key={item.id}/>}/> // renderItem 트리에 명시적 key가 있다면 재사용하려다 버그가 날 수 있다
체크 2. renderItem 트리에 useState가 있다면, 아이템 변경 시 상태 초기화 필요
역시 셀 재사용과 관련된 것으로, 특정 셀 뷰가 스크롤로 넘어가서 더 이상 뷰포트에 남아있지 않으면 FlashList는 해당 셀 뷰를 다른 아이템에 재사용하는데, 만약 셀 내부 값이 useState로 관리된다면 이전 아이템의 흔적이 남는 버그가 생길 수 있다.
이런 경우를 위해, FlashList는 useRecyclingState라는 훅을 제공하는데, renderItem으로 렌더되는 반복적인 아이템들에서 useState가 쓰일 자리에 대신 쓰여 상태가 dependency array에 있는 의존성이 변경되면 자동으로 초기화할 수 있게 해준다.
tsxtype Post = { id: string; title: string}; const PostRow = ({ item }: {item: Post}) => { // expanded의 값은 의존성 배열이 변경되면 리셋된다. // 즉, 여기선 item.id가 바뀌면 자동으로 초기화된다(false) // 또, 콜백 함수도 받아 reset 시에 실행할 수도 있다(스크롤 위치 초기화, 내부 애니메이션 등) const [expanded, setExpanded] = useRecyclingState<boolean>( false, [item.id], () => { // reset 시에 실행할 콜백 } ); return ( <Pressable onPress={() => setExpanded((v) => !v)}> <Text>{item.title}</Text> {expanded && <Text numberOfLines={0}>본문 미리보기…</Text>} </Pressable> ); };
체크 3. 여러 타입이 섞이면 getItemType로 타입을 전달
불균질한 타입으로 데이터가 구성되어 있으면(heterogenous), 셀 재사용을 잘못할 수도 있다. 타입 전달은 배치/예측하는 성능을 위한 것이다. 이후 성능 개선 부분에서 좀 더 자세히 알아보자.
체크 4. 부모에서 전달하는 props를 메모화할 것
불필요한 리렌더를 줄이기 위해 renderItem이나 ListHeaderComponent, ListFooterComponent를 useMemo/useCallback으로 안정화해야 한다. 또, keyExtractor, getItemType, onEndReached등도 원래(v1)는 FlatList에서 메모화를 어느 정도 해주었으나, 이게 버그 같다는 지적을 여러번 받고 v2에선 개발자 측에서 메모화를 하게 했다고 하니 useCallback을 활용해야 한다.
위의 주의사항들 외에도 dev 모드에선 렌더 버퍼가 작아 성능이 좋지 않을 수 있다는 점, 수직 스크롤 리스트 안에 수평 스크롤 리스트를 구현하고 FlashList를 쓰려고 하는 경우 두 리스트 모두 FlahsList로 쓰라는 점 등의 체크 포인트들이 있다.
성능을 개선하기 위해 추가해야할 사항들은 아래와 같다.
성능 개선 1. estimatedItemSize
사실상 필수인 prop으로, 평균적인 아이템 높이를 적어줘야 FlashList가 스크롤 도중 레이아웃 재계산 비용 및 빈칸이 보이는 현상을 줄일 수 있다.
성능 개선 2. 크기가 다른 아이템 렌더 시 getItemType + overrideItemLayout
앞서 체크 3에서 한번 언급했던 내용으로, 셀 재사용을 위해 이질적 아이템들에 대해선 아이템 타입을 우선 getItemType로 구한 후, overrideItemLayout으로 레이아웃의 크기를 대략적으로 알려주면 스크롤 예측 정확도가 올라간다고 한다. 예를 들어, getItemType={(item) => item.type}로 타입을 태깅하고 overrideItemLayout={(layout, item, index, type) => {layout.size = type === 'ad' ? 280 : 84; }} 식이다.
tsx<FlashList data={items} keyExtractor={(i) => i.id} estimatedItemSize={96} // 평균 높이 getItemType={(i) => i.type} // 'post' | 'ad' 등 overrideItemLayout={(layout, item, index, type) => { layout.size = type === 'ad' ? 280 : 96; // 타입별 대략 높이 }} renderItem={({ item }) => ( item.type === 'ad' ? <AdCard item={item}/> : <PostCard item={item}/> )} />
이외에도 numColumns로 그리드형 리스트를, MasonryFlashList로 핀터레스트 같은 멋진 레이아웃도 구현이 가능하다.
FlashList는 FlatList의 상위 호환을 지향하여 만들어진 라이브러리로, FlatList가 현재 많이 안정되었지만 안드로이드 중저가 기기의 스크롤 끊김 현상이나 수천 건에 달하는 리스트에서 스크롤이 느려지는 현상 등이 있을 때 도입을 고려할만하다고 한다.