Cloud Firestore iOS Codelab

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

1. 개요

목표

이 코드랩에서는 iOS의 Firestore 지원 레스토랑 추천 앱을 Swift로 빌드합니다. 다음 방법을 배우게 됩니다.

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

전제 조건

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

  • Xcode 버전 13.0(또는 그 이상)
  • CocoaPods 1.11.0(또는 그 이상)

2. Firebase 콘솔 프로젝트 생성

프로젝트에 Firebase 추가

  1. Firebase 콘솔 로 이동합니다.
  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 콘솔 에서 프로젝트의 GoogleService-Info.plist 파일을 다운로드하고 Xcode 프로젝트의 루트로 드래그합니다. 프로젝트를 다시 실행하여 앱이 올바르게 구성되고 시작 시 더 이상 충돌하지 않는지 확인합니다. 로그인 후 아래 예시와 같은 빈 화면이 나타나야 합니다. 로그인할 수 없는 경우 인증 아래 Firebase 콘솔의 이메일/비밀번호 로그인 방법을 활성화했는지 확인하세요.

d5225270159c040b.png

4. Firestore에 데이터 쓰기

이 섹션에서는 앱 UI를 채울 수 있도록 Firestore에 일부 데이터를 작성합니다. 이는 Firebase 콘솔 을 통해 수동으로 수행할 수 있지만 기본 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의 보안 규칙을 열고 데이터베이스의 어떤 부분을 어떤 사용자가 쓸 수 있는지 설명해야 합니다. 지금은 인증된 사용자만 전체 데이터베이스를 읽고 쓸 수 있도록 허용합니다. 이것은 프로덕션 앱에 대해 약간 너무 관대하지만 앱 빌드 프로세스 중에 실험하는 동안 인증 문제가 지속적으로 발생하지 않도록 충분히 완화된 것을 원합니다. 이 코드랩의 끝에서 보안 규칙을 강화하고 의도하지 않은 읽기 및 쓰기 가능성을 제한하는 방법에 대해 설명합니다.

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

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 콘솔에서 Firestore 데이터 탭 으로 이동합니다. 이제 레스토랑 컬렉션에 새 항목이 표시됩니다.

스크린샷 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 참조), 데이터를 표시하는 것은 몇 가지 보기 속성을 할당하는 문제입니다. RestaurantTableViewController.swift 의 RestaurantsTableViewController.swift RestaurantTableViewCell.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의 고급 쿼리를 사용하여 필터링을 활성화합니다.

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

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/testapp-5d356/database/firestore/indexes?create_index=..." 
UserInfo={NSLocalizedDescription=The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_index=...}

Firestore에는 대부분의 복합 쿼리에 대한 색인이 필요하기 때문입니다. 쿼리에 색인을 요구하면 Firestore를 규모에 맞게 빠르게 유지할 수 있습니다. 오류 메시지의 링크를 열면 올바른 매개변수가 채워진 Firebase 콘솔의 색인 생성 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 보안 규칙을 사용하여 데이터를 보호하는 방법을 알아보겠습니다.

먼저 코드랩을 시작할 때 작성한 보안 규칙을 자세히 살펴보겠습니다. Firebase 콘솔을 열고 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;
    }
  }
}

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

이는 보안 규칙을 사용하여 이전에 앱에 작성한 묵시적 보증을 명시적으로 명시하기 때문에 리뷰에 매우 적합합니다. 사용자는 자신의 리뷰만 작성할 수 있습니다. 리뷰에 대한 편집 또는 삭제 기능을 추가하는 경우 이 똑같은 규칙 집합으로 인해 사용자가 다른 사용자의 리뷰도 수정하거나 삭제할 수 없습니다. 그러나 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;
    }
  }
}

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

보안 규칙으로 수행할 수 있는 작업에 대해 자세히 알아보려면 설명서 를 참조하세요.

9. 결론

이 코드랩에서는 Firestore를 사용한 기본 및 고급 읽기 및 쓰기 방법과 보안 규칙으로 데이터 액세스를 보호하는 방법을 배웠습니다. codelab-complete 분기 에서 전체 솔루션을 찾을 수 있습니다.

Firestore에 대해 자세히 알아보려면 다음 리소스를 방문하세요.