2022년 10월 18일에 오프라인과 온라인으로 진행될 Firebase Summit에 참여하세요. Firebase로 앱을 빠르게 개발하고 안심하고 앱을 출시하며 손쉽게 확장하는 방법을 알아보세요. 지금 등록하기

Cloud Firestore 데이터 번들

컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.

Cloud Firestore 데이터 번들은 Cloud Firestore 문서 및 쿼리 스냅샷에서 사용자가 빌드한 정적 데이터 파일로, 사용자가 CDN, 호스팅 서비스 또는 기타 솔루션에 게시합니다. 데이터 번들에는 클라이언트 앱에 제공하려는 문서 및 이를 생성한 쿼리에 대한 메타데이터가 모두 포함됩니다. 클라이언트 SDK를 사용하여 네트워크를 통해서 또는 로컬 스토리지에서 번들을 다운로드한 후 번들 데이터를 Cloud Firestore 로컬 캐시에 로드합니다. 번들이 로드되면 클라이언트 앱이 로컬 캐시 또는 백엔드에서 문서를 쿼리할 수 있습니다.

데이터 번들을 사용하면 Cloud Firestore 백엔드 호출 없이도 시작 시에 문서를 사용할 수 있으므로 앱에서 일반 쿼리 결과를 더 빨리 로드할 수 있습니다. 또한 결과가 로컬 캐시에서 로드되면 액세스 비용도 줄어들게 됩니다. 동일한 최초의 100개 문서를 쿼리하는 100만 개의 앱 인스턴스에 대해 비용을 지불하는 대신 해당하는 100개 문서를 번들로 만드는 데 필요한 쿼리에 대해서만 비용을 지불하면 됩니다.

Cloud Firestore 데이터 번들은 다른 Firebase 백엔드 제품과도 잘 작동하도록 빌드되었습니다. Cloud Functions에 의해 번들이 빌드되고 Firebase 호스팅을 통해 사용자에게 제공되는 통합 솔루션을 살펴보세요.

앱에 번들을 사용하려면 다음 3단계를 따르세요.

  1. Admin SDK로 번들 빌드
  2. 로컬 스토리지 또는 CDN에서 번들 제공
  3. 클라이언트에 번들 로드

데이터 번들이란?

데이터 번들은 하나 이상의 문서 및 쿼리 스냅샷을 패키지로 만들기 위해 사용자가 빌드하고 이름이 지정된 쿼리를 추출할 수 있는 정적 바이너리 파일입니다. 아래 설명과 같이 서버 측 SDK를 사용하면 번들을 빌드할 수 있으며 클라이언트 SDK를 통해 번들을 로컬 캐시에 로드할 수 있습니다.

이름이 지정된 쿼리는 번들의 특히 강력한 기능 중 하나입니다. 이름이 지정된 쿼리는 번들에서 추출한 다음, 일반적으로 Cloud Firestore와 커뮤니케이션하는 앱의 특정 부분에서 쿼리하는 것과 같이 캐시나 백엔드에서 바로 데이터 쿼리에 사용할 수 있는 Query 객체입니다.

서버에서 데이터 번들 빌드

Node.js 또는 자바 Admin SDK를 사용하면 사용자가 번들에 포함할 항목과 제공 방법을 완벽하게 제어할 수 있습니다.

Node.js
var bundleId = "latest-stories";

var bundle = firestore.bundle(bundleId);

var docSnapshot = await firestore.doc('stories/stories').get();
var querySnapshot = await firestore.collection('stories').get();

// Build the bundle
// Note how querySnapshot is named "latest-stories-query"
var bundleBuffer = bundle.add(docSnapshot); // Add a document
                   .add('latest-stories-query', querySnapshot) // Add a named query.
                   .build()
      
자바
Firestore db = FirestoreClient.getFirestore(app);

// Query the 50 latest stories
QuerySnapshot latestStories = db.collection("stories")
    .orderBy("timestamp", Direction.DESCENDING)
    .limit(50)
    .get()
    .get();

// Build the bundle from the query results
FirestoreBundle bundle = db.bundleBuilder("latest-stories")
    .add("latest-stories-query", latestStories)
    .build();
      
Python
from google.cloud import firestore
from google.cloud.firestore_bundle import FirestoreBundle

db = firestore.Client()
bundle = FirestoreBundle("latest-stories")

doc_snapshot = db.collection("stories").document("news-item").get()
query = db.collection("stories")._query()

# Build the bundle
# Note how `query` is named "latest-stories-query"
bundle_buffer: str = bundle.add_document(doc_snapshot).add_named_query(
    "latest-stories-query", query,
).build()
      

데이터 번들 제공

Cloud Storage와 같은 서비스에서 번들을 다운로드하는 방식으로 또는 CDN에서 클라이언트 앱에 번들을 제공할 수 있습니다.

이전 섹션에서 만든 번들이 bundle.txt라는 파일에 저장되고 서버에 게시되었다고 가정해 보겠습니다. 이 번들 파일은 여기에서 설명한 간단한 Node.js Express 앱의 경우와 같이 웹을 통해 제공할 수 있는 기타 애셋과 유사합니다.

const fs = require('fs');
const server = require('http').createServer();

server.on('request', (req, res) => {
  const src = fs.createReadStream('./bundle.txt');
  src.pipe(res);
});

server.listen(8000);

클라이언트에서 데이터 번들 로드

HTTP 요청, Storage API 호출 또는 네트워크에서 바이너리 파일을 가져오는 기타 기술을 사용하여 원격 서버에서 Firestore 번들을 가져와 로드할 수 있습니다.

가져온 후에는 Cloud Firestore 클라이언트 SDK를 사용해 앱에서 작업 추적 개체를 반환하는 loadBundle 메서드를 호출하고, 반환된 객체를 통해 프로미스의 상태를 모니터링하는 것과 같이 완료 상태를 모니터링할 수 있습니다. 번들 로드 작업이 성공적으로 완료되면 로컬 캐시에서 번들 콘텐츠를 사용할 수 있습니다.

// If you are using module bundlers.
import firebase from "firebase/app";
import "firebase/firestore";
import "firebase/firestore/bundle"; // This line enables bundle loading as a side effect.

// ...

async function fetchFromBundle() {
  // Fetch the bundle from Firebase Hosting, if the CDN cache is hit the 'X-Cache'
  // response header will be set to 'HIT'
  const resp = await fetch('/createBundle');

  // Load the bundle contents into the Firestore SDK
  await db.loadBundle(resp.body);

  // Query the results from the cache
  // Note: omitting "source: cache" will query the Firestore backend.
  const query = await db.namedQuery('latest-stories-query');
  const storiesSnap = await query.get({ source: 'cache' });

  // Use the results
  // ...
}
Swift
참고: 이 제품은 watchOS 및 App Clip 대상에서 사용할 수 없습니다.
// Utility function for errors when loading bundles.
func bundleLoadError(reason: String) -> NSError {
  return NSError(domain: "FIRSampleErrorDomain",
                 code: 0,
                 userInfo: [NSLocalizedFailureReasonErrorKey: reason])
}

// Loads a remote bundle from the provided url.
func fetchRemoteBundle(for firestore: Firestore,
                       from url: URL,
                       completion: @escaping ((Result<LoadBundleTaskProgress, Error>) -> ())) {
  guard let inputStream = InputStream(url: url) else {
    let error = self.bundleLoadError(reason: "Unable to create stream from the given url: \(url)")
    completion(.failure(error))
    return
  }

  // The return value of this function is ignored, but can be used for more granular
  // bundle load observation.
  let _ = firestore.loadBundle(inputStream) { (progress, error) in
    switch (progress, error) {

    case (.some(let value), .none):
      if value.state == .success {
        completion(.success(value))
      } else {
        let concreteError = self.bundleLoadError(
          reason: "Expected bundle load to be completed, but got \(value.state) instead"
        )
        completion(.failure(concreteError))
      }

    case (.none, .some(let concreteError)):
      completion(.failure(concreteError))

    case (.none, .none):
      let concreteError = self.bundleLoadError(reason: "Operation failed, but returned no error.")
      completion(.failure(concreteError))

    case (.some(let value), .some(let concreteError)):
      let concreteError = self.bundleLoadError(
        reason: "Operation returned error \(concreteError) with nonnull progress: \(value)"
      )
      completion(.failure(concreteError))
    }
  }
}

// Fetches a specific named query from the provided bundle.
func loadQuery(named queryName: String,
               fromRemoteBundle bundleURL: URL,
               with store: Firestore,
               completion: @escaping ((Result<Query, Error>) -> ())) {
  fetchRemoteBundle(for: store,
                    from: bundleURL) { (result) in
    switch result {
    case .success:
      store.getQuery(named: queryName) { query in
        if let query = query {
          completion(.success(query))
        } else {
          completion(
            .failure(
              self.bundleLoadError(reason: "Could not find query named \(queryName)")
            )
          )
        }
      }

    case .failure(let error):
      completion(.failure(error))
    }
  }
}

// Load a query and fetch its results from a bundle.
func runStoriesQuery() {
  let queryName = "latest-stories-query"
  let firestore = Firestore.firestore()
  let remoteBundle = URL(string: "https://example.com/createBundle")!
  loadQuery(named: queryName,
            fromRemoteBundle: remoteBundle,
            with: firestore) { (result) in
    switch result {
    case .failure(let error):
      print(error)

    case .success(let query):
      query.getDocuments { (snapshot, error) in

        // handle query results

      }
    }
  }
}
Objective-C
참고: 이 제품은 watchOS 및 App Clip 타겟에서는 사용할 수 없습니다.
// Utility function for errors when loading bundles.
- (NSError *)bundleLoadErrorWithReason:(NSString *)reason {
  return [NSError errorWithDomain:@"FIRSampleErrorDomain"
                             code:0
                         userInfo:@{NSLocalizedFailureReasonErrorKey: reason}];
}

// Loads a remote bundle from the provided url.
- (void)fetchRemoteBundleForFirestore:(FIRFirestore *)firestore
                              fromURL:(NSURL *)url
                           completion:(void (^)(FIRLoadBundleTaskProgress *_Nullable,
                                                NSError *_Nullable))completion {
  NSInputStream *inputStream = [NSInputStream inputStreamWithURL:url];
  if (inputStream == nil) {
    // Unable to create input stream.
    NSError *error =
        [self bundleLoadErrorWithReason:
            [NSString stringWithFormat:@"Unable to create stream from the given url: %@", url]];
    completion(nil, error);
    return;
  }

  [firestore loadBundleStream:inputStream
                   completion:^(FIRLoadBundleTaskProgress * _Nullable progress,
                                NSError * _Nullable error) {
    if (progress == nil) {
      completion(nil, error);
      return;
    }

    if (progress.state == FIRLoadBundleTaskStateSuccess) {
      completion(progress, nil);
    } else {
      NSError *concreteError =
          [self bundleLoadErrorWithReason:
              [NSString stringWithFormat:
                  @"Expected bundle load to be completed, but got %ld instead",
                  (long)progress.state]];
      completion(nil, concreteError);
    }
    completion(nil, nil);
  }];
}

// Loads a bundled query.
- (void)loadQueryNamed:(NSString *)queryName
   fromRemoteBundleURL:(NSURL *)url
         withFirestore:(FIRFirestore *)firestore
            completion:(void (^)(FIRQuery *_Nullable, NSError *_Nullable))completion {
  [self fetchRemoteBundleForFirestore:firestore
                              fromURL:url
                           completion:^(FIRLoadBundleTaskProgress *progress, NSError *error) {
    if (error != nil) {
      completion(nil, error);
      return;
    }

    [firestore getQueryNamed:queryName completion:^(FIRQuery *query) {
      if (query == nil) {
        NSString *errorReason =
            [NSString stringWithFormat:@"Could not find query named %@", queryName];
        NSError *error = [self bundleLoadErrorWithReason:errorReason];
        completion(nil, error);
        return;
      }
      completion(query, nil);
    }];
  }];
}

- (void)runStoriesQuery {
  NSString *queryName = @"latest-stories-query";
  FIRFirestore *firestore = [FIRFirestore firestore];
  NSURL *bundleURL = [NSURL URLWithString:@"https://example.com/createBundle"];
  [self loadQueryNamed:queryName
   fromRemoteBundleURL:bundleURL
         withFirestore:firestore
            completion:^(FIRQuery *query, NSError *error) {
    // Handle query results
  }];
}

Java

public InputStream getBundleStream(String urlString) throws IOException {
    URL url = new URL(urlString);
    HttpURLConnection connection = (HttpURLConnection) url.openConnection();
    return connection.getInputStream();
}

public void fetchBundleFrom() throws IOException {
    final InputStream bundleStream = getBundleStream("https://example.com/createBundle");
    LoadBundleTask loadTask = db.loadBundle(bundleStream);

    // Chain the following tasks
    // 1) Load the bundle
    // 2) Get the named query from the local cache
    // 3) Execute a get() on the named query
    loadTask.continueWithTask(new Continuation<LoadBundleTaskProgress, Task<Query>>() {
        @Override
        public Task<Query> then(@NonNull Task<LoadBundleTaskProgress> task) throws Exception {
            // Close the stream
            bundleStream.close();

            // Calling getResult() propagates errors
            LoadBundleTaskProgress progress = task.getResult(Exception.class);

            // Get the named query from the bundle cache
            return db.getNamedQuery("latest-stories-query");
        }
    }).continueWithTask(new Continuation<Query, Task<QuerySnapshot>>() {
        @Override
        public Task<QuerySnapshot> then(@NonNull Task<Query> task) throws Exception {
            Query query = task.getResult(Exception.class);

            // get() the query results from the cache
            return query.get(Source.CACHE);
        }
    }).addOnCompleteListener(new OnCompleteListener<QuerySnapshot>() {
        @Override
        public void onComplete(@NonNull Task<QuerySnapshot> task) {
            if (!task.isSuccessful()) {
                Log.w(TAG, "Bundle loading failed", task.getException());
                return;
            }

            // Get the QuerySnapshot from the bundle
            QuerySnapshot storiesSnap = task.getResult();

            // Use the results
            // ...
        }
    });
}

Kotlin+KTX

@Throws(IOException::class)
fun getBundleStream(urlString: String?): InputStream {
    val url = URL(urlString)
    val connection = url.openConnection() as HttpURLConnection
    return connection.inputStream
}

@Throws(IOException::class)
fun fetchFromBundle() {
    val bundleStream = getBundleStream("https://example.com/createBundle")
    val loadTask = db.loadBundle(bundleStream)

    // Chain the following tasks
    // 1) Load the bundle
    // 2) Get the named query from the local cache
    // 3) Execute a get() on the named query
    loadTask.continueWithTask<Query> { task ->
        // Close the stream
        bundleStream.close()

        // Calling .result propagates errors
        val progress = task.getResult(Exception::class.java)

        // Get the named query from the bundle cache
        db.getNamedQuery("latest-stories-query")
    }.continueWithTask { task ->
        val query = task.getResult(Exception::class.java)!!

        // get() the query results from the cache
        query.get(Source.CACHE)
    }.addOnCompleteListener { task ->
        if (!task.isSuccessful) {
            Log.w(TAG, "Bundle loading failed", task.exception)
            return@addOnCompleteListener
        }

        // Get the QuerySnapshot from the bundle
        val storiesSnap = task.result

        // Use the results
        // ...
    }
}
C++
db->LoadBundle("bundle_name", [](const LoadBundleTaskProgress& progress) {
  switch(progress.state()) {
    case LoadBundleTaskProgress::State::kError: {
      // The bundle load has errored. Handle the error in the returned future.
      return;
    }
    case LoadBundleTaskProgress::State::kInProgress: {
      std::cout << "Bytes loaded from bundle: " << progress.bytes_loaded()
                << std::endl;
      break;
    }
    case LoadBundleTaskProgress::State::kSuccess: {
      std::cout << "Bundle load succeeeded" << std::endl;
      break;
    }
  }
}).OnCompletion([db](const Future<LoadBundleTaskProgress>& future) {
  if (future.error() != Error::kErrorOk) {
    // Handle error...
    return;
  }

  const std::string& query_name = "latest_stories_query";
  db->NamedQuery(query_name).OnCompletion([](const Future<Query>& query_future){
    if (query_future.error() != Error::kErrorOk) {
      // Handle error...
      return;
    }

    const Query* query = query_future.result();
    query->Get().OnCompletion([](const Future<QuerySnapshot> &){
      // ...
    });
  });
});

30분 이내에 빌드한 번들에서 이름이 지정된 쿼리를 로드할 때 캐시가 아닌 백엔드에서 읽는 경우, 백엔드에 저장된 항목과 일치하도록 문서를 업데이트하는 데 필요한 데이터베이스 읽기 비용만 지불하면 됩니다. 즉, 델타에 대해서만 비용을 지불합니다.

다음 단계