sungyup's.

react_native / Core Components / 2.1 FlatList

2.1FlatList

대용량 스크롤 리스트를 위한 FlatList의 가상화 원리, 자주 쓰이는 패턴들 및 주요 Props 정리

TL;DR

추억의 쪽지 시험

이 포스팅은 리액트 네이티브 공식 문서를 공부하는 과정에 제 나름대로 작성한 필기입니다. 따라서 보다 구체적이고 친절한 리액트 네이티브 관련 포스트는 링크된 공식 문서 원본을 참고하시길 권합니다.

FlatList란?

React Native에서 기본적으로 제공하는, 고성능으로 구현된 스크롤을 할 수 있는 리스트이다.

많은 아이템들을 받아와 좁은 모바일 화면에 띄워야할 때 필연적으로 스크롤을 하게 된다. 이 때, 화면에 아직 보이지 않는 아이템까지 모두 한번에 렌더하면 성능에 손해를 볼 것이다.

FlatList의 내부 동작 원리

FlatList는 수천 개 아이템을 가상화(virtualization)해서 필요한 것만 렌더링한다. 가상화란, 필요한 부분만 실제 자원 위에 올려두고 나머지는 추상적으로 취급한다는 의미다.

즉, FlatList는 보이는 영역과 주변 일부(버퍼)만 메모리에 두고 렌더링하고, 보이지 않는 부분은 '존재하는 것처럼 취급'만 하고 실제로 렌더하진 않는다. 유저가 이후 스크롤하면, 필요할 때가 되어 렌더링하고 다시 안 보이면 제거한다.

이 보이는 영역과 주변 일부를 Render Window라고 한다. 즉, FlatList는 렌더 윈도우에 있는 아이템만 실제 뷰 트리(View Hierarchy)에 올리고 이 밖의 아이템은 언마운트되거나 재활용된다.

여기서 재활용이란, 네이티브의 셀 재사용 풀(Reuse pool)처럼 하나의 뷰 인스턴스를 다른 데이터로 교체하는 방식이 아니라, 보이는 구간만 마운트/언마운트하는 방식이다.

이 때문에 FlatList 내부 아이템들의 상태(state)는 보존되지 않을 수도 있다. 스크롤로 Render Window 밖으로 나가면 사라지기 때문으로, 따라서 FlatList를 쓸 때 UI의 상태는 상위 state나 전역 상태(Redux 등)로 관리해야하고, 아이템에 props로 내려줘야 제대로 관리할 수 있다. 이 부분은 이후 extraData라는 prop을 알아볼때 함께 살펴보자.

이 특성은 FlatListVirtualizedList라는 가상화 로직이 담긴 컴포넌트를 사용해 구현한 컴포넌트이기 때문에 존재하는 것으로, 마찬가지로 VirtualizedList를 사용해 구현한 SectionList도 이 특성을 공유한다. 참고로 VirtualizedList는 단독으로는 잘 쓰이지 않고 하위 컴포넌트 FlatListSectionList를 구현하기 위한 컴포넌트다.

FlatList의 쓰임새

FlatList는 데이터 개수가 많아서 긴 스크롤을 해야할 때 유용하다. 특히, 각 아이템이 비슷한 레이아웃/높이를 가지고 있고 스크롤 성능이 중요하다면 FlatList가 최적이다.(물론 이후에 알아볼 FlashList도 있지만 우선 넘어가자)

다만, 아이템 수가 적고 레이아웃이 단순하다면 굳이 FlatList를 쓰기보단 ScrollView + map으로 구현하는 것으로도 충분하다. 단순히 몇개의 children만 구현하면 되는데, FlatList로 만들면 이후에 나올 여러가지 prop을 모두 챙기기에는 구현에 오버헤드가 걸리기 때문이다.

FlatList 사용 패턴 및 주요 속성들

공식 문서에는 FlatList에서 쓸 수 있는 모든 속성들과 메소드들을 적어두었는데, 실제 쓰이는 패턴들을 보면서 등장하는 속성들을 알아보자.

1. 기본 패턴

id와 title 속성을 가진 item들로 구성된 data를 받아 일렬로 쭉 배치해보자. RN에선 아래와 같은 형태로 기본적인 FlatList를 구현한다.

tsx
type Item = { id: string; title: string }; const ItemRow = React.memo(({ item }: { item: Item }) => { return ( <Pressable className="px-4 py-3"> <Text>{item.title}</Text> </Pressable> ); }); export default function MyList({ data }: { data: Item[] }) { const keyExtractor = React.useCallback((it: Item) => it.id, []); const renderItem = React.useCallback( ({ item }: { item: Item }) => <ItemRow item={item} />, [] ); return ( <FlatList data={data} keyExtractor={keyExtractor} renderItem={renderItem} ItemSeparatorComponent={() => <View style={{ height: 8 }} />} ListEmptyComponent={<Text className="p-4">데이터가 없습니다.</Text>} initialNumToRender={10} maxToRenderPerBatch={10} windowSize={5} removeClippedSubviews /> ); }

여기선 FlatList의 기본적인 속성들에 대해 알아보자.

data

FlatList에서 렌더할 ArrayLike<T>형태의 데이터를 받는다.

타입스크립트에서 ArrayLike<T>는 이렇게 정의된다:

typescript
interface ArrayLike<T> { readonly length: number; readonly [n: number]: T; }

즉, length 속성이 있으며 숫자 인덱스로 원소에 접근할 수 있으면 된다. 사실상 코딩 시엔 배열(T[])만 넘기게 된다.

keyExtractor

typescript
(item: ItemT, index: number) => string;

React에선 리스트를 렌더링할 때 각 요소를 고유하게 식별하기 위해 key 속성을 필요로 한다. 이는 리스트의 요소가 업데이트 되었을 때 DOM을 다시 만들기보다 가상 DOM에서 변경된 부분만 찾아내서 업데이트하는 식으로 최적화를 하기 때문인데, 정확히 어떤 항목이 업데이트 되었는지 파악할때 key가 기준이 된다.

keyExtractor해당 아이템과 아이템의 인덱스를 받아 그 아이템을 RN이 식별할 수 있게 고유한 키를 추출하는 함수다. 위의 예시에선 item.id를 추출하는 함수를 선언하고 해당 함수를 썼는데, 만약 index를 쓰면 재정렬한다거나 데이터를 삽입하면 예상치 못한 결과를 초래할 수 있다.

따라서 순서에 변동이 있을 수 있는 리스트의 keyExtractor에선 index를 쓰지 않는다.

renderItem

typescript
renderItem({ item: ItemT, index: number, separators: { highlight: () => void; unhighlight: () => void; updateProps: (select: 'leading' | 'trailing', newProps: any) => void; } }): JSX.Element;

data속성에서 받은 아이템들을 리스트로 렌더링하는 함수다. 배열의 순서에 해당하는 index를 제공하기에 순서에 따라 다르게 렌더링하거나 하는데 활용할 수도 있다.

separators는 각 리스트 아이템 사이의 구분선 제어 객체다. highlight()unhighlight()는 특정 시점(ex.눌렀을 때나 포커스 받았을 떄)에 separator 스타일을 바꿀 수 있다. 예를 들어, 아래는 공식 문서의 예시다:

tsx
<FlatList data={[{title: 'Title Text', key: 'item1'}]} renderItem={({item, index, separators}) => ( <TouchableHighlight key={item.key} onPress={() => this._onPress(item)} onShowUnderlay={separators.highlight} onHideUnderlay={separators.unhighlight}> <View style={{backgroundColor: 'white'}}> <Text>{item.title}</Text> </View> </TouchableHighlight> )} />

이 예시에선 TouchableHighlight를 이용해 터치하면 구분선이 highlight되고, 떼면 원래대로 돌아온다.

updateProps()는 구분선 컴포넌트에 새로운 prop을 주는 것으로, "leading"은 위쪽, "trailing"은 아래쪽이다. 예를 들어 아이템 아래쪽 구분선의 색을 바꾸고 싶다면 'trailing'을 첫번째 인자로, 색 관련된 prop을 두번째 인자로 넣으면 된다.

ItemSeparatorComponent

리스트의 각 아이템 사이에 표시될 컴포넌트를 지정할 수 있다. 예를 들어, 각 아이템 사이에 얇은 선 또는 간격을 넣고 싶다면 renderItem에 추가하는 것이 아닌 ItemSeparatorComponent를 활용해야 맨 아래나 맨 위가 아닌 각 아이템 사이사이에만 선을 추가할 수 있다.

앞서 살펴본 renderItemseparators와의 차이는, separators는 사용자 액션 기반, 실시간 상태 변화에 따른 동적 스타일을 반영하기 위해 쓰인다면 ItemSeparatorComponent는 데이터를 기반으로 렌더링 시점에서의 정적/동적 스타일을 반영하기 위해 쓰인다. 예를 들어, 전자는 터치했을 때 구분선 강조/해제라면 후자는 처음에 렌더링했을 때 아이템 조건에 따라 구분선을 다르게 보이게 할 수 있다.

ListEmptyComponent

리스트가 비어 있을 때 대신 보여줄 컴포넌트를 지정한다. data 배열이 []일 때 renderItem이 호출되지 않고 ListEmptyComponent에 지정한 UI가 렌더된다.

보통 "데이터 없음"이나 "불러오는 중", "에러 발생" 등의 상태를 사용자에게 알려주는 컴포넌트를 쓴다.

initialNumToRender

처음에 몇 개 아이템을 렌더할지 결정한다. 기본값은 10으로, 스크롤되기 전 첫 화면에 최소한 몇 개를 그려야 안정적으로 보일지 정할 수 있다.

만약 처음에 여러개를 미리 보여주는게 사용자 경험이 더 좋다면 늘리는게 낫고, UI가 커서 더 적게 보여주는것도 충분하다면 수를 줄여 성능을 아낄 수 있다.

maxToRenderPerBatch

한번의 배치(Batch)에서 추가로 렌더할 수 있는 아이템 수다. FlatList에서 스크롤할 때, 새로운 아이템을 배치 단위로 렌더하는데 이 수를 정하는 것으로, 기본값은 10이다.

배치를 너무 적게 배정하면 스크롤 도중 빈 공간이 (잠깐이지만) 보일 수 있고, 반대로 너무 많이 그리면 성능 저하(프레임 드랍) 위험이 있다.

windowSize

Render Window의 크기를 정한다. 보이는 화면(뷰포트)의 높이를 기준으로 한 배수인데, 기본값이 21이다. 즉 현재 화면 높이 + 위/아래 버퍼 합쳐 총 21화면 단위를 유지하는거니 꽤나 넉넉하게 렌더하는 셈이다.

이보다 값을 늘리면 스크롤 앞뒤로 많은 아이템을 미리 렌더해서 스크롤 시 빈 공간의 위험은 줄지만 메모리 사용이 늘고, 값이 작으면 메모리를 절약하지만 스크롤 깜빡임이 발생할 수 있다.

removeClippedSubviews

렌더 윈도우 안에는 있지만 화면 밖으로 스크롤되어 완전히 보이지 않게된 아이템을 뷰 트리에서 제거할지 여부다. 기본값은 false로, true로 바꾸면 렌더 윈도우 안에 있더라도 화면에서 안 보이면 뷰 트리에서 제거한다. 다시 스크롤해서 돌아오면 뷰를 새로 mount하므로 리렌더 비용이 발생한다.

이 설정은 Render Window 밖 아이템엔 영향을 주지 않는다. FlatList는 Render Window 밖이라면 unmount하는게 기본 동작이다. 이 설정은 Render Window 안이지만 화면 밖일 때에 대한 설정이다.

removeClippedSubviews는 iOS에선 효과가 제한적일 수 있고, 애니메이션이 있는 복잡한 셀에선 의도치 않은 클리핑이 보이는 현상이 있다고 한다. 따라서 케이스별로 켜고 끄면서 테스트를 해보고 사용하는 것이 좋다.

2. 당겨서 새로 고침(pull-to-refresh)

tsx
const [refreshing, setRefreshing] = useState(false); const onRefresh = useCallback(async () => { setRefreshing(true); await refetch(); // 서버에서 최신 데이터 setRefreshing(false); }, []); <FlatList data={data} renderItem={renderItem} refreshing={refreshing} onRefresh={onRefresh} />

refreshing과 onRefresh

이 두 prop은 으로, 아래로 당겨서 새로고침(pull to refresh) 기능을 제공한다.

우선 refreshing은 "지금 새로 고침 중인지"를 FlatList에 알려주는 boolean의 상태 플래그다. true면 상단에 로딩 스피너를 표시하고, false면 스피너를 제거한다. 이 값은 주로 useState를 통한 상태로 관리한다.

onRefresh는 사용자가 리스트를 위에서 아래로 끌어내렸을때 호출되는 콜백 함수로, 위에서 보듯:

  1. setRefreshing(true)로 스피너를 띄우고
  2. await으로 데이터 갱신(네트워크 요청)을 수행한 뒤
  3. 완료되면 setRefreshing(false)로 스피너를 숨긴다.

3. 무한 스크롤 패턴

tsx
const [loadingMore, setLoadingMore] = useState(false); const onEndReached = useCallback(async () => { if (loadingMore || !hasNextPage) return; // 중복 방지 setLoadingMore(true); await fetchNextPage(); setLoadingMore(false); }, [loadingMore, hasNextPage]); <FlatList data={items} renderItem={renderItem} onEndReached={onEndReached} onEndReachedThreshold={0.5} // 50% 지점에서 미리 로드 ListFooterComponent={loadingMore ? <Spinner /> : null} />

onEndReached

렌더된 아이템의 끝, 정확히는 끝에서 일정거리가 남았을에 도달했을때 실행되는 콜백으로, 무한 스크롤(Infinite Scroll) 구현에 필수다. 이 때 거리기준은 onEndReachedThreshold로 조절한다.

onReached에 오는 함수는 보통 다음 페이지 데이터를 불러오는 API다. 여기서 주의해야할 점은 중복 호출 문제를 방지해야한다는 것인데, 빠르게 스크롤하거나, 데이터 추가 후 다시 끝에 닿으면서 호출이 여러번 이루어질 수 있다.

이를 방지하기 위해 로딩 중일 땐 onEndReached를 무시하는 기법이 주로 쓰인다. 위의 코드에선 loadingMore가 진입 플래그를 세워서 동시에 두 번 이상 fetchNextPage()가 실행되지 않는다. 또, if(!hasNextPage)로 다음 페이지가 없으면 아예 API를 호출하지 않는다.

다만 이것도 완벽하진 않다. setLoadingMore(true)는 렌더 사이클에 바로 반영되는건 아니어서, 만약 아주 빠르게 연속 호출되면 여전히 두번째 호출은 업데이트되지 않은 상태를 볼 수 있다. 이럴땐 아예 ref를 이용해 동기적으로 즉시 플래그 값을 반영하게 코드를 작성하는 것도 방법이다.

4. 채팅/타임라인(아래로 신규 메시지 추가) 패턴

tsx
<FlatList data={messages} renderItem={renderItem} inverted maintainVisibleContentPosition={{ minIndexForVisible: 1 }} />

inverted와 maintainVisibleContentPosition

inverted아래에서 위로 쌓이게 방향을 바꾼다.

maintainVisibleContentPosition은 새 항목이 추가되었을 때 점프하지 않고 현재 보이는 영역을 유지할 수 있게 해준다.

5. extraData로 갱신 강제하기

FlatList는 React의 PureComponent처럼 동작하는데, 즉 data 배열이 참조하는 곳이 같으면 리스트가 바뀌지 않았다고 판단해 리렌더링하지 않는다는 의미다. 자바스크립트에서 배열이나 객체는 원시값이 아닌 참조값이기 때문에 리스트 안의 요소가 업데이트되더라도 FlatList는 여전히 해당 리스트는 같은 참조를 바라보고 있으므로 업데이트되지 않았다고 판단해 리렌더링하지 않을 수 있다.

예를 들어, 아래의 경우 FlatList는 요소들을 터치하더라도 data가 변했다고 생각하지 않아 UI를 업데이트하지 않는다. 이는 data가 부모 컴포넌트에서 내려와서 참조가 안 바뀌기 때문이다.

tsx
export default function MyList({data, selectedId, setSelectedId}) { const renderItem = ({ item }: { item: { id: string; title: string } }) => ( <TouchableOpacity onPress={() => setSelectedId(item.id)}> <Text style={{ color: selectedId === item.id ? 'red' : 'black' }}> {item.title} </Text> </TouchableOpacity> ); return ( <FlatList data={data} renderItem={renderItem} keyExtractor={(item) => item.id} /> ); }

이런 경우, '이 값이 바뀌면 리렌더해라'고 특정 상태를 따로 지시할 수 있다. 이 때 들어가는, 이 값이 바뀌면 리렌더하라고 지시하는 상태들의 목록이 extraData다.

tsx
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={(item) => item.id}
extraData={selectedId} // 상태 변화를 FlatList에 알림
/>

Redux Store 같은 전역 상태 관리 라이브러리를 사용할때도 비슷한 문제가 발생한다. 서버 데이터를 전역 스토어에 저장하고, 리스트를 스토어에서 읽어온다면 그렇게 읽어온 배열은 참조가 안정적으로 유지되고, 선택 상태만 변하기 때문에 extraData가 필요하다.

tsx
const items = useSelector((state: RootState) => state.items.list);
const selectedId = useSelector((state: RootState) => state.items.selectedId);
<FlatList
data={items}
renderItem={({ item }) => (
<Text style={{ color: item.id === selectedId ? 'red' : 'black' }}>
{item.title}
</Text>
)}
keyExtractor={(item) => item.id}
extraData={selectedId}
/>