React Native에서 리스트 컴포넌트 개선하기
사용자가 만들어낸 수많은 캐릭터와 대화할 수 있도록 만들어진 우리 앱은 다양한 주제로 많은 이야기가 만들어지고 있습니다.
캐릭터와 채팅을 시작하면 친구 탭에 자동으로 캐릭터의 프로필이 추가되는데요. 사용자와 여러 캐릭터와 개별적으로 제한 없이 대화할 수 있다는 서비스 특성상 유저들의 친구 탭에는 수십 개, 많게는 수백 개의 캐릭터 목록이 생성됩니다. 그렇기 때문에 화면 렌더링 성능에 큰 관심이 갈 수 밖에 없었고, 그동안 빠르게 달려나가 미쳐 해결하지 못 했던 친구 탭의 화면 렌더링 성능을 개선 시도를 공유해보려고 합니다.
성능 이슈
캐릭터가 많아질 수록 느려지는 목록 렌더링
단순히 몇 명의 캐릭터라면 사용성에는 크게 문제가 되지 않겠지만, 그 목록이 늘어나는 경우 화면 스크롤시 목록이 제대로 렌더링이 되지 않아 마치 앱이 멈춘 것처럼 느껴지는 경험을 사용자에게 제공하고 있었습니다.
호감도에 따른 그룹화 문제
대화를 통해 해당 캐릭터와의 호감도가 증가하고 더 깊은 대화를 할 수 있도록 설계되어 있기 때문에 친구 탭에서는 호감도에 따른 단계별로 친구 목록을 그룹화하고 있습니다. 이를 위해 SectionList
를 사용했는데요. 문제는 클라이언트에서 직접 데이터를 그룹화하고, 그 데이터를 다시 렌더링하는 구조였습니다.
🚨 초기 구조의 문제점
- 초기 로딩 지연: 모든 캐릭터를 클라이언트에서 그룹화하고, 각 그룹을
SectionList
로 렌더링하는 데 시간이 걸림. - 스크롤 성능 저하: 데이터가 많아질수록, 가상화 성능이 떨어지며, 스크롤할 때 빈 화면이 나타나는 현상 발생.
- 불필요한 렌더링:
SectionList
는 각 섹션마다 헤더를 렌더링하기 때문에, 그룹이 많아질수록 성능 부담이 증가.
구조를 바꾸어 빠르게 그려보기
위에서도 언급한 것처럼 캐릭터 DB에는 호감도에 따른 단계 데이터가 없었고, 클라이언트에서 직접 데이터를 그룹화하고 있었는데요. DB 마이그레이션으로 서버에서 그룹화된 데이터를 제공할 수도 있었지만, 매일 1,000개가 넘는 캐릭터가 생성되며 누적된 캐릭터가 백만 개가 넘어가는 상황에서는 마이그레이션 비용이 너무 크다고 판단했습니다.
따라서 저는 SectionList
의 사용을 포기하고, FlatList
로 직접 그룹화된 목록을 렌더링하는 방식을 선택했습니다.
직접 렌더링하기
우선 renderItem
에서 섹션을 직접 렌더링 하는 방식으로 변경했습니다. 이를 통해 불필요한 섹션 렌더링을 없애고 필요할 때만 렌더링 되도록 했습니다.
const renderItem = useCallback(({ item, index }) => {
const isNewSection =
chatbotContacts?.[index - 1]?.intimacyLevel !== item.intimacyLevel;
return (
<>
{isNewSection && (
<View style={styles.sectionHeader}>
<Text color={Colors.base._80} variant="p14">
{
(translations as any)[
`readable_intimacy_${item.intimacyLevel}`
]
}
</Text>
</View>
)}
<ChatbotContactListItem chatbotId={item.id} />
</>
);
}, [chatbotContacts, translations]);
useMemo는 필수
useMemo
를 사용하여 불필요한 곳에서 낭비되는 것들도 최적화하는 것도 잊지 않았습니다.
const listHeaderComponent = useMemo(
() => (
<>
<View style={{ borderBottomColor: Colors.pink._10, borderBottomWidth: 1 }}>
{!!authUser && <ChatbotContactUserProfile />}
</View>
{recentlyAddedOrUpdatedChatbotContacts('update')}
{recentlyAddedOrUpdatedChatbotContacts('new')}
</>
),
[authUser, recentlyAddedOrUpdatedChatbotContacts]
);
const listFooterComponent = useMemo(() => {
if (chatbotContacts.length <= 3) {
return (
<TouchableOpacity onPress={() => navigation.navigate('RecommendationTab')}>
<IconButton size={40} name="plus" />
<Text>{translations['app.contact.add-friend-action']}</Text>
</TouchableOpacity>
);
}
return null;
}, [chatbotContacts, navigation, translations]);
FlatList로 대체하기
마지막으로 FlatList
로 컴포넌트를 대체하고 각 옵션들을 이용하여 스크롤 성능을 보장하려고 했습니다.
<FlatList
data={chatbotContacts}
renderItem={renderItem}
keyExtractor={(item) => item.id}
initialNumToRender={10}
maxToRenderPerBatch={12}
windowSize={5}
getItemLayout={(data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
/>
결과적으로
최소 100명 이상의 캐릭터가 있을 때 스크롤 성능이 300명이 넘어가도 원활하게 동작하는 것을 확인했습니다. 성능 개선이 사용성 증가에 직접적인 영향을 주기 때문에 전체적인 앱 사용성에 대한 사용자들의 평가도 좋아졌어요. 이 경험을 통해 SectionList가 모든 경우에 최선의 선택은 아니라는 점을 배웠습니다. 특히 대용량 데이터를 효율적으로 렌더링하려면, FlatList의 유연성과 가상화 렌더링이 훨씬 유리할 수 있다는 점을 확인했습니다. 다만 100%의 개선은 아니라고 생각합니다. React Native
에 대한 지식이 부족한만큼 앞으로 더 노력하여 완벽한 성능의 친구 탭을 사용자에게 전달하도록 해야겠다고 느꼈습니다.