[React Native] FlashList로 리스트의 렌더링 성능 최적화하기

profile image pIutos 2024. 7. 3. 21:41

들어가며

시대생 서비스의 도서관 예약하기 서비스는 아래 이미지처럼 도서관 현재 좌석 상태를 표시합니다.

도서관 예약하기 서비스

해당 서비스의 좌석 item을 표시하기 위해 보통 18 x 30 크기의 이차원 배열을 이중 리스트로 렌더링합니다. 

이를 구현하기 위해 리스트의 렌더링 성능을 최적화하기 위해 공식문서에서 제시하는 FlatList를 사용하고, getItemLayout prop을 이용하여 구현했습니다.

<FlatList
  data={...}
  renderItem={...}
  ...
  getItemLayout={(_, index) => ({
    length: itemWidth + 2,
    offset: (itemWidth + 2) * index,
    index,
  })}
>

하지만 해당 방법을 이용하더라도, 아래와 같이 시뮬레이터 및 실 기기에서 여전히 느린 렌더링 성능을 보여주었습니다.

또한 예약 페이지가 넘어가기 전에 버튼이 잠깐 멈춘듯하는 현상과, 렌더링이 될 때까지 좌석 일부만 표시되는 현상도 볼 수 있습니다.

예약 페이지의 느린 렌더링

이와 같은 현상은 UX를 저하시키는 요소이기 때문에, 개선하기 위해 Shopify/flash-list의 FlashList 컴포넌트를 서비스 코드에 도입하고, 기타 렌더링 성능을 최적화하기 위한 과정을 작성합니다.

FlashList 도입

Shopify/flash-list 패키지는 기존 RN의 FlatList보다 더 나은 렌더링 성능의 FlashList 컴포넌트를 제공합니다.

설치

$ yarn add @shopify/flash-list && cd ios && pod install

적용

<FlashList
  data={currentSeat}
  scrollEnabled={false}
  renderItem={row => {
	// ...
    return (
      <FlashList
        horizontal
        scrollEnabled={false}
        data={rowItem}
        renderItem={({item}) => {
          return (
            <SeatItem
             // ...
            />
          );
        }}
        initialScrollIndex={0}
        estimatedFirstItemOffset={itemWidth + 2}
        estimatedItemSize={itemWidth + 2}
        keyExtractor={(_, index) => index.toString()}
      />
    );
  }}
  initialScrollIndex={0}
  estimatedFirstItemOffset={itemWidth + 2}
  estimatedItemSize={itemWidth + 2}
  keyExtractor={(_, index) => index.toString()}
/>

FlashList의 prop으로 initialScrollIndex, estimatedItemSize 등의 속성을 주면 성능을 조금 더 최적화 할 수 있습니다.

단순히 FlatList를 FlashList로 교체하는 것 만으로도 이전보다는 빠르게 렌더링이 되는 것을 확인할 수 있었습니다.

추가적으로 FlashList는 FlatList와 다르게, List를 감싸는 View의 height, width를 지정해줘야 에러가 발생하지 않습니다.

트러블 슈팅: 레이아웃이 재배치 되는 현상

아래와 같이 처음 렌더링이 완료된 다음, 가로로 layout이 재정렬되는 현상이 발생했습니다.

원인을 파악한 결과, list에 들어갈 각 item의 너비(정사각형)를 소숫점으로 주게되면, 렌더링이 일어난 다음 너비를 재계산한다는 것을 알아냈습니다.

따라서 Math.round() 함수를 이용해 item의 너비를 반올림하여 정수로 설정하여 해당 문제를 해결했습니다.

const itemWidth = useMemo(() => {
  // ...
  return (width - 16) / libraryRowCount - 2; // 기존 
  return Math.round((width - 8) / libraryRowCount - 2); // 변경
  // ...

애니메이션 방식 변경

FlashList를 이용해 렌더링 시간은 이전에 비해 개선되었지만, 여전히 만족스럽지 못한 렌더링 속도를 보여주고 있습니다.

시대생 앱에서는 일관된 애니메이션을 보여주기 위해 reanimatied 기반의 @moti 패키지를 이용해, 아래와 같이 클릭 애니메이션을 추상화하여 사용하고 있습니다.

const AnimatePress = ({children, variant, ...props}: Props) => {
  return (
    <MotiPressable {...props} animate={switchVariant()}>
      {children}
    </MotiPressable>
  );
}

// 사용
<AnimatePress variant="scale_up_00">
  <Component />
</AnimatePress>

하지만 해당 커스텀 컴포넌트의 사용으로 인해 예약 페이지의 렌더링 성능이 저하되는 점을 발견했습니다.

좌: Moti 컴포넌트가 적용된 상태, Moti 컴포넌트를 제거한 상태

item마다 AnimatePress 컴포넌트를 감싸 구현이 되어있는데, devtool을 이용해 확인해 본 결과 View 레이어가 3~4중으로 더 렌더링이 되는 것을 확인할 수 있었습니다.

이에 많은 렌더링이 일어나는 예약 페이지에서는 커스텀 컴포넌트의 사용이 적합하지 못하다 판단하여, reanimated를 이용하여 직접 애니메이션을 구현했습니다.

const SeatItem = (...) => {
  const pressed = useSharedValue<boolean>(false);

  const animatedStyles = useAnimatedStyle(() => ({
    backgroundColor: pressed.value ? colors.grey60 : colors.grey40,
    transform: [{scale: withTiming(pressed.value ? 1.1 : 1, {duration: 250})}],
  }));
  
  // ...

  return isAvailable ? (
    <Pressable
      onPressIn={() => (pressed.value = true)}
      onPressOut={() => (pressed.value = false)}
      onPress={handlePressSeatItem}>
      <Animated.View
        style={[
          {
            width: itemWidth,
            height: itemWidth,
            backgroundColor: SeatStatusColorEnum[status],
            justifyContent: 'center',
            alignItems: 'center',
            borderRadius: 3,
            margin: 1,
            paddingLeft: 0.5,
          },
          animatedStyles,
        ]}>
        // ...
      </Animated.View>
    </Pressable>
  ) : (
    <View>
     // 그냥 View 표시
    </View>
  );
}

Pressable 컴포넌트의 onPressIn, Out prop을 통해 useSharedValue로 생성한 pressed 상태를 변경합니다. 그리고 AnimatedStyle을 컴포넌트에 적용했습니다.

추가적으로, 이미 예약이 불가능한 좌석(ex. 사용 중)은 클릭이 되지 않아야 하기 때문에 해당 경우 Animated가 적용되지 않은 그냥 View 컴포넌트를 렌더링하도록 구현했습니다. 이를 통해 리스트를 조금 더 빠르게 렌더링할 수 있을 것입니다.

reanimated를 이용하여 애니메이션을 직접 적용

결과 및 마무리

좌: 렌더링 성능 개선 전, 우: 개선 후

 

이번 글에서 소개한 방법으로 도서관 예약하기 페이지의 평균 4~5초에서 1~2초로 렌더링 성능을 많이 개선할 수 있었습니다.