Logo
Published on
·9 min read

데이터 양이 많아지면서 발생한 React Native 앱 성능 문제 개선하기

개요

소소한 단어장 앱에 '[2022년 개정] 필수 영어 단어'를 다운 받아서 사용할 수 있도록 기능을 추가했습니다.

이 기능을 추가하면서 테스트를 하다보니, 단어 양이 많아질 수록 (3,000개 가량) 단어 목록 화면이 심하게 느려지는 문제를 발견했습니다.

하나의 문제가 아닌 복합적인 문제들이었는데(총체적 난국;;), 어떤 문제들이 있었는지 부끄럽지만 정리해봅니다.

react-native-async-storage => react-native-mmkv

데이터를 가져올 때 속도

사용자가 저장하거나, 다운받은 데이터는 react-native-async-storage 라이브러리를 사용하여 로컬에 저장하도록 되어있었습니다.

데이터 양이 적을 때는 문제없이 잘 사용했지만, 한 번에 몇 천개를 가져오려고 하니 속도가 10초 정도 걸리기도 했습니다. 😱

react-native-mmkv로 변경

react-native-mmkv 라이브러리로 변경했습니다.

아래와 같은 소개가 눈에 띄었습니다.

~30x faster than AsyncStorage

적용해보니 확실히 데이터를 가져오는 속도가 빨라졌습니다. (3,000개 정도를 가져오는데 1초도 걸리지 않았습니다.)

해당 라이브러리에 대한 더 자세한 내용은 react-native-mmkv 에서 확인할 수 있습니다.

데이터 마이그레이션

  • react-native-async-storage로 구현된 코드를 react-native-mmkv로 구현하는 작업을 했습니다.
    (매우 귀찮았지만, 기존 코드의 문제점을 발견하는 기회가 되었습니다.)

  • 기존에 AsyncStorage를 이용해 저장한 데이터를 MMKV 데이터로 다시 저장할 수 있도록 마이그레이션 코드를 추가 했습니다.

    • Migrate from AsyncStorage 문서에 있는 코드를 참고하여 마이그레이션을 진행했습니다.
      (예제 코드가 잘 되있어서 쉽게 마이그레이션 할 수 있었습니다.)
  • AscyncStorage로 저장된 백업 데이터를 MMKV로 복원할 수 있도록 코드 추가

    • 구글 로그인시 구글 드라이브에 백업할 수 있도록 되어있었습니다.
    • 백업했던 데이터를 복원 할 떄 이전 방식인 AscyncStorage 데이터인지, 새로운 방식인 MMKV 데이터인지 구분하여 복원해야했습니다.

완전 동기식

Fully synchronous calls, no async/await, no Promises, no Bridge.

MMKV 라이브러리의 소개에 위 내용이 있습니다.

비동기식으로 사용하는 AsyncStorage에 익숙해져서(?) 동기식으로 사용하는 MMKV를 사용하는 것이 조금 어색했지만 당연히 더 편리하긴 했습니다.

결론

React Native에서 로컬에 데이터를 저장할 떄는 AsyncStorage를 사용하는 것 외에는 선택지가 없는 줄 알았습니다.

혹시 프로젝트 초반, React Native 개발을 위한 로컬 스토리지 라이브러리를 검색하신다면 처음부터 react-native-mmkv를 사용하시는 것을 추천드립니다.

filter 남용

데이터를 가져오는데도 시간이 걸렸지만, 가져온 데이터를 선택한 조건에 맞게 필터링하는데도 시간이 걸렸습니다.

부끄럽게도 filter를 생각없이 막 썼습니다.

  • 검색어가 있을 경우 검색어로 filter
  • 선택한 표시 상태에 따라 filter
  • 선택한 퀴즈 결과에 따라 filter
  • 그리고 마지막으로 선택된 정렬 조건에 따라 정렬까지. (정렬에 관한 이야기는 다음 내용에서 다루겠습니다.)

3,000개의 데이터를 loop 3번, 그리고 마지막으로 정렬까지. 😨

변경

  • filter 조건이 없을 경우 바로 리턴

  • 3번에 걸친 filter를 1번으로 변경

const filterSentences = useCallback(() => {
    const searchTextNormalized = normalizeSearchText(searchText);

    // ... 필터 조건이 없으면 그냥 리턴 코드 생략

    // filter 1번으로 변경
    const result = group.sentences.filter((sentence) => {
      return (
        textMatch(sentence, searchTextNormalized) &&
        markedTypeMatch(sentence, filterType.markedTypes) &&
        quizResultMatch(sentence, filterType.quizResultTypes)
      );
    });

    // ... 정렬 코드 생략
  }, [group.sentences, filterType, searchText]);

결론

쉽게 사용하는 filter() / map() 등 loop 남용이 있지 않은 지 확인

정렬 문제

filter도 문제였지만, 정렬에도 꽤 많은 시간이 걸렸습니다.

기존에는 문자열로 된 id를 기준으로 정렬을 했습니다.

  filter.sort((a, b) => b.id.localeCompare(a.id));

데이터에 number 타입으로 timestamp(createdAt)를 추가로 기록하고, timestamp를 기준으로 정렬하도록 변경하니 성능이 향상됐습니다.

    result.sort((a, b) => {
      return b.createdAt - a.createdAt;
    });

결론

정렬 타입 확인

참고로 위 코드와는 다르게 정렬 순서를 따로 정의하지 않고 기본 sort() 함수를 사용하면, 숫자 타입을 문자열로 변환하여 정렬하기에, 속도와 결과면에서 예상치 못한 결과가 나올 수 있습니다.

불필요한 데이터 가져오기

추가/수정/삭제 등의 이유로 다른 화면으로 갔다가 다시 포커스 되면(isFocused) 데이터를 가져오도록 하는 코드가 있었습니다.

useEffect()의 의존성 배열에 isFocused를 추가해서 처리했는데, log를 출력하다 보니 다른 화면으로 이동할 때도 호출되는 것이었습니다.

if (isFocused) 조건이 없어서, 다른 화면으로 이동할 때도 호출되어 버리는 문제였습니다. 적다 보니 너무 민망합니다. 😰

  const isFocused = useIsFocused();
  useEffect(() => {
    if (isFocused) { // <--- !!! 포커스가 들어올 때만 데이터 가져오도록 수정
      // 데이터 가져오기
    }
  }, [isFocused]);

결론

데이터를 가져오는 부분 또는 속도 저하를 일으킬만한 곳에 log를 출력해보고, 불필요한 호출이 발생하는 부분이 있는 지 확인

React.Memo / useCallback

React Native 성능 개선 키워드로 검색하면 많이 나오는 내용입니다. 하지만, React.Memo를 적용한 컴포넌트에 정상적으로 적용되고 있는지, 개선된 것인지 긴가민가했습니다.

그래서, React.Memo를 적용한 컴포넌트에 log를 출력해보고, 확인해보니! 적용되지 않고 매번 렌더링이 되고 있었습니다.

이유

상위 컴포넌트에서 해당 컴포넌트를 호출하는 props에 메서드를 전달하는 부분이 있었습니다.

상위 컴포넌트가 랜더링 될 떄마다 해당 메서드가 매번 새로 생성되어서, 매번 새로운 props로 인식하여 React.Memo가 적용되지 않았던 것이었습니다.

전달하는 메서드를 useCallback으로 감싼 뒤 다시 log를 출력해보니, React.Memo가 적용되어 변경이 없을 때는 다시 렌더링이 되지 않는 것을 확인할 수 있었습니다.

결론

React.Memo 적용 후에는 log를 출력 등으로 확인 필요

캐시 사용하기

MMKV 라이브러리 사용으로 속도는 많이 개선되었지만, 여기서 조금 더 줄여보고 싶었습니다.

  • 데이터를 가져오는 함수명을 Key로, 결과 데이터를 Value로 저장
  • 캐시 데이터를 쓸지, 새로 데이터를 가져올 지 결정하기 위해 hasChanged 값을 이용하기로 함
    • 추가/수정/삭제가 발생하면 hasChanged 값을 모두 true로 변경
    • 하나의 hasChanged 값을 사용하면, 호출된 함수는 갱신되지만, 다른 함수는 여전히 캐시데이터를 사용하게 되므로 각 함수별로 hasChanged 값을 사용하기로 함
const cache = {}; // 캐시 데이터를 저장할 객체
const keyStates = {}; // 각 키에 대한 hasChanged 상태를 저장하는 객체

// 새로 읽을 지 캐시 데이터를 사용할 지 확인 후 데이터 반환
const getFromCacheOrRead = (key, readFunction) => {
  const cachedData = cache[key];
  const hasChanged = keyStates[key];

  if (hasChanged || !cachedData) {
    const newData = readFunction();

    // Update cache
    cache[key] = newData;

    // Save hasChanged status
    keyStates[key] = false;

    return newData;
  }

  return cachedData;
};

// 추가/수정/삭제가 발생하면 모든 hasChanged 값을 true로 변경
const resetAllKeyStates = () => {
  Object.keys(keyStates).forEach((key) => {
    keyStates[key] = true;
  });
};

// getFromCacheOrRead 사용 예
export const getAllGroupsFromStorage = () => {
  try {
    return getFromCacheOrRead('getAllGroupsFromStorage', () => {

      // 전체 키 목록 가져오기
      const allKeys = storage.getAllKeys();
      const groups = [];

      // group으로 시작하는 키만 필터링해서 가져오기
      for (const key of allKeys) {
        if (key.startsWith('group')) {
          groups.push(JSON.parse(storage.getString(key)));
        }
      }

      return groups;
    });
  }
  catch (e) {
    console.log('getAllGroupsFromStorage error: ', e);
    return [];
  }
}

// resetAllKeyStates 사용 예
export const addGroupToStorage = (group: GroupType) => {
  try {
    const key = group.id = 'group' + new Date().getTime().toString();

    storage.set(key, JSON.stringify(group));
    resetAllKeyStates();
    return key;
  }
  catch(e) {
    console.log('addToGroupStorage error: ', e);
    return '';
  }
}

디버깅 모드

문제점도 많았고, 많은 부분을 수정했지만, 그래도 렌더링시에 느린 부분이 있었습니다.

혹시 디버깅 모드라서 그런가 싶어서, 릴리즈 모드로 테스트 기기에 넣고 확인해보니 속도가 괜찮았습니다.

저는 주로 에뮬레이터로 확인하는 편이라, 에뮬레이터에서도 속도 확인을 할 수 있으면 좋겠다고 생각했습니다.

명령 프롬프트에서 d를 입력하면 아래와 같은 메뉴가 보입니다. (버전 등의 이유로 다르게 나올 수도 있습니다.)

디버깅 모드

Setting 을 선택하면 위와 같은 몇 가지 체크 박스가 표시됩니다.

저는 JS Dev Mode 체크 박스를 해제한 후, 확인하니 릴리즈 모드와 비슷하게 속도가 나왔습니다.

결론

디버깅 모드에서는 여러 이유로 속도 문제가 있으므로 릴리즈 모드, 혹은 JS Dev Mode 해제 후 확인

최종 결론

  • react-native-async-storage 라이브러리를 react-native-mmkv 라이브러리로 변경
  • filter 등 loop 남용 확인
  • 불필요한 데이터 가져오는 부분이 없는 지 log 등으로 확인
  • React.Memo / useCallback 으로 렌더링 최소화. 의도대로 잘 동작하는지 확인까지 필요
  • 캐시 사용하기
  • 디버깅 모드에서는 여러 이유로 속도 문제가 있으므로 릴리즈 모드, 혹은 JS Dev Mode 해제 후 확인

React Native는 처음이기도 하고, 기능 구현에 집중하느라 놓친 부분이 너무 많았습니다.

수정하면서도 부끄러웠고, 이 글을 쓰면서도 너무 부끄러워 글을 쓰지 말까도 생각했습니다. 😅

다음 개발을 위해서도 그렇고, 누군가의 실패담은 누군가에게는 도움이 될 수 있으니 쓰기로 했습니다.