앱에 게시글 목록 화면을 만들었는데, 스크롤이 자꾸 끊겼다. 데이터가 몇백 개 쌓이니까 아래로 빠르게 내릴 때 빈 화면이 잠깐씩 보이는 현상(blank cell)이 심해졌다.
원인은 FlatList의 동작 방식이다. FlatList는 화면 밖으로 나간 셀을 언마운트했다가 다시 필요하면 새로 마운트한다. 스크롤이 빠르면 마운트가 렌더 속도를 못 따라가서 빈 칸이 생긴다. windowSize, maxToRenderPerBatch 같은 옵션을 만져봤는데 근본 해결은 아니었다.
FlashList로 교체
Shopify가 만든 @shopify/flash-list는 셀을 언마운트하지 않고 재활용(recycling) 한다. 화면 밖으로 나간 셀 컴포넌트를 버리지 않고, 새 데이터만 갈아끼워서 재사용하는 방식이다. 그래서 마운트 비용이 거의 안 든다.
좋은 점은 API가 FlatList와 거의 같다는 거다. import만 바꾸면 대부분 그대로 동작한다.
npx expo install @shopify/flash-list
import { FlashList } from '@shopify/flash-list';
function PostList({ posts }: { posts: Post[] }) {
return (
<FlashList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <PostCard post={item} />}
/>
);
}
FlatList를 FlashList로 바꾸고, import 경로만 고치면 끝이다. 실제로 교체하고 나니 그 빈 칸 현상이 거의 사라졌다.
estimatedItemSize를 꼭 넣어야 한다
처음에 콘솔에 경고가 떴다. FlashList는 재활용을 위해 아이템의 대략적인 높이를 미리 알아야 한다. 이걸 estimatedItemSize로 알려준다.
<FlashList
data={posts}
renderItem={({ item }) => <PostCard post={item} />}
estimatedItemSize={120}
/>
정확할 필요는 없고 평균값이면 된다. 카드 하나 높이를 대충 재서 넣으면 된다. 이 값이 있어야 스크롤바 위치나 초기 렌더 범위를 제대로 계산한다.
셀 재활용에서 조심할 점
재활용이 이득인 만큼 함정도 있다. 셀 컴포넌트가 재사용되기 때문에, useState로 셀 안에 로컬 상태를 두면 이전 아이템의 상태가 남아서 엉뚱하게 보일 수 있다.
예를 들어 "펼치기/접기" 상태를 셀 내부 useState에 두면, 스크롤 후 재활용된 셀에 그 상태가 그대로 붙는다. 그래서 이런 상태는 셀 밖(부모)에서 데이터로 관리하는 게 안전하다.
// 셀 내부에 상태를 두지 말고
const [expandedId, setExpandedId] = useState<string | null>(null);
<FlashList
data={posts}
extraData={expandedId}
renderItem={({ item }) => (
<PostCard
post={item}
expanded={item.id === expandedId}
onToggle={() => setExpandedId(item.id)}
/>
)}
estimatedItemSize={120}
/>
상태가 바뀔 때 리렌더를 반영하려면 extraData에 그 값을 넘겨줘야 한다는 것도 잊지 말자.
정리하면, 긴 리스트에서 FlatList가 버벅이면 FlashList로 바꾸는 걸 먼저 시도해볼 만하다. import 교체 + estimatedItemSize 추가면 대부분 끝나고, 셀 로컬 상태만 부모로 끌어올리면 재활용 함정도 피할 수 있다. 실제 체감 차이가 꽤 컸다.