Bookmark profile
서브컬렉션을 활용한 Firestore 최적화

Firestore를 데이터베이스로 사용할 예정이므로, 데이터 스키마를 더 효율적으로 설계해야 한다는 필요성을 느꼈다. Firestore는 문서당 1MB 제한이 있고 서브컬렉션을 사용할 수 있기 때문에, 데이터 구조를 잘 정의하여 최적화된 API를 구축해보자.

서브컬렉션을 활용한 Firestore 최적화, 스키마의 관계에 맞는 데이터 조회방법

앱 사용자에게 기록을 제공하기 위해서 데이터 구조를 고려해야 했다. 앱의 핵심 기능은 하루를 기록하는 것이며, 부가적으로 꿈/목표/할 일을 관리하는 기능을 제공한다.

먼저 사용자 데이터에 기록을 추가했다.

사용자 데이터와 기록 데이터의 관계를 서브컬렉션으로 정의한다.

사용자 > 기록 형태로 서브컬렉션을 구성하면 아래와 같이 불러올 수 있다. 기록을 꿈과 별도로 분리한 이유는, 꿈/목표/할일 데이터는 내 일정을 관리하기 위함이지만 기록은 하루를 시작하는 목적이기 때문에 데이터의 성향이 다르기 때문이다.

// 사용자
const userRef = firestore.collection('users').doc(userId);

// 사용자 - 기록 목록
const recordsOfDaySnapshot = userRef.collection('recordsOfDay')
  .where(...)
  .get();

// 사용자 - 기록 목록 데이터 담을 배열
const recordsOfDay = [];

// 사용자 - 기록 목록 데이터 담기
recordsOfDaySnapshot.forEach(recordOfDayDoc => {
  recordsOfDay.push(recordOfDayDoc.data());
});

이어서, 꿈, 목표, 할 일에 대한 데이터 구조도 정의해보자.

사용자 데이터와 꿈/목표/할일 데이터의 관계를 서브컬렉션 계층으로 정의

사용자 부터 순서대로 사용자 > 꿈 > 목표 > 할일 계층으로 서브컬렉션을 구성하면 아래와 같이 불러올 수 있다. 계층이 너무 깊다고 볼 수도 있지만, 이 구조로 되어 있어야만 데이터간의 관계를 명확하게 명시할 수 있다고 생각했다.

// 사용자
const userRef = firestore.collection('users').doc(userId);

// 사용자 - 꿈 목록
const dreamsSnapshot = await userRef.collection('dreams')
  .where('...')
  .get();
const dreams = [];
for (const dreamDoc of dreamsSnapshot.docs) {
  const dreamData = dreamDoc.data();

  // 사용자 - 꿈 목록 - 목표 목록
  const goalsSnapshot = await dreamDoc.ref.collection('goals')
    .where('...')
    .get();
  const goals = [];
  for (const goalDoc of goalsSnapshot.docs) {
    const goalData = goalDoc.data();

    // 사용자 - 꿈 목록 - 목표 목록 - 할 일 목록
    const todosSnapshot = await goalDoc.ref.collection('todos')
      .where('...')
      .get();
    const todos = [];
    todosSnapshot.forEach(todoDoc => {
      todos.push(todoDoc.data());
    });
    goalData.todos = todos;

    // (옵션) 사용자 - 꿈 목록 - 목표 목록 - 반복 할 일 목록
    const recurringTodosSnapshot = await goalDoc.ref.collection('recurringTodos')
      .where('...')
      .get();
    const recurringTodos = [];
    recurringTodosSnapshot.forEach(recurringTodoDoc => {
      recurringTodos.push(recurringTodoDoc.data());
    });
    goalData.recurringTodos = recurringTodos;

    goals.push(goalData);
  }

  dreamData.goals = goals
  dreams.push(dreamData);
}

서브컬렉션을 활용한 Firestore 최적화, 데이터 캐싱 방법

데이터를 캐싱하기 위해 간단한 모듈을 작성했다. 가장 간단한 예시로 블로그 포스트 데이터를 캐싱하는 방법을 구현해보자.

import {MMKV} from 'react-native-mmkv';

export const cacheKeys = {
  CACHED_POST: 'CACHED_POST',
};

const cache = new MMKV();

export default cache;

캐시 모듈을 통해 데이터를 변경하고 조회하는 방법은 아래와 같다. 필요에 따라 cacheKeys 안에 원하는 키 값을 추가하여 관리할 수 있다.

// 변경
cache.set(cacheKeys.CACHED_POST, '포스트');

// 조회
cache.getString(cacheKeys.CACHED_POST);

그런 다음, 포스트를 가져오는 함수를 만들었다. UI를 렌더링하기 위한 상태값과 데이터를 가져오는 함수를 구현했다.

여기서 핵심은 데이터를 가져오는 함수의 동작이다.

  1. 캐시된 데이터를 먼저 가져온다. – 없다면? 걱정마라! onSnapshot 함수는 최초 1회 무조건 데이터를 불러오니까!
  2. 데이터베이스에 변경사항이 있으면 새로운 데이터를 가져온다.
  3. 이 두 과정을 통해 불필요한 데이터베이스 요청을 줄일 수 있다. 특히, 데이터베이스의 변경사항을 감지하는 onSnapshot 함수는 매우 유용하다!
// 상태값
const [post, setPost] = useState();

// 데이터 조회
const fetchPost = async () => {
  try {
    // 캐시에 저장된 데이터 불러오기
    const cachedPost = JSON.parse(cache.getString(cacheKeys.CACHED_POST));
    if (cachedPost) {
      setPost(cachedPost);
    }

    // 데이터베이스가 변경되면
    const postRef = db.collection('posts').doc(postId);
    postRef.onSnapshot(snapshot => {
      // 데이터베이스의 데이터 불러오기
      const postData = snapshot.data();

      // 상태값 업데이트
      setPost(postData);

      // 캐시에 저장된 데이터 업데이트
      cache.set(cacheKeys.CACHED_POST, JSON.stringfy(postData));
    });
  } catch (error) {
    console.error(error);
  }
};

그러면, 서브컬렉션에서는 어떻게 onSnapshot을 사용할 수 있을까? Firestore에서는 서브컬렉션에도 동일하게 onSnapshot 이벤트를 정의해야만 변경사항을 감지할 수 있다. 예를 들어, 포스트와 그 댓글을 예시로 살펴보자.

const [comments, setComments] = useState([]);

useEffect(() => {
  const unsubscribe = firestore()
    .collection('posts')
    .doc(postId)
    .collection('comments')
    .onSnapshot(snapshot => {
      const commentsData = snapshot.docs.map(doc => doc.data());
      setComments(commentsData);
    });

  return () => unsubscribe();
}, [postId]);

위와 같이, 메모리 누수를 방지하기 위해 onSnapshot 이벤트는 필요한 곳에서 선언하고, 필요 없을 때에는 제거하는 방식으로 useEffect 안에서 사용되는 것이 일반적이다.


데이터의 캐싱과 onSnapshot 함수의 조합으로 언제 데이터를 새로고침 해야하는지를 별도로 정의할 필요가 없어졌다. 이는 매우 획기적이라고 생각한다!