Bookmark profile
Firestore의 1MB 제한

Firestore는 문서당 데이터를 1MB로 제한하고있다. 이는 대규모 응용 프로그램에서 데이터 구조를 설계할 때 고려해야 할 중요한 제한 사항이라고 생각한다.

이번에는 Firestore의 데이터 용량 제한에 대해 자세히 살펴보고, 이를 극복하기 위한 방법 중 하나인 서브컬렉션을 알아보자.

Firestore의 1MB 제한, 서브컬렉션의 개념과 활용

서브컬렉션은 Firestore에서 문서 내에 존재하는 하위 컬렉션을 의미한다. 이를 통해 데이터를 더 작은 단위로 분할하고 관리할 수 있다.

이렇게만 말하면 알기 힘들다!

블로그에 올릴 글과 그 글에 달릴 댓글에 대해 생각해보자. (Post – Comment[])

블로그 글은 1개지만, 댓글은 무수히 많아질 수 있다. 이를 하나의 배열 형태의 컬럼으로 만든다면, 블로그 글의 정보를 담고 있는 데이터는 금방 1MB를 초과할 것이다.

// 한 컬렉션에 댓글 모두 추가 (용량 1MB 초과)

const newPost = {
  title: '블로그 글 제목',
  comments: ['잘 보고 갑니다 1', ... ,'잘 보고 갑니다 1000000000'],
}

const postRef = await db.collection('posts').add(postData);

이런 경우를 고려하기 위해서, firestore를 쓰기 위해서는 서브컬렉션을 활용해야 한다.

// 서브컬렉션에 댓글 모두 추가 (용량 1MB 미만)

const newPost = {
  title: '블로그 글 제목',
  comments: ['잘 보고 갑니다 1', ... ,'잘 보고 갑니다 1000000000'],
}

const commentsRef = db.collection('posts').doc(postRef.id).collection('comments');

commentsData.forEach(async comment => {
  await commentsRef.add({ text: comment });
});

firestore를 사용할 때, 데이터가 많아지는 경우에는 이렇게 서브컬렉션을 나누어서 데이터를 넣는 방법으로 해야만 용량 초과를 막을 수 있다.

Firestore의 1MB 제한, 서브컬렉션 최적화를 위한 데이터 캐싱

댓글과 같은 서브컬렉션의 데이터를 자주 조회하는 경우, 클라이언트 측에서 데이터를 캐싱하여 조회 성능을 향상시킬 수 있다.

데이터를 캐싱하다!

참 어려운 말이다. 조금 더 쉽게 표현하자면, 데이터베이스의 데이터를 불러오지 않고, 사용중인 기기 내부에 데이터를 저장해서 인터넷 없이 데이터를 사용할 수 있도록 만드는 형태이다.

우선, 단순하게 포스트만 가지고 예시를 들어보자.

포스트의 제목이나 내용을 입력하는 순간, 상태값이 변경될 수 있도록 변수를 정의한다.

const [post, setPost] = useState({
  title: '',
  content: ''
});

이어서, 포스트의 캐시된(이전에 저장해둔) 데이터를 가져온다.

const cachedPost = await mmkv.getMap('cachedPost');

저장된 데이터가 있다면, 얼른 상태값이 넣어주자.

if (cachedPost) {
  setPost(cachedPost);
}

firestore에서는 onSnapshot 이라는 핸들러 함수를 통해서, 해당 데이터가 변경될 때마다 호출되도록 하는 함수가 존재한다.

이 함수를 이용하여 최근 변경된 데이터를 가져와서 상태값과 캐시를 업데이트 해준다.

const postRef = db.collection('posts').doc(postId);
postRef.onSnapshot(snapshot => {
  // 최근 변경된 데이터베이스 데이터
  const postData = snapshot.data();

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

  // 캐시 업데이트
  mmkv.setMap('cachedPost', postData);
});

이제 완벽한 데이터 처리 과정이 완성되었다.

  1. 캐시된 데이터를 가져온다
  2. 상태값을 업데이트해서, 사용자에게 보여준다
  3. 데이터베이스의 최신 데이터가 변경될 때마다 불러온다
  4. 상태값 및 캐시 데이터를 업데이트해서, 사용자에게 보여준다.

Firestore의 1MB 제한, 서브컬렉션 최적화를 클라우드 함수

마지막으로 클라우드 함수를 만들어서 연산을 처리하는 방법이 있지만, 이 방법은 사용량에 비례하여 요금이 청구된다. 하지만, 좋은점도 많이 있기 때문에 한번 고려해볼만 하다.

클라우드 함수를 만들어서 사용하면 좋은점

  1. 실시간 데이터 업데이트: Firestore는 실시간 업데이트를 지원하므로, 데이터 변경 시 클라이언트에 자동으로 업데이트된다. 이를 통해 실시간으로 변화하는 데이터를 쉽게 처리하고 화면에 반영할 수 있다.
  2. 서버리스 아키텍처: 클라우드 함수를 사용하면 서버를 구축하고 관리하는 번거로움을 줄일 수 있다. 필요한 로직을 클라우드 함수로 구현하여 Firebase의 인프라에서 실행되므로, 서버 유지보수와 관련된 복잡성이 감소한다.
  3. 확장성: Firestore와 클라우드 함수를 함께 사용하면 애플리케이션의 확장성을 높일 수 있다. Firestore는 자동으로 확장되며, 클라우드 함수를 사용하여 복잡한 로직을 처리할 수 있다. 이를 통해 사용자 증가에 따라 애플리케이션을 쉽게 확장할 수 있다.
  4. 보안: Firebase는 사용자 인증 및 데이터 보안을 위한 다양한 기능을 제공한다. Firestore 규칙을 사용하여 데이터에 대한 접근을 제어하고, 클라우드 함수에서 보안 로직을 구현하여 데이터를 안전하게 처리할 수 있다.
  5. 개발 생산성 향상: Firebase의 다양한 기능을 활용하면 개발 생산성을 높일 수 있다. Firebase SDK를 사용하여 간단한 코드로 데이터를 처리하고 클라우드 함수를 호출할 수 있으며, Firebase 콘솔을 사용하여 앱을 모니터링하고 분석할 수 있다.

클라이언트에서 클라우드 함수 호출하는 방법

import functions from '@react-native-firebase/functions';

// 클라우드 함수 호출
const updateCommentCount = functions().httpsCallable('updateCommentCount');
updateCommentCount({ postId: 'postId', commentId: 'commentId' })
  .then(result => {
    // 클라우드 함수 실행 결과 처리
    console.log(result.data);
  })
  .catch(error => {
    // 에러 처리
    console.error(error);
  });

마지막으로, 포스팅과 그 댓글에 대한 데이터를 서브컬렉션과 데이터 캐싱을 사용하여 다루는 코드를 아래에 첨부해 두었다.

코드예제) Post – Comments

import React, { useState, useEffect } from 'react';
import MMKVStorage from 'react-native-mmkv-storage';
import firebase from 'firebase/app';
import 'firebase/firestore';

const db = firebase.firestore();
const mmkv = new MMKVStorage.Loader().initialize();

const App = () => {
  const [post, setPost] = useState({
    title: '',
    content: ''
  });
  const [comments, setComments] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchPost = async () => {
      try {
        const cachedPost = await mmkv.getMap('cachedPost');
        if (cachedPost) {
          setPost(cachedPost);
        }

        const postRef = db.collection('posts').doc(postId);
        postRef.onSnapshot(snapshot => {
          const postData = snapshot.data();
          setPost(postData);
          setLoading(false);

          // 캐시 업데이트
          mmkv.setMap('cachedPost', postData);
        });
      } catch (error) {
        setError(error.message);
        setLoading(false);
      }
    };

    const fetchComments = async () => {
      try {
        const cachedComments = await mmkv.getArray('cachedComments');
        if (cachedComments) {
          setComments(cachedComments);
        }

        const commentsRef = db.collection('posts').doc(postId).collection('comments');
        commentsRef.onSnapshot(snapshot => {
          const commentsData = snapshot.docs.map(doc => doc.data());
          setComments(commentsData);
          setLoading(false);

          // 캐시 업데이트
          mmkv.setArray('cachedComments', commentsData);
        });
      } catch (error) {
        setError(error.message);
        setLoading(false);
      }
    };

    fetchPost();
    fetchComments();
  }, []);

  const handlePostEdit = (key, value) => {
    setPost(prevPost => ({
      ...prevPost,
      [key]: value
    }));
  };

  const handleCommentEdit = (index, key, value) => {
    setComments(prevComments => {
      const newComments = [...prevComments];
      newComments[index][key] = value;
      return newComments;
    });
  };

  const handleSubmit = async () => {
    try {
      // 게시물 업데이트
      await db.collection('posts').doc(postId).update(post);

      // 댓글 업데이트
      await Promise.all(comments.map((comment, index) => {
        return db.collection('posts').doc(postId).collection('comments').doc(comment.id).update(comments[index]);
      }));

      // 캐시 업데이트
      await mmkv.setMap('cachedPost', post);
      await mmkv.setArray('cachedComments', comments);
    } catch (error) {
      setError(error.message);
    }
  };

  if (loading) {
    return <div>Loading...</div>;
  }

  if (error) {
    return <div>Error: {error}</div>;
  }

  return (
    <div>
      <input
        type="text"
        value={post.title}
        onChange={e => handlePostEdit('title', e.target.value)}
      />
      <textarea
        value={post.content}
        onChange={e => handlePostEdit('content', e.target.value)}
      />
      <button onClick={handleSubmit}>Save</button>
      <h2>Comments</h2>
      {comments.map((comment, index) => (
        <div key={comment.id}>
          <input
            type="text"
            value={comment.text}
            onChange={e => handleCommentEdit(index, 'text', e.target.value)}
          />
          <button onClick={handleSubmit}>Save</button>
        </div>
      ))}
    </div>
  );
};

export default App;

Firestore를 공식 문서의 사용법을 주로 참고하여 앱을 개발해 왔으나, 이번 기회를 통해 데이터 1MB 용량 제한과 최적화를 위한 데이터캐싱 및 클라우드 함수를 알게 되었다.

궁금해 하는 것. 이게 가장 중요한 것 같다. 아니라면, 내가 하는 방식 외에 새로운 것들은 배울 수 없다. (어쨌든, 돌아가긴 하니까 …)