Cloud Firestore iOS Codelab

1. 개요

목표

이 Codelab에서는 iOS에서 Swift로 Firestore를 지원하는 레스토랑 추천 앱을 빌드합니다. 다음을 수행하는 방법을 배우게 됩니다.

  1. iOS 앱에서 Firestore로 데이터 읽기 및 쓰기
  2. 실시간으로 Firestore 데이터 변경사항 수신 대기
  3. Firebase 인증 및 보안 규칙을 사용한 Firestore 데이터 보호
  4. 복잡한 Firestore 쿼리 작성

기본 요건

이 Codelab을 시작하기 전에 다음을 설치했는지 확인하세요.

  • Xcode 버전 14.0(이상)
  • CocoaPods 1.12.0(이상)

2. Firebase Console 프로젝트 만들기

프로젝트에 Firebase 추가

  1. Firebase Console로 이동합니다.
  2. 새 프로젝트 만들기를 선택하고 프로젝트 이름을 'Firestore iOS Codelab'으로 지정합니다.

3. 샘플 프로젝트 가져오기

코드 다운로드

먼저 샘플 프로젝트를 클론하고 프로젝트 디렉터리에서 pod update를 실행합니다.

git clone https://github.com/firebase/friendlyeats-ios
cd friendlyeats-ios
pod update

Xcode에서 FriendlyEats.xcworkspace를 열고 실행합니다(Cmd+R). GoogleService-Info.plist 파일이 없으므로 앱이 올바르게 컴파일되고 실행 시 즉시 비정상 종료됩니다. 다음 단계에서 이 문제를 수정합니다.

Firebase 설정

문서에 따라 새 Firestore 프로젝트를 만듭니다. 프로젝트를 만든 후 Firebase Console에서 프로젝트의 GoogleService-Info.plist 파일을 다운로드하여 Xcode 프로젝트의 루트로 드래그합니다. 프로젝트를 다시 실행하여 앱이 올바르게 구성되고 시작 시 더 이상 비정상 종료되지 않는지 확인합니다. 로그인하면 아래 예와 같은 빈 화면이 표시됩니다. 로그인할 수 없는 경우 Firebase Console의 인증에서 이메일/비밀번호 로그인 방법을 사용 설정했는지 확인하세요.

d5225270159c040b.png

4. Firestore에 데이터 쓰기

이 섹션에서는 앱 UI를 채울 수 있도록 Firestore에 일부 데이터를 작성합니다. 이 작업은 Firebase Console을 통해 수동으로 수행할 수 있지만, 기본 Firestore 작성을 보여주기 위해 앱 자체에서 이 작업을 수행합니다.

앱의 기본 모델 객체는 레스토랑입니다. Firestore 데이터는 문서, 컬렉션, 하위 컬렉션으로 분할됩니다. 각 식당을 restaurants라는 최상위 컬렉션에 문서로 저장합니다. Firestore 데이터 모델에 대해 자세히 알아보려면 문서의 문서 및 컬렉션에 대해 읽어보세요.

Firestore에 데이터를 추가하려면 먼저 식당 컬렉션에 대한 참조를 가져와야 합니다. RestaurantsTableViewController.didTapPopulateButton(_:) 메서드의 내부 for 루프에 다음을 추가합니다.

let collection = Firestore.firestore().collection("restaurants")

이제 컬렉션 참조가 있으므로 데이터를 작성할 수 있습니다. 추가한 마지막 코드 줄 바로 뒤에 다음을 추가합니다.

let collection = Firestore.firestore().collection("restaurants")

// ====== ADD THIS ======
let restaurant = Restaurant(
  name: name,
  category: category,
  city: city,
  price: price,
  ratingCount: 0,
  averageRating: 0
)

collection.addDocument(data: restaurant.dictionary)

위의 코드는 레스토랑 컬렉션에 새 문서를 추가합니다. 문서 데이터는 Restaurant 구조체에서 가져온 사전에서 가져옵니다.

이제 거의 완료되었습니다. Firestore에 문서를 작성하기 전에 Firestore의 보안 규칙을 열고 데이터베이스의 어떤 부분을 어떤 사용자가 쓸 수 있는지 설명해야 합니다. 현재는 인증된 사용자만 전체 데이터베이스를 읽고 쓸 수 있습니다. 이는 프로덕션 앱에는 약간 너무 관대한 설정이지만 앱 빌드 프로세스 중에는 실험 중에 인증 문제가 계속 발생하지 않도록 충분히 완화된 설정이 필요합니다. 이 Codelab의 마지막 부분에서는 보안 규칙을 강화하고 의도하지 않은 읽기 및 쓰기의 가능성을 제한하는 방법을 설명합니다.

Firebase Console의 규칙 탭에서 다음 규칙을 추가한 다음 게시를 클릭합니다.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      //
      // WARNING: These rules are insecure! We will replace them with
      // more secure rules later in the codelab
      //
      allow read, write: if request.auth != null;
    }
  }
}

보안 규칙에 대해서는 나중에 자세히 설명하지만 급한 경우 보안 규칙 문서를 참고하세요.

앱을 실행하고 로그인합니다. 그런 다음 '채우기'를 탭합니다. 버튼을 클릭합니다. 이 버튼을 사용하면 식당 문서 일괄이 생성됩니다. 하지만 앱에서는 아직 이 기능을 볼 수 없습니다.

그런 다음 Firebase Console에서 Firestore 데이터 탭으로 이동합니다. 이제 레스토랑 모음에 새 항목이 표시됩니다.

Screen Shot 2017-07-06 at 12.45.38 PM.png

축하합니다. 방금 iOS 앱에서 Firestore에 데이터를 작성했습니다. 다음 섹션에서는 Firestore에서 데이터를 검색하여 앱에 표시하는 방법을 알아봅니다.

5. Firestore의 데이터 표시

이 섹션에서는 Firestore에서 데이터를 검색하여 앱에 표시하는 방법을 알아봅니다. 두 가지 주요 단계는 쿼리 생성과 스냅샷 리스너 추가입니다. 이 리스너는 쿼리와 일치하는 기존의 모든 데이터에 관한 알림을 받으며 실시간으로 업데이트를 수신합니다.

먼저 필터링되지 않은 기본 레스토랑 목록을 제공할 쿼리를 구성해 보겠습니다. RestaurantsTableViewController.baseQuery()의 구현을 살펴보세요.

return Firestore.firestore().collection("restaurants").limit(to: 50)

이 쿼리는 'restaurants'라는 최상위 컬렉션에서 최대 50개의 레스토랑을 검색합니다. 이제 쿼리가 생성되었으므로 스냅샷 리스너를 연결하여 Firestore에서 앱으로 데이터를 로드해야 합니다. stopObserving() 호출 직후 RestaurantsTableViewController.observeQuery() 메서드에 다음 코드를 추가합니다.

listener = query.addSnapshotListener { [unowned self] (snapshot, error) in
  guard let snapshot = snapshot else {
    print("Error fetching snapshot results: \(error!)")
    return
  }
  let models = snapshot.documents.map { (document) -> Restaurant in
    if let model = Restaurant(dictionary: document.data()) {
      return model
    } else {
      // Don't use fatalError here in a real app.
      fatalError("Unable to initialize type \(Restaurant.self) with dictionary \(document.data())")
    }
  }
  self.restaurants = models
  self.documents = snapshot.documents

  if self.documents.count > 0 {
    self.tableView.backgroundView = nil
  } else {
    self.tableView.backgroundView = self.backgroundView
  }

  self.tableView.reloadData()
}

위의 코드는 Firestore에서 컬렉션을 다운로드하여 로컬 배열에 저장합니다. addSnapshotListener(_:) 호출은 서버에서 데이터가 변경될 때마다 뷰 컨트롤러를 업데이트하는 스냅샷 리스너를 쿼리에 추가합니다. 업데이트가 자동으로 이루어지므로 변경사항을 수동으로 푸시할 필요가 없습니다. 이 스냅샷 리스너는 서버 측 변경의 결과로 언제든지 호출될 수 있으므로 앱이 변경 사항을 처리할 수 있어야 합니다.

사전을 구조체에 매핑한 후(Restaurant.swift 참고) 데이터를 표시하는 것은 몇 가지 뷰 속성을 할당하는 것뿐입니다. RestaurantsTableViewController.swiftRestaurantTableViewCell.populate(restaurant:)에 다음 줄을 추가합니다.

nameLabel.text = restaurant.name
cityLabel.text = restaurant.city
categoryLabel.text = restaurant.category
starsView.rating = Int(restaurant.averageRating.rounded())
priceLabel.text = priceString(from: restaurant.price)

이 채우기 메서드는 표 보기 데이터 소스의 tableView(_:cellForRowAtIndexPath:) 메서드에서 호출됩니다. 이 메서드는 이전의 값 유형 컬렉션을 개별 표 보기 셀에 매핑하는 작업을 처리합니다.

앱을 다시 실행하고 이전에 콘솔에서 보았던 레스토랑이 이제 시뮬레이터 또는 기기에 표시되는지 확인합니다. 이 섹션을 성공적으로 완료했다면 이제 앱이 Cloud Firestore로 데이터를 읽고 쓰고 있는 것입니다.

391c0259bf05ac25.png

6. 데이터 정렬 및 필터링

현재 앱은 레스토랑 목록을 표시하지만 사용자가 필요에 따라 필터링할 수 있는 방법은 없습니다. 이 섹션에서는 Firestore의 고급 쿼리를 사용하여 필터링을 사용 설정합니다.

다음은 모든 Dim Sum 레스토랑을 가져오는 간단한 쿼리의 예입니다.

let filteredQuery = query.whereField("category", isEqualTo: "Dim Sum")

이름에서 알 수 있듯이 whereField(_:isEqualTo:) 메서드에 의해 쿼리는 필드가 설정된 제한사항을 충족하는 컬렉션의 멤버만 다운로드합니다. 이 경우에는 category"Dim Sum"인 레스토랑만 다운로드합니다.

이 앱에서 사용자는 여러 필터를 연결하여 '샌프란시스코의 피자 가게'와 같은 특정 쿼리를 만들 수 있습니다. 또는 '인기도순으로 로스앤젤레스의 해산물 요리'를 검색할 수 있습니다.

RestaurantsTableViewController.swift을 열고 query(withCategory:city:price:sortBy:)의 중앙에 다음 코드 블록을 추가합니다.

if let category = category, !category.isEmpty {
  filtered = filtered.whereField("category", isEqualTo: category)
}

if let city = city, !city.isEmpty {
  filtered = filtered.whereField("city", isEqualTo: city)
}

if let price = price {
  filtered = filtered.whereField("price", isEqualTo: price)
}

if let sortBy = sortBy, !sortBy.isEmpty {
  filtered = filtered.order(by: sortBy)
}

위의 스니펫은 여러 개의 whereFieldorder 절을 추가하여 사용자 입력을 기반으로 단일 복합 쿼리를 빌드합니다. 이제 쿼리는 사용자의 요구사항과 일치하는 레스토랑만 반환합니다.

프로젝트를 실행하고 가격, 도시, 카테고리별로 필터링할 수 있는지 확인합니다. 카테고리와 도시 이름을 정확하게 입력해야 합니다. 테스트하는 동안 로그에 다음과 같은 오류가 표시될 수 있습니다.

Error fetching snapshot results: Error Domain=io.grpc Code=9 
"The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=..." 
UserInfo={NSLocalizedDescription=The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=...}

이는 Firestore에서 대부분의 복합 쿼리에 색인이 필요하기 때문입니다. 쿼리의 색인을 요청하여 규모에 맞게 Firestore의 속도를 높일 수 있습니다. 오류 메시지에서 링크를 열면 Firebase Console에서 올바른 매개변수가 채워진 색인 생성 UI가 자동으로 열립니다. Firestore의 색인에 관해 자세히 알아보려면 문서를 참고하세요.

7. 트랜잭션으로 데이터 쓰기

이 섹션에서는 사용자가 레스토랑에 리뷰를 제출할 수 있는 기능을 추가합니다. 지금까지 모든 쓰기는 원자적이고 비교적 단순했습니다. 작업에 오류가 있는 경우 사용자에게 작업을 다시 시도하라는 메시지를 표시하거나 자동으로 다시 시도합니다.

식당에 평점을 추가하려면 여러 읽기 및 쓰기를 조정해야 합니다. 먼저 리뷰를 제출한 다음 식당의 평점 수와 평균 평점을 업데이트해야 합니다. 둘 중 하나가 실패하고 다른 하나는 실패하지 않으면 데이터베이스의 한 부분에 있는 데이터가 다른 부분의 데이터와 일치하지 않는 일관되지 않은 상태가 됩니다.

다행히 Firestore는 단일 원자적 연산으로 여러 읽기 및 쓰기를 실행할 수 있는 트랜잭션 기능을 제공하므로 데이터의 일관성을 유지할 수 있습니다.

RestaurantDetailViewController.reviewController(_:didSubmitFormWithReview:)의 모든 let 선언 아래에 다음 코드를 추가합니다.

let firestore = Firestore.firestore()
firestore.runTransaction({ (transaction, errorPointer) -> Any? in

  // Read data from Firestore inside the transaction, so we don't accidentally
  // update using stale client data. Error if we're unable to read here.
  let restaurantSnapshot: DocumentSnapshot
  do {
    try restaurantSnapshot = transaction.getDocument(reference)
  } catch let error as NSError {
    errorPointer?.pointee = error
    return nil
  }

  // Error if the restaurant data in Firestore has somehow changed or is malformed.
  guard let data = restaurantSnapshot.data(),
        let restaurant = Restaurant(dictionary: data) else {

    let error = NSError(domain: "FireEatsErrorDomain", code: 0, userInfo: [
      NSLocalizedDescriptionKey: "Unable to write to restaurant at Firestore path: \(reference.path)"
    ])
    errorPointer?.pointee = error
    return nil
  }

  // Update the restaurant's rating and rating count and post the new review at the 
  // same time.
  let newAverage = (Float(restaurant.ratingCount) * restaurant.averageRating + Float(review.rating))
      / Float(restaurant.ratingCount + 1)

  transaction.setData(review.dictionary, forDocument: newReviewReference)
  transaction.updateData([
    "numRatings": restaurant.ratingCount + 1,
    "avgRating": newAverage
  ], forDocument: reference)
  return nil
}) { (object, error) in
  if let error = error {
    print(error)
  } else {
    // Pop the review controller on success
    if self.navigationController?.topViewController?.isKind(of: NewReviewViewController.self) ?? false {
      self.navigationController?.popViewController(animated: true)
    }
  }
}

업데이트 블록 내에서 트랜잭션 객체를 사용하여 수행하는 모든 작업은 Firestore에서 단일 원자 업데이트로 처리됩니다. 서버에서 업데이트가 실패하면 Firestore가 자동으로 몇 번 다시 시도합니다. 즉, 오류 조건은 기기가 완전히 오프라인 상태이거나 사용자가 쓰려고 하는 경로에 쓰기 권한이 없는 경우와 같이 반복적으로 발생하는 단일 오류일 가능성이 높습니다.

8. 보안 규칙

앱 사용자가 데이터베이스의 모든 데이터를 읽고 쓸 수 없어야 합니다. 예를 들어 모든 사용자가 음식점 평점을 볼 수 있어야 하지만 인증된 사용자만 평점을 게시할 수 있어야 합니다. 클라이언트에서 좋은 코드를 작성하는 것만으로는 충분하지 않습니다. 백엔드에서 데이터 보안 모델을 지정하여 완전한 보안을 유지해야 합니다. 이 섹션에서는 Firebase 보안 규칙을 사용하여 데이터를 보호하는 방법을 알아봅니다.

먼저 Codelab 시작 부분에서 작성한 보안 규칙을 자세히 살펴보겠습니다. Firebase Console을 열고 데이터베이스 > Firestore 탭의 규칙으로 이동합니다.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

위 규칙의 request 변수는 모든 규칙에서 사용할 수 있는 전역 변수이며, 추가된 조건문은 사용자가 어떤 작업도 하기 전에 요청이 인증되도록 합니다. 이렇게 하면 인증되지 않은 사용자가 Firestore API를 사용하여 데이터를 무단으로 변경하는 것을 방지할 수 있습니다. 이는 좋은 시작이지만 Firestore 규칙을 사용하여 훨씬 더 강력한 작업을 할 수 있습니다.

리뷰의 사용자 ID가 인증된 사용자의 ID와 일치해야 하도록 리뷰 쓰기를 제한해 보겠습니다. 이렇게 하면 사용자가 서로 명의를 도용하여 허위 리뷰를 남길 수 없습니다. 보안 규칙을 다음과 같이 바꿉니다.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{any}/ratings/{rating} {
      // Users can only write ratings with their user ID
      allow read;
      allow write: if request.auth != null 
                   && request.auth.uid == request.resource.data.userId;
    }
  
    match /restaurants/{any} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

첫 번째 match 문은 restaurants 컬렉션에 속한 문서의 ratings라는 하위 컬렉션과 일치합니다. 그러면 allow write 조건부에서는 리뷰의 사용자 ID가 사용자의 ID와 일치하지 않을 경우 리뷰가 제출되지 않도록 합니다. 두 번째 일치 문을 사용하면 인증된 모든 사용자가 레스토랑을 데이터베이스에서 읽고 쓸 수 있습니다.

이는 Google 리뷰에서 매우 효과적인 방법입니다. 보안 규칙을 사용하여 사용자가 자신의 리뷰만 작성할 수 있다는 암묵적인 보장을 앱에 명시한 바 있습니다. 리뷰에 수정 또는 삭제 기능을 추가하면 동일한 규칙에 따라 사용자가 다른 사용자의 리뷰를 수정하거나 삭제할 수도 없습니다. 하지만 Firestore 규칙을 더 세부적으로 사용하여 전체 문서 자체가 아닌 문서 내 개별 필드의 쓰기를 제한할 수도 있습니다. 이를 통해 사용자가 평점, 평균 평점, 음식점 평점 수만 업데이트하도록 허용하여 악의적인 사용자가 레스토랑 이름이나 위치를 변경할 가능성을 제거할 수 있습니다.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{restaurant} {
      match /ratings/{rating} {
        allow read: if request.auth != null;
        allow write: if request.auth != null 
                     && request.auth.uid == request.resource.data.userId;
      }
    
      allow read: if request.auth != null;
      allow create: if request.auth != null;
      allow update: if request.auth != null
                    && request.resource.data.name == resource.data.name
                    && request.resource.data.city == resource.data.city
                    && request.resource.data.price == resource.data.price
                    && request.resource.data.category == resource.data.category;
    }
  }
}

여기서는 쓰기 권한을 create 및 update로 분할했으므로 어떤 작업을 허용해야 하는지 더 구체적으로 지정할 수 있습니다. 모든 사용자는 코드 랩 시작 시 만든 채우기 버튼의 기능을 유지하면서 레스토랑을 데이터베이스에 쓸 수 있지만, 레스토랑이 작성되면 이름, 위치, 가격, 카테고리는 변경할 수 없습니다. 더 구체적으로 말하면, 마지막 규칙은 식당 업데이트 작업에서 데이터베이스에 이미 존재하는 필드의 이름, 도시, 가격 및 카테고리를 동일하게 유지해야 합니다.

보안 규칙으로 할 수 있는 작업을 자세히 알아보려면 문서를 참고하세요.

9. 결론

이 Codelab에서는 Firestore로 기본 및 고급 읽기 및 쓰기를 실행하는 방법과 보안 규칙으로 데이터 액세스를 보호하는 방법을 알아봤습니다. codelab-complete 브랜치에서 전체 솔루션을 확인할 수 있습니다.

Firestore에 관해 자세히 알아보려면 다음 리소스를 참고하세요.