콘솔로 이동

집계 쿼리

Cloud Firestore의 고급 쿼리를 사용하면 대규모 컬렉션에서 빠르게 문서를 찾을 수 있습니다. 컬렉션의 속성을 전반적으로 파악하려면 컬렉션에 대한 집계가 필요합니다.

Cloud Firestore는 네이티브 집계 쿼리를 지원하지 않습니다. 그러나 클라이언트측 트랜잭션 또는 Cloud 함수를 사용하여 데이터에 대한 집계 정보를 쉽게 유지 관리할 수 있습니다.

계속하기 전에 쿼리 및 Cloud Firestore 데이터 모델을 꼭 읽어보시기 바랍니다.

솔루션: 클라이언트측 트랜잭션

맛집 정보를 제공하는 지역 정보 추천 앱을 가정해 보겠습니다. 다음 쿼리는 특정 레스토랑의 평점을 모두 검색합니다.

db.collection("restaurants")
  .doc("arinell-pizza")
  .collection("ratings")
  .get()

Swift

db.collection("restaurants")
    .document("arinell-pizza")
    .collection("ratings")
    .getDocuments() { (querySnapshot, err) in

        // ...

}

Objective-C

FIRQuery *query = [[[self.db collectionWithPath:@"restaurants"]
    documentWithPath:@"arinell-pizza"] collectionWithPath:@"ratings"];
[query getDocumentsWithCompletion:^(FIRQuerySnapshot * _Nullable snapshot,
                                    NSError * _Nullable error) {
  // ...
}];

자바
Android

db.collection("restaurants")
        .document("arinell-pizza")
        .collection("ratings")
        .get();

Kotlin
Android

db.collection("restaurants")
        .document("arinell-pizza")
        .collection("ratings")
        .get()

모든 평점을 가져와서 집계 정보를 계산하는 대신 이 정보를 레스토랑 문서 자체에 저장할 수 있습니다.

var arinellDoc = {
  name: 'Arinell Pizza',
  avgRating: 4.65,
  numRatings: 683
}

Swift

struct Restaurant {

    let name: String
    let avgRating: Float
    let numRatings: Int

    init(name: String, avgRating: Float, numRatings: Int) {
        self.name = name
        self.avgRating = avgRating
        self.numRatings = numRatings
    }

}

let arinell = Restaurant(name: "Arinell Pizza", avgRating: 4.65, numRatings: 683)

Objective-C

@interface FIRRestaurant : NSObject

@property (nonatomic, readonly) NSString *name;
@property (nonatomic, readonly) float averageRating;
@property (nonatomic, readonly) NSInteger ratingCount;

- (instancetype)initWithName:(NSString *)name
               averageRating:(float)averageRating
                 ratingCount:(NSInteger)ratingCount;

@end

@implementation FIRRestaurant

- (instancetype)initWithName:(NSString *)name
               averageRating:(float)averageRating
                 ratingCount:(NSInteger)ratingCount {
  self = [super init];
  if (self != nil) {
    _name = name;
    _averageRating = averageRating;
    _ratingCount = ratingCount;
  }
  return self;
}

@end

자바
Android

public class Restaurant {
    String name;
    double avgRating;
    int numRatings;

    public Restaurant(String name, double avgRating, int numRatings) {
        this.name = name;
        this.avgRating = avgRating;
        this.numRatings = numRatings;
    }
}
Restaurant arinell = new Restaurant("Arinell Pizza", 4.65, 683);

Kotlin
Android

data class Restaurant(
    internal var name: String,
    internal var avgRating: Double,
    internal var numRatings: Int
) {

    // No-arg constructor required for use with "toObject"
    constructor() : this("", 0.0, 0)
}
val arinell = Restaurant("Arinell Pizza", 4.65, 683)

이러한 집계를 일관성 있게 유지하려면 하위 컬렉션에 새 평점이 추가될 때마다 업데이트해야 합니다. 일관성을 유지하는 방법 중 하나는 단일 트랜잭션에서 추가와 업데이트를 수행하는 것입니다.

function addRating(restaurantRef, rating) {
    // Create a reference for a new rating, for use inside the transaction
    var ratingRef = restaurantRef.collection('ratings').doc();

    // In a transaction, add the new rating and update the aggregate totals
    return db.runTransaction(transaction => {
        return transaction.get(restaurantRef).then(res => {
            if (!res.exists) {
                throw "Document does not exist!";
            }

            // Compute new number of ratings
            var newNumRatings = res.data().numRatings + 1;

            // Compute new average rating
            var oldRatingTotal = res.data().avgRating * res.data().numRatings;
            var newAvgRating = (oldRatingTotal + rating) / newNumRatings;

            // Commit to Firestore
            transaction.update(restaurantRef, {
                numRatings: newNumRatings,
                avgRating: newAvgRating
            });
            transaction.set(ratingRef, { rating: rating });
        })
    });
}

Swift

func addRatingTransaction(restaurantRef: DocumentReference, rating: Float) {
    let ratingRef: DocumentReference = restaurantRef.collection("ratings").document()

    db.runTransaction({ (transaction, errorPointer) -> Any? in
        do {
            let restaurantDocument = try transaction.getDocument(restaurantRef).data()
            guard var restaurantData = restaurantDocument else { return nil }

            // Compute new number of ratings
            let numRatings = restaurantData["numRatings"] as! Int
            let newNumRatings = numRatings + 1

            // Compute new average rating
            let avgRating = restaurantData["avgRating"] as! Float
            let oldRatingTotal = avgRating * Float(numRatings)
            let newAvgRating = (oldRatingTotal + rating) / Float(newNumRatings)

            // Set new restaurant info
            restaurantData["numRatings"] = newNumRatings
            restaurantData["avgRating"] = newAvgRating

            // Commit to Firestore
            transaction.setData(restaurantData, forDocument: restaurantRef)
            transaction.setData(["rating": rating], forDocument: ratingRef)
        } catch {
            // Error getting restaurant data
            // ...
        }

        return nil
    }) { (object, err) in
        // ...
    }
}

Objective-C

- (void)addRatingTransactionWithRestaurantReference:(FIRDocumentReference *)restaurant
                                             rating:(float)rating {
  FIRDocumentReference *ratingReference =
      [[restaurant collectionWithPath:@"ratings"] documentWithAutoID];

  [self.db runTransactionWithBlock:^id (FIRTransaction *transaction,
                                        NSError **errorPointer) {
    FIRDocumentSnapshot *restaurantSnapshot =
        [transaction getDocument:restaurant error:errorPointer];

    if (restaurantSnapshot == nil) {
      return nil;
    }

    NSMutableDictionary *restaurantData = [restaurantSnapshot.data mutableCopy];
    if (restaurantData == nil) {
      return nil;
    }

    // Compute new number of ratings
    NSInteger ratingCount = [restaurantData[@"numRatings"] integerValue];
    NSInteger newRatingCount = ratingCount + 1;

    // Compute new average rating
    float averageRating = [restaurantData[@"avgRating"] floatValue];
    float newAverageRating = (averageRating * ratingCount + rating) / newRatingCount;

    // Set new restaurant info

    restaurantData[@"numRatings"] = @(newRatingCount);
    restaurantData[@"avgRating"] = @(newAverageRating);

    // Commit to Firestore
    [transaction setData:restaurantData forDocument:restaurant];
    [transaction setData:@{@"rating": @(rating)} forDocument:ratingReference];
    return nil;
  } completion:^(id  _Nullable result, NSError * _Nullable error) {
    // ...
  }];
}

자바
Android

private Task<Void> addRating(final DocumentReference restaurantRef, final float rating) {
    // Create reference for new rating, for use inside the transaction
    final DocumentReference ratingRef = restaurantRef.collection("ratings").document();

    // In a transaction, add the new rating and update the aggregate totals
    return db.runTransaction(new Transaction.Function<Void>() {
        @Override
        public Void apply(Transaction transaction) throws FirebaseFirestoreException {
            Restaurant restaurant = transaction.get(restaurantRef).toObject(Restaurant.class);

            // Compute new number of ratings
            int newNumRatings = restaurant.numRatings + 1;

            // Compute new average rating
            double oldRatingTotal = restaurant.avgRating * restaurant.numRatings;
            double newAvgRating = (oldRatingTotal + rating) / newNumRatings;

            // Set new restaurant info
            restaurant.numRatings = newNumRatings;
            restaurant.avgRating = newAvgRating;

            // Update restaurant
            transaction.set(restaurantRef, restaurant);

            // Update rating
            Map<String, Object> data = new HashMap<>();
            data.put("rating", rating);
            transaction.set(ratingRef, data, SetOptions.merge());

            return null;
        }
    });
}

Kotlin
Android

private fun addRating(restaurantRef: DocumentReference, rating: Float): Task<Void> {
    // Create reference for new rating, for use inside the transaction
    val ratingRef = restaurantRef.collection("ratings").document()

    // In a transaction, add the new rating and update the aggregate totals
    return db.runTransaction { transaction ->
        val restaurant = transaction.get(restaurantRef).toObject(Restaurant::class.java)

        // Compute new number of ratings
        val newNumRatings = restaurant!!.numRatings + 1

        // Compute new average rating
        val oldRatingTotal = restaurant.avgRating * restaurant.numRatings
        val newAvgRating = (oldRatingTotal + rating) / newNumRatings

        // Set new restaurant info
        restaurant.numRatings = newNumRatings
        restaurant.avgRating = newAvgRating

        // Update restaurant
        transaction.set(restaurantRef, restaurant)

        // Update rating
        val data = hashMapOf<String, Any>()
        data["rating"] = rating
        transaction.set(ratingRef, data, SetOptions.merge())

        null
    }
}

트랜잭션을 사용하면 집계 데이터와 내부 컬렉션의 일관성이 유지됩니다. Cloud Firestore의 트랜잭션에 관한 자세한 내용은 트랜잭션 및 일괄 쓰기를 참조하세요.

제한사항

위 솔루션은 Cloud Firestore 클라이언트 라이브러리를 사용한 데이터 집계를 보여주지만 다음과 같은 제한사항에 유의해야 합니다.

  • 보안 - 클라이언트측 트랜잭션을 사용하려면 데이터베이스의 집계 데이터를 업데이트할 권한을 클라이언트에 부여해야 합니다. 고급 보안 규칙을 작성하여 이 방식의 위험을 줄일 수는 있지만, 상황에 따라 이 방법이 적절하지 않을 수도 있습니다.
  • 오프라인 지원 - 사용자의 기기가 오프라인 상태이면 클라이언트측 트랜잭션이 실패합니다. 따라서 앱에서 이러한 경우를 처리하고 적절한 시간에 다시 시도해야 합니다.
  • 성능 - 트랜잭션에 읽기, 쓰기, 업데이트 작업이 여러 개 포함되어 있으면 Cloud Firestore 백엔드에 여러 번 요청해야 할 수 있습니다. 휴대기기에서는 상당한 시간이 걸릴 수 있습니다.

솔루션: Cloud 함수

애플리케이션에 클라이언트측 트랜잭션이 적합하지 않으면 Cloud 함수를 사용하여 레스토랑 평점이 새로 추가될 때마다 집계 정보를 업데이트할 수 있습니다.

Node.js

exports.aggregateRatings = functions.firestore
    .document('restaurants/{restId}/ratings/{ratingId}')
    .onWrite((change, context) => {
      // Get value of the newly added rating
      var ratingVal = change.after.data().rating;

      // Get a reference to the restaurant
      var restRef = db.collection('restaurants').doc(context.params.restId);

      // Update aggregations in a transaction
      return db.runTransaction(transaction => {
        return transaction.get(restRef).then(restDoc => {
          // Compute new number of ratings
          var newNumRatings = restDoc.data('numRatings') + 1;

          // Compute new average rating
          var oldRatingTotal = restDoc.data('avgRating') * restDoc.data('numRatings');
          var newAvgRating = (oldRatingTotal + ratingVal) / newNumRatings;

          // Update restaurant info
          return transaction.update(restRef, {
            avgRating: newAvgRating,
            numRatings: newNumRatings
          });
        });
      });
    });

이 솔루션은 클라이언트에서 호스팅 함수로 작업 부담을 옮기므로 모바일 앱에서 트랜잭션이 완료되기를 기다리지 않고 평점을 추가할 수 있습니다. Cloud 함수에서 실행되는 코드에는 보안 규칙이 적용되지 않으므로 집계 데이터에 대한 쓰기 권한을 클라이언트에 부여할 필요가 없습니다.

제한사항

집계를 위해 Cloud 함수를 사용하면 클라이언트측 트랜잭션에 따르는 몇 가지 문제를 피할 수 있지만, 여기에는 또 다른 제한사항이 있습니다.

  • 비용 - 평점을 추가할 때마다 Cloud 함수가 호출되므로 비용이 증가할 수 있습니다. 자세한 내용은 Cloud 함수 가격 책정 페이지를 참조하세요.
  • 지연 시간 - 집계 작업을 Cloud 함수로 옮기면 Cloud 함수 실행이 완료되고 클라이언트에 새 데이터가 전송되기 전까지는 앱에서 업데이트된 데이터를 확인할 수 없습니다. Cloud 함수의 속도에 따라 로컬에서 트랜잭션을 실행하는 것보다 시간이 오래 걸릴 수 있습니다.