Neoself의 기술 블로그

React Native 스켈레톤 UI 모듈 구현 및 최적화 본문

개발지식 정리/React Native

React Native 스켈레톤 UI 모듈 구현 및 최적화

Neoself 2024. 6. 26. 16:06

모바일 앱 개발에서 사용자 경험은 매우 중요하다. 특히 데이터를 불러오는 동안 사용자에게 적절한 피드백을 제공하는 것은 필수적이다. 이 글에서는 React Native 환경에서 효과적인 로딩 UI를 구현하는 방법, 특히 스켈레톤 UI를 최적화하는 과정을 다루고자 한다.

 

서버로부터 데이터를 불러오는 작업은 비동기로 진행되는 경우가 많다. 즉, 데이터를 불러 이를 처음으로 렌더하기까지에는 적지않은 시간이 소요될 수 있다.  이때 적절한 로딩 UI를 제공하지 않으면 다음과 같은 문제가 발생할 수 있다.

  • 사용자 혼란: 화면이 비어 있거나 변화가 없으면 사용자는 앱이 멈췄다고 생각할 수 있음
  • 잘못된 상호작용: 로딩 중임을 알리지 않으면 사용자가 불완전한 데이터로 작업을 시도할 수 있음
  • 부정적인 사용자 경험: 기다림의 이유를 알 수 없는 사용자는 앱에 대해 부정적인 인상을 가질 수 있음

따라서 데이터를 불러오는 동안에는 로딩 UI를 화면 상에 표시하여, 사용자에게 이를 바로 알리는 것이 일반적이다.

 

Activity Indicator

이 Loading UI를 위해 React Native에서는 Activity Indicator 컴포넌트를 기본 제공하고 있다. 각 플랫폼별 기본 로딩 스피너를 표시하는 컴포넌트이며, 색상, 크기와 같은 세부 커스터마이징이 가능하다.

(좌) iOS, (우) Android

import React from 'react';
import {ActivityIndicator, View} from 'react-native';

const App = () => (
  <View>
    <ActivityIndicator />
    <ActivityIndicator size="large" />
    <ActivityIndicator size="small" color="#0000ff" />
    <ActivityIndicator size="large" color="#00ff00" />
  </View>
);

 

 

Skeleton UI

스켈레톤 UI는 ActivityIndicator보다 한 단계 더 발전된 로딩 UI이다. 실제 콘텐츠의 레이아웃을 미리 보여주어 사용자에게 어떤 정보가 로드될지 알릴 수 있기 때문이다.

 

1. 스켈레톤 UI 구현

이를 구현하는 방법들 중엔 react-native-skeleton-placeholder 라이브러리를 import하여 로딩 뷰를 구현하는 방법도 있지만, 라이브러리 없이 직접 구현하는 방법을 정리한 블로그 글이 있어, 이를 크게 참고해 SkeletonUI 로딩 컴포넌트 초안을 쉽게 구현할 수 있었다. 하기 블로그 글에 구현되어있는 스켈레톤 컴포넌트의 원리를 간단하게 설명하자면 다음과 같다.

  1. Skeleton UI 애니메이션에 있어 핵심인 투명도를 저장하는 opacity useRef 변수 생성
  2. Animated 라이브러리 함수들로 opacity값이 특정 속도에 맞춰 0.3에서 1.0을 무한 왕복하도록 애니메이션 패턴을 useEffect 훅 내부에 지정
  3. 구현하고자 하는 SkeletonUI 레이아웃에 맞춰 View 컴포넌트들과 StyleSheet 스타일을 구현
  4. 앞서 생성한 View 컴포넌트들에 Animated 속성을 Wrap하고, opacity useRef 변수를 opacity 스타일 속성에 대입

자세한 내용은 하기 링크에 접속해 확인할 수 있다.

https://velog.io/@chloedev/React-RN

 

[RN] 라이브러리 없이 Skeleton UI 만들기

사용자에게 데이터가 로딩 중임을 알리는 대체 컴포넌트를 의미한다. 보통은 loading Spinner를 사용하기도 하는데 사용자에게 지루한 대기 시간을 줄이기 위해 Skeleton UI를 사용하기도 한다.RN을 바

velog.io

위 예시와 다른 점이 하나 있다면, Skeleton 컴포넌트 최 바깥단의 View 컴포넌트에 position: 'absolute'와 zIndex:100 속성을 부여했는데, 이유는 2가지가 있다.

  1. 스켈레톤 UI가 사라질때 사라진 영역으로 나머지 컴포넌트들이 이동하며 발생하는 추가 재렌더 방지
  2. 나머지 컴포넌트들의 위치 변경 없이, 단순히 isLoading 변수와 && 연산자로 SkeletonUI 추가 및 삭제 가능
...  
  const [isLoading, setIsLoading] = useState<boolean>(false)
  ...
  return (
    <View>
      // 다른 컴포넌트의 위치 변경없이 최바깥단 View 내부에 isLoading 상태변수와 && 연산자로 호출할 수 있다.
      {isLoading && <SkeletonModule_1/>}
      ...
    </View>
  );
};

 

위 블로그 글에서 설명한 내용만으로 물론 Skeleton UI 구현은 가능하다. Skeleton UI가 단 하나의 화면에서만 사용되는 경우에는 상관없었지만, 각 화면마다 다른 레이아웃의 스켈레톤 UI가 필요해지게 되면서, 중복되는 코드 제거의 필요성을 느끼게 되었다.

2. 스켈레톤 UI 최적화

2.1 스타일 최적화

일반적으로 각 스크린 및 컴포넌트를 구성하는 index 파일마다 StyleSheet 정의용 파일을 하나씩 분리하여 구성하지만, 스켈레톤 UI 특성상 많은 컴포넌트들이 동일한 스타일 속성들을 공유하기에, 모든 스타일 속성들을 하나의 Styles.ts 파일로 통합해 중복 코드들을 제거할 수 있다.

또한 대부분의 View 컴포넌트들이 opacity ref 객체로부터 투명도값을 받고 있기 때문에, opacity를 인자로 받는 skeletonStyle 함수를 별도로 생성 및 호출하여 전체적인 코드 가독성을 높일 수 있다.

(좌) 변경 전, (우) 변경 후

 

2.2 애니메이션 로직 재사용

스타일 속성 선언에 사용되는 BoilerPlate들을 제거되었지만, 여전히 반환문 내부를 제외한 나머지 코드들 즉, 애니메이션 구현에 필요한 opacity Ref객체와 useEffect 훅, 그리고 Animated 함수 호출 구문은 모두 동일함을 확인할 수 있다. 따라서 useRef 함수, useEffect 훅으로 구성된 애니메이션 로직을 별도의 컴포넌트로 분리해 재사용성을 높였다.

 

애니메이션 로직 구성 컴포넌트

import { useEffect, useRef } from 'react';
import { Animated } from 'react-native';
import { MainScreenSkeleton } from './mainScreen';
import { DetailScreenSkeleton } from './detailScreen';
...
// 전달받는 인자값에 대한 타입 명시, 스켈레톤 UI가 추가될때마다 여기에 명시
interface SkeletonModuleProps {
  screenName:
    | 'MainScreen'
    | 'DetailScreen'
    |  ...
}

export const SkeletonModule = ({ screenName }: SkeletonModuleProps) => {
  // opacity ref객체 생성
  const opacity = useRef(new Animated.Value(0.3));
  
  // 인자값-스켈레톤 컴포넌트 형태의 Object
  const skeletonScreens = {
    MainScreen: MainScreenSkeleton,
    DetailScreen: DetailScreenSkeleton,
    ...
  };
  // 위 Object를 참조해 인자값에 대응되는 스크린 컴포넌트 Skeleton 변수에 대입
  const Skeleton =
    skeletonScreens[screenName as SkeletonModuleProps['screenName']];

  // 애니메이션 패턴 구현
  useEffect(() => {
    Animated.loop(
      Animated.sequence([
        Animated.timing(opacity.current, {
          toValue: 1,
          duration: 600,
          useNativeDriver: true,
        }),
        Animated.timing(opacity.current, {
          toValue: 0.3,
          duration: 1000,
          useNativeDriver: true,
        }),
      ]),
    ).start();
  }, [opacity, screenName]);
  
  // opacity 값 전달
  return <Skeleton opacity={opacity.current} />;
};

 

 

스켈레톤 레이아웃 컴포넌트

import { Animated, View } from 'react-native';
import { skeletonStyle, styles } from '../styles';

export const MainScreenSkeleton = ({ opacity }: { opacity: any }) => {
  // 반환문 제외하고 일괄 제거
  return (
    <View style={styles.container}>
      <Animated.View style={skeletonStyle(opacity).fullImageSmall} />

      <View style={styles.mainScreenSection}>
        <Animated.View style={skeletonStyle(opacity).head} />
          ...
      </View>
    </View>
  );
};

 

 


3. 스켈레톤 UI 사용 예시

최종적으로, 스켈레톤 UI를 다음과 같이 사용할 수 있다.

import React, { useState, useEffect } from 'react';
import { View } from 'react-native';
import SkeletonModule from './SkeletonModule';

const MainScreen = () => {
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // 데이터 로딩 로직
    // 로딩 완료 시 setIsLoading(false) 호출
  }, []);

  return (
    <View>
      {isLoading && <SkeletonModule screenName={'MainScreen'} />}
    </View>
  );
};

 

최종 도식

 

스켈레톤 UI 결과물