React Native 가상화 리스트에서 ListEmptyComponent 의 역할

eeennsu·2026년 6월 15일

React Native

목록 보기
53/88

ListEmptyComponent vs 삼항연산자 — 무엇이 다른가

FlashList, LegendList(그리고 RN 기본 FlatList)에는 ListEmptyComponent라는 prop이 있다. data가 비어 있을 때 자동으로 이 컴포넌트를 렌더링해준다. 그런데 다음처럼 삼항연산자로 직접 분기해도 똑같이 빈 상태를 보여줄 수 있다.

{type === 'SOME_TYPE' && dataList.length === 0 ? (
  <EmptyStateComponent />
) : (
  <CommonLegendList
    data={dataList}
    renderItem={renderItem}
    keyExtractor={item => item.idx}
    horizontal
    // ...
  />
)}

그렇다면 ListEmptyComponent를 굳이 쓰는 이유가 무엇인지, 둘은 어떤 차이가 있는지 정리한다.



1. ListEmptyComponent는 어떤 구조인가

내부적으로 리스트 컴포넌트는 대략 다음과 같이 동작한다. (개념적 의사코드)

function List({ data, renderItem, ListEmptyComponent, ListHeaderComponent, ListFooterComponent, ... }) {
  const isEmpty = !data || data.length === 0;

  return (
    <ScrollContainer {...scrollProps}>
      {ListHeaderComponent && renderElement(ListHeaderComponent)}

      {isEmpty
        ? renderElement(ListEmptyComponent)      // 비어 있으면 여기
        : data.map((item, i) => renderItem({ item, index: i }))  // 아니면 아이템들
      }

      {ListFooterComponent && renderElement(ListFooterComponent)}
    </ScrollContainer>
  );
}

핵심은 ListEmptyComponent가 스크롤 컨테이너 내부에, 헤더와 푸터 사이에 렌더링된다는 점이다. 즉 리스트의 레이아웃 컨텍스트를 그대로 유지한 채 빈 상태를 보여준다.

ListEmptyComponent는 세 가지 형태를 모두 받는다.

// 1) 컴포넌트 타입
ListEmptyComponent={EmptyState}

// 2) 엘리먼트
ListEmptyComponent={<EmptyState />}

// 3) 함수 (인라인)
ListEmptyComponent={() => <EmptyState />}


2. 삼항연산자와의 핵심 차이

겉보기 결과는 같아도, 둘은 렌더 트리 구조가 다르다.

1) 삼항연산자: 리스트 컴포넌트 자체가 사라진다

isEmpty ? <EmptyState /> : <List data={...} />
  • 비어 있을 때 <List>가 아예 마운트되지 않는다.
  • 데이터가 생기면 <List>새로 마운트된다. → 스크롤 위치, 측정 캐시, 내부 state가 초기화됨.
  • 반대로 데이터가 사라지면 <List>언마운트된다.

2) ListEmptyComponent: 리스트 컴포넌트는 항상 살아있다

<List data={dataList} ListEmptyComponent={EmptyState} renderItem={...} />
  • 데이터가 비든 차든 <List> 컴포넌트 자체는 계속 마운트 상태를 유지한다.
  • 내부에서 비어 있을 때만 ListEmptyComponent로 자식을 교체한다.
  • 데이터가 채워질 때 리스트가 새로 마운트되지 않으므로 스크롤 컨테이너, 측정 캐시 등이 유지된다.

이 차이가 실무에서 다음과 같은 결과로 이어진다.



3. 어느 쪽이 유리한가 — 상황별 판단

ListEmptyComponent가 유리한 경우

(1) 헤더/푸터를 빈 상태에서도 유지해야 할 때

<List
  data={dataList}
  ListHeaderComponent={<SearchBar />}      // 항상 보여야 함
  ListEmptyComponent={<NoResults />}
  ListFooterComponent={<Disclaimer />}
  renderItem={renderItem}
/>

검색 결과 리스트에서 검색창(헤더)은 항상 떠 있어야 하고, 결과만 비어 있을 때 "결과 없음"을 보여주고 싶다면 ListEmptyComponent가 정답이다. 삼항연산자로 리스트 전체를 빼버리면 헤더의 검색창도 같이 사라진다.

(2) Pull-to-Refresh를 빈 상태에서도 쓰고 싶을 때

<List
  data={dataList}
  ListEmptyComponent={<NoData />}
  refreshControl={<RefreshControl refreshing={loading} onRefresh={refetch} />}
/>

빈 상태에서도 아래로 당겨 새로고침을 해야 하는 화면이라면, 스크롤 컨테이너가 살아있어야 한다. 삼항연산자로 리스트를 빼면 RefreshControl도 함께 사라져 새로고침을 못 한다.

(3) 데이터가 자주 비었다 찼다 하는 화면

검색어를 바꿀 때마다 결과가 비었다 찼다 한다면, 삼항연산자는 매번 리스트를 마운트/언마운트한다. ListEmptyComponent는 컨테이너를 유지하므로 전환이 더 매끄럽고, 마운트 비용이 없다.

(4) 빈 상태도 리스트 영역 안에 자연스럽게 배치하고 싶을 때

contentContainerStyle, padding, 스크롤 영역의 중앙 정렬 등 리스트의 레이아웃 컨텍스트를 그대로 활용할 수 있다.


삼항연산자가 유리한 경우

(1) 빈 상태의 레이아웃이 리스트와 완전히 다를 때

{type === 'SOME_TYPE' && dataList.length === 0 ? (
  <ChashbackEmptyState />        // 가로 리스트와 무관한 별도 디자인
) : (
  <CommonLegendList horizontal ... />   // 가로 스크롤 리스트
)}

리스트는 horizontal(가로 스크롤)인데, 빈 상태(ChashbackEmptyState)는 아마 세로로 꽉 찬 별도 디자인일 것이다. 이런 경우 ListEmptyComponent로 가로 스크롤 컨테이너 안에 욱여넣으면 오히려 레이아웃이 꼬인다. 빈 상태가 리스트의 레이아웃 컨텍스트를 공유할 필요가 없다면 삼항연산자가 더 깔끔하다.

(2) 빈 상태에서 리스트 관련 로직을 아예 끄고 싶을 때

빈 상태일 때 리스트의 스크롤, 측정, 가상화 로직이 도는 게 불필요하다면, 삼항연산자로 컴포넌트 자체를 빼는 게 더 명확하고 가볍다.

(3) 조건이 데이터 유무 외에 복합적일 때

지금 코드처럼 type === 'SOME_TYPE' && dataList.length === 0처럼 "특정 타입일 때만, 그리고 비어 있을 때만" 같은 복합 조건이라면, ListEmptyComponent로는 표현하기 번거롭다. ListEmptyComponent는 "data가 비면"이라는 단일 조건에만 반응하기 때문이다. 복합 조건은 삼항연산자가 읽기 쉽다.



4. 특히 horizontal 리스트에서 주의할 점

가로 리스트의 ListEmptyComponent가로 스크롤 컨테이너 안에 들어가기 때문에, 빈 상태 컴포넌트의 너비가 화면 전체로 잡히지 않을 수 있다. 가로 컨텐츠 영역은 콘텐츠 크기에 맞춰 좁아지려는 성질이 있어서, "화면 가운데에 크게 표시" 같은 디자인이 의도대로 안 나온다. 이걸 맞추려면 빈 컴포넌트에 명시적으로 width: SCREEN_WIDTH 등을 줘야 하는데, 그럴 바엔 삼항연산자로 빼는 게 더 직관적이다.

그래서 가로 리스트 + 리스트와 완전히 다른 빈 상태 디자인 조합에서는, 질문의 코드처럼 삼항연산자가 합리적인 선택이다.



5. 정리

기준ListEmptyComponent삼항연산자
리스트 컴포넌트항상 마운트 유지빈 상태일 때 언마운트
헤더/푸터빈 상태에서도 유지같이 사라짐
Pull-to-Refresh빈 상태에서도 가능같이 사라짐
스크롤 위치/측정 캐시유지됨초기화됨
빈 상태 레이아웃리스트 컨텍스트 공유완전히 독립적
복합 조건 분기표현 번거로움자연스러움
가로 리스트 + 다른 디자인너비 이슈 발생 가능깔끔함

결론

ListEmptyComponent와 삼항연산자는 우열 관계가 아니라 용도가 다르다.

  • 빈 상태가 리스트의 일부처럼 동작해야 하면(헤더/푸터 유지, pull-to-refresh, 같은 레이아웃 컨텍스트) → ListEmptyComponent
  • 빈 상태가 리스트와 완전히 별개의 화면이면(다른 레이아웃, 복합 조건, 특히 가로 리스트) → 삼항연산자



보너스: ListHeaderComponentListFooterComponent

ListEmptyComponent와 형제 격인 prop이 ListHeaderComponentListFooterComponent이다. 이름 그대로 리스트의 맨 위(헤더)와 맨 아래(푸터)에 고정으로 렌더링되는 컴포넌트다.

<List
  data={poiList}
  ListHeaderComponent={<SectionTitle />}   // 리스트 맨 위
  ListFooterComponent={<LoadMoreButton />} // 리스트 맨 아래
  renderItem={renderItem}
/>

여기서 자주 나오는 질문이 "그냥 리스트 바깥에 헤더/푸터를 두면 안 되나?" 이다. 결과는 비슷해 보여도 둘은 동작이 다르다.

1. 바깥에 두는 방식

<View>
  <SectionTitle />              {/* 리스트 바깥 헤더 */}
  <List data={poiList} renderItem={renderItem} />
  <LoadMoreButton />            {/* 리스트 바깥 푸터 */}
</View>

이 구조에서 헤더와 푸터는 리스트 스크롤 영역 바깥에 고정된다. 즉 리스트를 스크롤해도 헤더/푸터는 그 자리에 그대로 붙어 움직이지 않는다. (sticky한 고정 영역처럼 동작)


2. ListHeaderComponent / ListFooterComponent 방식

<List
  data={poiList}
  ListHeaderComponent={<SectionTitle />}
  ListFooterComponent={<LoadMoreButton />}
  renderItem={renderItem}
/>

이 구조에서 헤더와 푸터는 리스트 스크롤 영역 안에 포함된다. 즉 리스트를 스크롤하면 헤더는 콘텐츠와 함께 위로 밀려 사라지고, 푸터는 맨 아래까지 스크롤해야 보인다.



3. 핵심 차이 — "스크롤과 함께 움직이는가"

기준리스트 바깥에 선언ListHeader/FooterComponent
스크롤 시 동작화면에 고정 (안 움직임)콘텐츠와 함께 스크롤
스크롤 영역헤더/푸터 제외한 리스트만헤더+아이템+푸터 전체
가상화 영향리스트와 무관리스트 가상화 흐름에 포함
빈 상태일 때항상 보임 (리스트와 독립)빈 상태에서도 보임 (리스트 내부)

가장 큰 차이는 스크롤 동작이다. 한 문장으로 요약하면:

스크롤할 때 같이 움직여야 하면 ListHeaderComponent, 고정되어 있어야 하면 바깥에 선언.


4. 실무에서의 선택 기준

ListHeaderComponent를 쓰는 경우

  • 스크롤하면 사라져도 되는 헤더: 프로필 배너, 섹션 설명, 배너 광고 등. 스크롤을 내리면 콘텐츠에 자리를 내주고 위로 밀려나는 게 자연스러운 경우.
  • 무한 스크롤의 로딩 인디케이터(푸터): 리스트 맨 아래 도달 시 보이는 "로딩 중..." 스피너. 콘텐츠 끝에 자연스럽게 붙어야 하므로 ListFooterComponent가 적합.
  • 리스트와 함께 측정되어야 하는 콘텐츠: 헤더 높이까지 포함해 스크롤 위치를 계산해야 할 때.

바깥에 선언하는 경우

  • 항상 고정되어야 하는 헤더: 화면 제목, 검색 바, 탭 바, 필터 버튼 등. 스크롤해도 항상 보여야 하는 UI.
  • 고정 액션 버튼(푸터): 화면 하단에 늘 떠 있어야 하는 "다음" 버튼, "구매하기" 버튼 같은 CTA.


5. 성능 관점의 추가 포인트

ListHeaderComponent/ListFooterComponent무거운 컴포넌트나 인라인 함수를 넣으면 주의가 필요하다.

// ❌ 매 렌더마다 새 함수/엘리먼트 생성 → 불필요한 재렌더
<List ListHeaderComponent={() => <HeavyHeader data={someProp} />} />
 
// ✅ 메모이즈하거나 안정적인 참조 사용
const renderHeader = useCallback(() => <HeavyHeader data={someProp} />, [someProp]);
<List ListHeaderComponent={renderHeader} />

리스트가 리렌더링될 때 헤더/푸터도 함께 평가되므로, 무거운 헤더라면 useCallback이나 React.memo로 안정적인 참조를 유지하는 게 좋다. 반면 바깥에 선언한 헤더는 리스트의 리렌더 흐름과 분리되어 있어 이 문제에서 비교적 자유롭다.

profile
이력서 https://resume.eunsu.pro

0개의 댓글