개발/주니어 개발자의 캐시백 앱 단독 개발기

7. 편했던 ScrollView를 FlatList로 바꾸게 된 이유

hyjoo1226 2026. 2. 9. 17:01

웹 개발만 하다가 React Native로 앱 개발을 처음 시작했을 때, 가장 편하다고 느꼈던 컴포넌트는 ScrollView였습니다.

배열을 .map()으로 돌려서 컴포넌트만 나열하면 자동으로 스크롤이 생기고, 무한 스크롤처럼 자연스럽게 동작했기 때문입니다.

// 브랜드 목록 렌더링
<ScrollView>
  {brands.map(brand => (
    <BrandCard key={brand.id} brand={brand} />
  ))}
</ScrollView>

 

웹에서는 무한 스크롤을 따로 구현했던 기억이 있는데, 앱에서는 "그냥 되네?"라는 느낌이었습니다.

그래서 아무 생각 없이 계속 이 방식만 쓰고 있었습니다.

데이터가 많아지기 전까지는요.

 

 

 

 

 

1. ScrollView와 무한렌더링

 

100개의 브랜드 더미데이터를 넣고 카테고리 페이지에서 테스트하던 중이었습니다.

화면이 로딩될 때 눈에 띄에 버벅이는 현상이 발생했습니다.

// 카테고리 상세페이지

<ScrollView>
  <CategoryHeader />
  <PromotionBanner />
  
  {/* 브랜드 목록 */}
  {filteredBrands.map(brand => (
    <BrandCard key={brand.id} brand={brand} />
  ))}
</ScrollView>

 

원인을 살펴보니 100개의 BrandCard가 한 번에 모두 렌더링되고 있었습니다.

화면에는 10개만 보이는데, 나머지 90개도 메모리에 올라와 있었던 거죠.

렌더링된 아이템이 100개인데 실제로 보이는게 10개라면, 90개분의 메모리가 그대로 낭비됩니다.

그 때 ScrollView가 무한스크롤이 아니라 무한렌더링이구나 하고 깨달았습니다.

 

 

 

 

 

2. FlatList와 가상화

 

React Native 공식 문서를 다시 읽어보니 FlatList라는 컴포넌트가 있었습니다.

FlatList의 핵심은 가상화(Virtualization)입니다.

현재 화면에 보이는 아이템만 렌더링하며, 스크롤하면 기존 아이템을 메모리에서 지우고 새 아이템을 그립니다.

// ScrollView

[1] [2] [3] [4] [5] ... [100] ← 전부 렌더링
 ↑   ↑   ↑
화면에 보이는 부분 (3개만 보임)




// FlatList

[X] [X] [3] [4] [5] [X] [X] ← 보이는 것만 렌더링
         ↑   ↑   ↑
    화면에 보이는 부분

 

기존에 ScrollView로 구현됐던 부분을 FlastList로 바꾸면 다음과 같습니다.

결과적으로 엄청나게 부드러워지고, 아이템이 많아지더라도 스크롤이 끊김 없이 작동했습니다.

// Before: ScrollView + .map()
<ScrollView>
  {brands.map(brand => (
    <BrandCard key={brand.id} brand={brand} />
  ))}
</ScrollView>

// After: FlatList
<FlatList
  data={brands}
  renderItem={({ item }) => <BrandCard brand={item} />}
  keyExtractor={(item) => item.id.toString()}
/>

 

 

 

 

 

3. 헤더는 어디에두지?

 

카테고리 상세페이지는 단순히 브랜드 목록만 있는 게 아니었습니다.

헤더와 배너, 브랜드 목록의 총 3가지 부분으로 구성됩니다.

 

 

시도1: ScrollView 안에 FlatList

 

처음엔 이렇게 했습니다.

<ScrollView>
  <CategoryHeader />
  <PromotionBanner />
  
  <FlatList
    data={brands}
    renderItem={({ item }) => <BrandCard brand={item} />
    keyExtractor={(item) => item.id.toString()}
  />
</ScrollView>

 

하지만 제대로 작동하지 않았고, 경고 메세지가 떴습니다.

ScrollView 안에 있는 FlatList의 높이가 무한대로 인식되기 때문에 가상화가 제대로 이루어지지 못한 것입니다.

VirtualizedLists should never be nested inside plain ScrollViews

 

ScrollView는 자식 컴포넌트들의 총 높이(contentSize)를 먼저 계산해서, 그 값을 기준으로 스크롤 가능한 범위를 정합니다.

그런데 FlatList는 내부적으로 가상화된 리스트라 고정된 전체 높이를 전제로 하지 않고 동작합니다.

이 둘이 섞이면서 ScrollView 입장에서는 FlatList의 높이를 제대로 계산하지 못하고, 사실상 무한대처럼 취급하게 됩니다.

 

 

시도2: ListHeaderComponent 활용

 

FlatList에는 이미 해결책이 있었습니다. 공식적으로 제공하는 prop 들이었죠.

  • ListHeaderComponent: 목록 맨 위에 표시 (스크롤 됨)
  • ListFooterComponent: 목록 맨 아래에 표시
  • ListEmptyComponent: 데이터가 비었을 때 표시
<FlatList
  data={brands}
  renderItem={({ item }) => <BrandCard brand={item} />}
  keyExtractor={(item) => item.id.toString()}
  
  // 헤더 컴포넌트 추가
  ListHeaderComponent={
    <>
      <CategoryHeader />
      <PromotionBanner />
    </>
  }
  
  // 빈 화면 처리
  ListEmptyComponent={
    <View className="items-center py-20">
      <Text>해당 카테고리의 브랜드가 없습니다</Text>
    </View>
  }
/>

 

이제 페이지 전체가 하나의 FlatList이므로 가상화가 완벽하게 작동합니다.

 

 

 

 

 

4. 이미 스크롤이 있는 화면에서는 어떻게 할까

 

그런데 또 하나의 문제가 있었습니다.

부모가 이미 스크롤을 담당하고 있고, 그 안에서 일부 아이템만 목록 형태로 보여줘야 하는 경우입니다.

대표적인 예가 홈 화면이었습니다.

// 홈 화면 구조

[캐시백 카드]
[배너 슬라이더]
[카테고리별 브랜드 섹션]
  - 뷰티 브랜드
  - 헬스 브랜드
  - 여행 브랜드

 

홈 화면 전체는 이미 ScrollView로 감싸져 있고, 각 카테고리 섹션 안에서 브랜드 카드 목록을 보여주는 구조입니다.

이 상태에서 각 섹션마다 FlatList를 사용하면 또다시 중첩 스크롤 문제가 발생하게 됩니다.

이 경우에는 FlatList를 억지로 사용하기보다는, 아이템 개수와 화면의 역할을 기준으로 판단하기로 했습니다.

 

먼저 입점 브랜드 목록을 봤을 때 아직 개수가 많지 않아서, 이런 경우 조건부로 기존처럼 .map() 을 사용하더라도 성능상 큰 문제가 없다고 생각했습니다.

그리고 홈 화면에서 보여주는 브랜드 목록은 미리보기 성격이 강한 영역입니다.

그래서 만약 카테고리별 브랜드 수가 늘어나게 된다면, 홈 화면에서는 노출 개수를 제한하고 옆에 더보기 버튼을 두기로 결정했습니다.

홈 화면 전체를 FlatList로 바꾸는 선택지도 있었지만, 배너·카드·섹션 등 서로 다른 성격의 컴포넌트가 섞인 구조에서는 오히려 가독성과 유지보수가 더 복잡해질 수 있다고 판단했습니다.

 

 

 

 

 

5. 나만의 규칙 세우기

 

이 경험을 통해 React Native에서 스크롤에 대한 규칙을 정했습니다. 나만의 규칙이 생기니 고민하는 시간을 줄일 수 있었습니다.

  1. 정적 페이지는 ScrollView
    • 서로 다른 종류의 컴포넌트들
    • 컴포넌트의 개수가 적고 고정된 경우
  2. 긴 목록은 FlatList
    • 같은 종류의 컴포넌트가 반복됨
    • 아이템 개수가 많은 경우
  3. FlatList 페이지에 헤더가 있다면 ListHeaderComponent
  4. 예외적으로 .map() 허용
    • 부모에서 이미 스크롤 사용
    • 아이템 개수가 적거나 확정된 경우

 

 

ScrollView + .map() 은 편리해 보였지만 실제로는 성능 문제를 숨기고 있던 구조였습니다.

그 외에도 FlatList와 가상화, 중첩스크롤이라는 안티패턴 등에 대해 이해할 수 있었습니다.

 

다음 글에서는 실시간 캐시백 적립과 화면 자동 업데이트 문제를 다루겠습니다.