2.1FlatList
대용량 스크롤 리스트를 위한 FlatList의 가상화 원리, 자주 쓰이는 패턴들 및 주요 Props 정리
TL;DR
추억의 쪽지 시험
FlatList란?
React Native에서 기본적으로 제공하는, 고성능으로 구현된 스크롤을 할 수 있는 리스트이다.
많은 아이템들을 받아와 좁은 모바일 화면에 띄워야할 때 필연적으로 스크롤을 하게 된다. 이 때, 화면에 아직 보이지 않는 아이템까지 모두 한번에 렌더하면 성능에 손해를 볼 것이다.
FlatList의 내부 동작 원리
FlatList는 수천 개 아이템을 가상화(virtualization)해서 필요한 것만 렌더링한다. 가상화란, 필요한 부분만 실제 자원 위에 올려두고 나머지는 추상적으로 취급한다는 의미다.
즉, FlatList는 보이는 영역과 주변 일부(버퍼)만 메모리에 두고 렌더링하고, 보이지 않는 부분은 '존재하는 것처럼 취급'만 하고 실제로 렌더하진 않는다. 유저가 이후 스크롤하면, 필요할 때가 되어 렌더링하고 다시 안 보이면 제거한다.
이 보이는 영역과 주변 일부를 Render Window라고 한다. 즉, FlatList는 렌더 윈도우에 있는 아이템만 실제 뷰 트리(View Hierarchy)에 올리고 이 밖의 아이템은 언마운트되거나 재활용된다.
이 때문에 FlatList 내부 아이템들의 상태(state)는 보존되지 않을 수도 있다. 스크롤로 Render Window 밖으로 나가면 사라지기 때문으로, 따라서 FlatList를 쓸 때 UI의 상태는 상위 state나 전역 상태(Redux 등)로 관리해야하고, 아이템에 props로 내려줘야 제대로 관리할 수 있다. 이 부분은 이후 extraData
라는 prop을 알아볼때 함께 살펴보자.
이 특성은 FlatList
가 VirtualizedList
라는 가상화 로직이 담긴 컴포넌트를 사용해 구현한 컴포넌트이기 때문에 존재하는 것으로, 마찬가지로 VirtualizedList
를 사용해 구현한 SectionList
도 이 특성을 공유한다. 참고로 VirtualizedList
는 단독으로는 잘 쓰이지 않고 하위 컴포넌트 FlatList
와 SectionList
를 구현하기 위한 컴포넌트다.
FlatList의 쓰임새
FlatList는 데이터 개수가 많아서 긴 스크롤을 해야할 때 유용하다. 특히, 각 아이템이 비슷한 레이아웃/높이를 가지고 있고 스크롤 성능이 중요하다면 FlatList가 최적이다.(물론 이후에 알아볼 FlashList도 있지만 우선 넘어가자)
다만, 아이템 수가 적고 레이아웃이 단순하다면 굳이 FlatList를 쓰기보단 ScrollView + map
으로 구현하는 것으로도 충분하다. 단순히 몇개의 children만 구현하면 되는데, FlatList로 만들면 이후에 나올 여러가지 prop을 모두 챙기기에는 구현에 오버헤드가 걸리기 때문이다.
FlatList 사용 패턴 및 주요 속성들
공식 문서에는 FlatList
에서 쓸 수 있는 모든 속성들과 메소드들을 적어두었는데, 실제 쓰이는 패턴들을 보면서 등장하는 속성들을 알아보자.
1. 기본 패턴
id와 title 속성을 가진 item
들로 구성된 data
를 받아 일렬로 쭉 배치해보자. RN에선 아래와 같은 형태로 기본적인 FlatList
를 구현한다.
tsxtype 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>
는 이렇게 정의된다:
typescriptinterface 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
typescriptrenderItem({ 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
를 활용해야 맨 아래나 맨 위가 아닌 각 아이템 사이사이에만 선을 추가할 수 있다.
앞서 살펴본 renderItem
의 separators
와의 차이는, 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)
tsxconst [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
는 사용자가 리스트를 위에서 아래로 끌어내렸을때 호출되는 콜백 함수로, 위에서 보듯:
setRefreshing(true)
로 스피너를 띄우고await
으로 데이터 갱신(네트워크 요청)을 수행한 뒤- 완료되면
setRefreshing(false)
로 스피너를 숨긴다.
3. 무한 스크롤 패턴
tsxconst [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
가 부모 컴포넌트에서 내려와서 참조가 안 바뀌기 때문이다.
tsxexport 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
<FlatListdata={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);<FlatListdata={items}renderItem={({ item }) => (<Text style={{ color: item.id === selectedId ? 'red' : 'black' }}>{item.title}</Text>)}keyExtractor={(item) => item.id}extraData={selectedId}/>