React Native + Expo 앱에서 채팅 메시지 리스트를 만들 때, 처음에는 별생각 없이 이렇게 썼습니다.
<FlatList
data={messages}
renderItem={...}
keyExtractor={...}
/>
처음 10개 메시지일 때는 멀쩡합니다. 그런데 메시지가 누적되고, 성능이 안좋은 기기로 테스트했을 때
이런 성능 이슈들이 보였습니다.
원인을 추적해보니 FlatList의 가상화가 기본값에 의존하고 있었고, 그 기본값은 일반적인 리스트(게시글 같은거)에는 잘 맞지만 채팅처럼 셀이 가변 높이이면서 누적되는 화면에는 적합하지 않다고 추측했습니다.
가상화는 자료구조의 크기와 RN view tree 크기를 분리하는 것입니다.
리스트에 1,000개의 아이템이 있고, 한 번에 화면에 보이는 것은 15개라고 가정해봅시다.
이때 Virtualization을 이용하면 나머지 985개는 자료구조 안에만 존재하고, 실제 RN 뷰 트리에는 마운트되지 않습니다. 사용자가 스크롤하면 그때그때 화면에 들어올 셀을 마운트하고, 멀어진 셀은 언마운트합니다.
이 페이지에서 Virtualization에 대해 쉽게 이해할 수 있습니다!
FlatList는 사실 얇은 wrapper입니다. 진짜 구현은 RN 레포에 VirtualizedList.js에 있고, FlatList는 거기에 일부 기본값과 편의 API를 더한 것뿐입니다.
VirtualizedList는 리스트를 다음 5개 영역으로 나눕니다.

스크롤이 발생하면 VirtualizedList 내부의 computeWindowedRenderLimits() 함수가 "지금 어디부터 어디까지 마운트되어야 하는가"를 계산합니다.
이 계산을 아래 소개할 파라미터로 좌우할 수 있습니다.
컴포넌트가 처음 마운트될 때 한 번에 그리는 셀의 개수입니다
기본값: 10

RN 앱의 첫 paint(Time To Interactive, TTI)는 UX와 직결됩니다. 이 값이 클수록 첫 화면에 풍부한 콘텐츠가 보이지만 그만큼 JS thread가 첫 paint 전에 처리해야 할 일이 늘어납니다.
initialNumToRender는 첫 paint의 무게를 결정한다고 할 수 있습니다.
viewport 몇 개 분량만큼 마운트를 유지할지 결정합니다. 단위가 개수가 아니라 viewport 배수라는 점이 핵심입니다.
기본값: 21

windowSize = 21은 Pre-window 10화면, Viewport 1화면, Post-window 10화면을 합쳐서 21화면 분량을 마운트 상태로 유지한다는 의미입니다.
화면 높이가 800px이고 평균 셀 높이가 80px이면 viewport ≈ 10개 셀이고, 마운트되는 셀 수는 10 × 21 = 210개입니다.
windowSize는 메모리와 스크롤 부드러움의 균형을 잡아야 한다고 볼 수 있습니다.
두 개를 같이 보겠습니다. 둘 다 "신규 셀을 그리되, JS thread를 점유해서 60fps를 깨지 않게 하라"는 문제를 목적으로 하기 때문입니다.
maxToRenderPerBatch는 한 번의 batch에 그리는 신규 셀 개수 상한이고, (기본값: 10)
updateCellsBatchingPeriod는 batch 사이의 간격입니다. (기본값: 50ms)

JS thread는 single thread이기 때문에 한 frame(16.6ms) 안에서 너무 많은 일을 하면 frame drop이 발생합니다. 만약 셀 100개를 한 번에 마운트하려고 하면 그 렌더 작업이 끝날 때까지 사용자 입력이 걍 막힙니다.
batch를 작게 나누고 사이사이에 간격을 주면, 그 간격 동안 다른 작업(스크롤, 애니메이션 등등)이 처리될 여유가 생깁니다.
maxToRenderPerBatch를 키우면 → 셀이 빨리 채워지지만 frame drop 위험updateCellsBatchingPeriod를 줄이면 → 셀이 빨리 채워지지만 다른 작업이 짓눌림이 둘은 JS thread를 보호하는 장치라고 할 수 있습니다.
removeClippedSubviews화면 밖으로 완전히 클리핑된 native view를 일시적으로 detach합니다. JS 레벨이 아니라 네이티브 단(iOS UIView, Android View) 에서 일어나는 최적화입니다.
기본값: iOS는 false, Android는 true (플랫폼별로 다름. 정확한 값은 RN 버전마다 약간씩 다른데, 신뢰성 이슈가 있을 때 비활성화하는 게 일반 가이드)
- Android의 텍스트 컴포넌트에서 가끔 빈 셀이 보이는 이슈가 있음
- 셀 내용이 적거나 투명도가 섞이면 detach/attach 시점에 깜빡임 발생 가능
채팅의 경우 셀당 아바타·말풍선·시간 등 시각적으로 차 있는 컨텐츠라 detach/attach 이슈가 잘 보이지 않습니다. 그래도 도입 전에 실기 QA가 필요한 prop입니다.
일반 리스트는 처음에 위에서 시작하고 빠른 Fling 스크롤이 흔합니다. 그리고 셀 높이가 균일하고 한번 진입하고 잘 안 돌아오는 경향이 있습니다.
이에 반헤 채팅은 마지막 메시지부터 시작해서 천천히 위로 스크롤하는 경우가 많습니다. 셀 높이도 텍스트 1줄에서 이미지 n장까지 균일하지 않고, 같은 채팅방을 자주 재진입하는 경우도 많습니다.
<FlatList
ref={flatListRef}
data={combinedData}
keyExtractor={keyExtractor}
renderItem={renderItem}
ListHeaderComponent={renderHeader}
className="flex-1 px-4"
showsVerticalScrollIndicator={false}
onContentSizeChange={onScrollToEnd}
contentContainerStyle={{ paddingBottom: 10 }}
initialNumToRender={15}
maxToRenderPerBatch={10}
windowSize={11}
updateCellsBatchingPeriod={50}
removeClippedSubviews
/>
기본값 10이 아니라 15로 한 이유는 bottom-anchored 자동 스크롤과 직결됩니다.
onContentSizeChange={onScrollToEnd}
이 prop은 컨텐츠 크기가 변할 때마다 flatListRef.current.scrollToEnd()를 호출합니다. 그런데 scrollToEnd가 정확히 동작하려면 마지막 메시지를 포함한 충분한 셀이 이미 마운트되어 있어야 합니다. 마운트가 부족한 상태에서 스크롤을 시도하면 잘못된 높이로 계산되어 화면이 어긋납니다.
대화방 진입 시 viewport ~10개 셀 + 약간의 buffer로 15가 적정하다고 판단했습니다.
기본 21을 절반으로 줄인 이유는 메모리와 사용 패턴입니다.
windowSize=21 → 마운트 셀 ≈ 210개 (viewport 10셀 × 21)
windowSize=11 → 마운트 셀 ≈ 110개
대신 빠르게 플링 스크롤할 때 빈 셀이 잠깐 보일 수 있긴 한데... 드물기 때문에 감수하기로 결정했습니다.
이 둘은 명시적으로 기본값을 다시 적어둔 것입니다.
명시적으로 적어둔 이유는 "이 값을 검토했고 이게 적정이라고 판단했다"는 의도를 코드로 남기기 위함입니다.
채팅 셀은 컨텐츠가 충분히 차 있어 detach/attach 이슈가 잘 보이지 않을 거라 판단해 활성화했습니다. 실기 QA에서 빈 셀이 보고되면 비활성화하면 되는 한 줄 롤백 가능한 결정입니다.
가상화 글마다 등장하는 단골 prop이 있습니다.
getItemLayout={(_, index) => ({
length: 80,
offset: 80 * index,
index,
})}
이 prop은 각 셀의 높이와 위치를 RN에게 미리 알려주는 역할입니다. 알려주면 RN은 측정 단계를 건너뛰고 즉시 정확한 스크롤 위치를 계산할 수 있어 가장 강력한 최적화입니다.
하지만 채팅은 이걸 쓸 수 없습니다.
getItemLayout은 모든 셀이 동일 높이라는 전제가 있어야 합니다. 가변 높이에 무리하게 적용하면 offset 계산이 어긋나서 스크롤 위치가 엉뚱한 곳으로 점프합니다.
N=300 메시지 기준 시뮬레이션
| 지표 | 기본값 (AS-IS) | 튜닝 후 (TO-BE) |
|---|---|---|
| 마운트 셀 수 | 약 210개 | 약 110개 |
| 추정 메모리 사용량 | 약 1.6MB | 약 0.9MB |
| 첫 페인트 직후 렌더 셀 | 10개 | 15개 |
| frame drop 위험 (batch 단위) | 가변 | 10셀 상한으로 보호 |
5개 파라미터의 기본값과 의미는 docs에 다 나와 있습니다. 하지만 어떤 값으로 설정해야 하는가에 대한 답은 docs에 없습니다. 그건 우리 도메인이 어떤 사용 패턴인지, 셀이 어떤 모양인지, 어떤 trade-off를 받아들일지에 따라 달라지기 때문입니다.