Swift Codable을 사용한 Cloud Firestore 데이터 매핑

Swift 4에 도입된 Swift의 Codable API를 사용하면 컴파일러를 활용하여 직렬화된 형식의 데이터를 Swift 유형으로 더 쉽게 매핑할 수 있습니다.

Codable을 사용하여 웹 API에서 앱의 데이터 모델로 또는 그 반대로 데이터를 매핑했을 수 있겠지만, 이보다 훨씬 유연합니다.

이 가이드에서는 Codable을 사용하여 Cloud Firestore에서 Swift 유형으로 또는 그 반대로 데이터를 매핑하는 방법을 알아봅니다.

Cloud Firestore에서 문서를 가져오면 앱이 키-값 쌍 사전(또는 여러 문서를 반환하는 작업 중 하나를 사용하는 경우 사전 배열)을 수신합니다.

이제 Swift에서 계속 사전을 직접 사용할 수 있습니다. 사전은 사용 사례에 적합한 높은 유연성을 제공합니다. 하지만 이 방식은 유형 안전성이 없으며, 속성 이름을 잘못 입력하거나 팀에서 지난 주에 흥미로운 새 기능을 출시할 때 추가한 새 속성을 매핑하는 것을 잊어버려서 추적하기 어려운 버그를 쉽게 발생시킬 수도 있습니다.

이전에는 많은 개발자가 사전을 Swift 유형에 매핑할 수 있는 간단한 매핑 레이어를 구현하여 이러한 단점을 해결했습니다. 그러나 대부분의 이러한 구현은 Cloud Firestore 문서와 앱 데이터 모델의 해당 유형 간의 매핑을 수동으로 지정하는 것을 기반으로 합니다.

Cloud Firestore에서 Swift의 Codable API를 지원하면 훨씬 더 수월해집니다.

  • 더 이상 매핑 코드를 직접 구현하지 않아도 됩니다.
  • 다양한 이름의 속성을 매핑하는 방법을 쉽게 정의할 수 있습니다.
  • 여러 Swift 유형이 기본 지원됩니다.
  • 또한 커스텀 유형 매핑 지원을 쉽게 추가할 수 있습니다.
  • 무엇보다도 간단한 데이터 모델의 경우 매핑 코드를 전혀 작성할 필요가 없습니다.

데이터 매핑

Cloud Firestore는 키를 값에 매핑하는 문서에 데이터를 저장합니다. 개별 문서에서 데이터를 가져오려면 필드 이름을 Any: func data() -> [String : Any]?에 매핑하는 사전을 반환하는 DocumentSnapshot.data()를 호출하면 됩니다.

즉, Swift의 아래 첨자 문법을 사용하여 개별 필드에 액세스할 수 있습니다.

import FirebaseFirestore

#warning("DO NOT MAP YOUR DOCUMENTS MANUALLY. USE CODABLE INSTEAD.")
func fetchBook(documentId: String) {
  let docRef = db.collection("books").document(documentId)

  docRef.getDocument { document, error in
    if let error = error as NSError? {
      self.errorMessage = "Error getting document: \(error.localizedDescription)"
    }
    else {
      if let document = document {
        let id = document.documentID
        let data = document.data()
        let title = data?["title"] as? String ?? ""
        let numberOfPages = data?["numberOfPages"] as? Int ?? 0
        let author = data?["author"] as? String ?? ""
        self.book = Book(id:id, title: title, numberOfPages: numberOfPages, author: author)
      }
    }
  }
}

직관적이고 구현하기 쉬운 것처럼 보일 수 있지만, 이 코드는 취약하고 유지가 어려우며 오류가 발생하기 쉽습니다.

보시다시피 여기서는 문서 필드의 데이터 유형을 가정합니다. 이 가정이 맞을 수도 있고 틀릴 수도 있습니다.

스키마가 없으므로 컬렉션에 새 문서를 쉽게 추가하고 필드에 다른 유형을 선택할 수 있습니다. 실수로 numberOfPages 필드에 문자열을 선택할 경우 이로 인해 찾기 어려운 매핑 문제가 발생할 수 있습니다. 또한 새 필드가 추가될 때마다 매핑 코드를 업데이트해야 하므로 번거롭습니다.

잊지 말아야 할 점은 Book의 각 속성의 정확한 유형을 알고 있는 Swift의 강력한 유형 시스템을 활용하지 않는 것입니다.

Codable이란 무엇일까요?

Apple 문서에 따르면 Codable은 '자신을 외부 표현으로 변환하거나 외부 표현으로부터 변환할 수 있는 유형'입니다. 실제로 Codable은 Encodable 및 Decodable 프로토콜의 유형 별칭입니다. Swift 유형을 이 프로토콜에 맞추면 컴파일러가 JSON과 같은 직렬화된 형식에서 이 유형의 인스턴스를 인코딩/디코딩하는 데 필요한 코드를 합성합니다.

도서에 관한 데이터를 저장하는 간단한 유형은 다음과 같습니다.

struct Book: Codable {
  var title: String
  var numberOfPages: Int
  var author: String
}

보시다시피 유형을 Codable에 맞추는 것이 최소 침습적입니다. 프로토콜에 적합성만 추가하면 됩니다. 다른 변경은 필요하지 않았습니다.

이를 통해 이제 도서를 JSON 객체로 쉽게 인코딩할 수 있습니다.

do {
  let book = Book(title: "The Hitchhiker's Guide to the Galaxy",
                  numberOfPages: 816,
                  author: "Douglas Adams")
  let encoder = JSONEncoder()
  let data = try encoder.encode(book)
} 
catch {
  print("Error when trying to encode book: \(error)")
}

JSON 객체를 Book 인스턴스로 디코딩하는 방법은 다음과 같습니다.

let decoder = JSONDecoder()
let data = /* fetch data from the network */
let decodedBook = try decoder.decode(Book.self, from: data)

Codable을 사용하여 Cloud Firestore 문서의
간단한 유형 간 매핑

Cloud Firestore는 간단한 문자열에서 중첩된 맵에 이르기까지 다양한 데이터 유형을 지원합니다. 대부분은 Swift의 기본 유형에 해당합니다. 좀 더 복잡한 데이터 유형을 살펴보기 전에 먼저 몇 가지 간단한 데이터 유형을 매핑해 보겠습니다.

Cloud Firestore 문서를 Swift 유형에 매핑하려면 다음 단계를 따르세요.

  1. 프로젝트에 FirebaseFirestore 프레임워크를 추가했는지 확인합니다. 이렇게 하려면 Swift Package Manager 또는 CocoaPods를 사용하면 됩니다.
  2. FirebaseFirestore를 Swift 파일로 가져옵니다.
  3. 유형을 Codable에 맞춥니다.
  4. (List 뷰에서 유형을 사용하려는 경우 선택사항) 유형에 id 속성을 추가하고 @DocumentID를 사용하여 Cloud Firestore에 이 유형을 문서 ID에 매핑하도록 지시합니다. 이에 대해서는 아래에서 자세히 설명합니다.
  5. documentReference.data(as: )를 사용하여 문서 참조를 Swift 유형에 매핑합니다.
  6. documentReference.setData(from: )를 사용하여 Swift 유형의 데이터를 Cloud Firestore 문서에 매핑합니다.
  7. (선택사항이지만 적극 권장됨) 적절한 오류 처리를 구현합니다.

Book 유형을 적절하게 업데이트해 보겠습니다.

struct Book: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
}

이 유형은 이미 코딩이 가능했으므로 id 속성을 추가하고 @DocumentID 속성 래퍼로 주석 처리하기만 하면 되었습니다.

문서를 가져오고 매핑하는 이전 코드 스니펫을 사용하면 모든 수동 매핑 코드를 한 줄로 바꿀 수 있습니다.

func fetchBook(documentId: String) {
  let docRef = db.collection("books").document(documentId)

  docRef.getDocument { document, error in
    if let error = error as NSError? {
      self.errorMessage = "Error getting document: \(error.localizedDescription)"
    }
    else {
      if let document = document {
        do {
          self.book = try document.data(as: Book.self)
        }
        catch {
          print(error)
        }
      }
    }
  }
}

getDocument(as:)를 호출할 때 문서의 유형을 지정하여 더 간결하게 작성할 수 있습니다. 이렇게 하면 매핑이 자동으로 수행되어 매핑된 문서가 포함된 Result 유형을 반환하거나 디코딩에 실패할 경우 오류를 반환합니다.

private func fetchBook(documentId: String) {
  let docRef = db.collection("books").document(documentId)
  
  docRef.getDocument(as: Book.self) { result in
    switch result {
    case .success(let book):
      // A Book value was successfully initialized from the DocumentSnapshot.
      self.book = book
      self.errorMessage = nil
    case .failure(let error):
      // A Book value could not be initialized from the DocumentSnapshot.
      self.errorMessage = "Error decoding document: \(error.localizedDescription)"
    }
  }
}

기존 문서를 업데이트하는 것은 documentReference.setData(from: )를 호출하는 것만큼 간단합니다. 기본 오류 처리를 포함하여 Book 인스턴스를 저장하는 코드는 다음과 같습니다.

func updateBook(book: Book) {
  if let id = book.id {
    let docRef = db.collection("books").document(id)
    do {
      try docRef.setData(from: book)
    }
    catch {
      print(error)
    }
  }
}

새 문서를 추가하면 Cloud Firestore에서 자동으로 새 문서 ID를 문서에 할당합니다. 앱이 오프라인 상태인 경우에도 작동합니다.

func addBook(book: Book) {
  let collectionRef = db.collection("books")
  do {
    let newDocReference = try collectionRef.addDocument(from: self.book)
    print("Book stored with new document reference: \(newDocReference)")
  }
  catch {
    print(error)
  }
}

Cloud Firestore는 간단한 데이터 유형을 매핑하는 것 외에도 여러 데이터 유형을 지원합니다. 그중 일부는 문서 내에 중첩된 객체를 만드는 데 사용할 수 있는 구조화된 유형입니다.

중첩된 커스텀 유형

문서에 매핑하려는 대부분의 속성은 도서 제목이나 저자 이름과 같은 간단한 값입니다. 하지만 더 복잡한 객체를 저장해야 하는 경우에는 어떻게 해야 할까요? 예를 들어 도서 표지에 대한 URL을 서로 다른 해상도로 저장하려고 할 수 있습니다.

Cloud Firestore에서 이를 수행하는 가장 쉬운 방법은 맵을 사용하는 것입니다.

Firestore 문서에 중첩된 커스텀 유형 저장

해당 Swift 구조체를 작성할 때 Cloud Firestore가 URL을 지원한다는 점을 활용할 수 있습니다. URL이 포함된 필드를 저장하면 문자열로 변환되며 그 반대의 경우도 마찬가지입니다.

struct CoverImages: Codable {
  var small: URL
  var medium: URL
  var large: URL
}

struct BookWithCoverImages: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var cover: CoverImages?
}

Cloud Firestore 문서에서 표지 맵에 구조체 CoverImages를 정의한 방법을 확인합니다. BookWithCoverImages의 표지 속성을 선택사항으로 표시하면 일부 문서에 표지 속성이 포함되지 않아도 괜찮습니다.

데이터를 가져오거나 업데이트하는 코드 스니펫이 없는 이유는 Cloud Firestore에서 데이터를 읽거나 쓰기 위해 코드를 조정할 필요가 없기 때문입니다. 모든 것은 최초 섹션에서 작성한 코드와 함께 작동합니다.

배열

때로는 값 컬렉션을 문서에 저장하려는 경우가 있습니다. 좋은 예시로는 책의 장르가 있습니다. 은하수를 여행하는 히치하이커를 위한 안내서는 여러 카테고리로 분류될 수 있습니다. 이 경우 'SF', '코미디'로 분류하겠습니다.

Firestore 문서에 배열 저장

Cloud Firestore에서는 값의 배열을 사용하여 이를 모델링할 수 있습니다. 이는 코딩 가능한 모든 유형(예: String, Int 등)에서 지원됩니다. 다음은 Book 모델에 장르 배열을 추가하는 방법을 보여줍니다.

public struct BookWithGenre: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var genres: [String]
}

모든 코딩 가능한 유형에 적용할 수 있으므로 커스텀 유형을 사용할 수도 있습니다. 각 도서의 태그 목록을 저장한다고 가정해 보겠습니다. 태그의 이름과 함께 다음과 같이 태그의 색상도 저장하려고 합니다.

Firestore 문서에 커스텀 유형 배열 저장

이러한 방식으로 태그를 저장하려면 Tag 구조체를 구현하여 태그를 표현하고 코딩 가능하게 만들기만 하면 됩니다.

struct Tag: Codable, Hashable {
  var title: String
  var color: String
}

이렇게 Book 문서에 Tags 배열을 저장할 수 있습니다.

struct BookWithTags: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var tags: [Tag]
}

문서 ID 매핑에 대한 간단한 설명

더 많은 유형을 매핑하기 전에 잠시 문서 ID를 매핑하는 방법을 알아보겠습니다.

이전 예시의 @DocumentID 속성 래퍼를 사용하여 Cloud Firestore 문서의 문서 ID를 Swift 유형의 id 속성에 매핑했습니다. 이것이 중요한 이유는 다음과 같습니다.

  • 이렇게 하면 사용자가 로컬에서 변경한 경우 어떤 문서를 업데이트할지 알 수 있습니다.
  • SwiftUI의 List는 요소가 삽입될 때 이동하지 못하도록 요소가 Identifiable이어야 합니다.

@DocumentID로 표시된 속성은 문서를 다시 작성할 때 Cloud Firestore의 인코더에서 인코딩되지 않는다는 점에 주목해야 합니다. 그 이유는 문서 ID가 문서 자체의 속성이 아니기 때문에 문서에 쓰면 안 됩니다.

중첩된 유형(예: 이 가이드의 앞선 예시에 있는 Book의 태그 배열)을 다루는 경우 @DocumentID 속성을 추가하지 않아도 됩니다. 중첩 속성은 Cloud Firestore 문서의 일부이며 별도의 문서를 구성하지 않습니다. 따라서 문서 ID가 필요하지 않습니다.

날짜 및 시간

Cloud Firestore에는 날짜 및 시간 처리를 위한 기본 제공 데이터 유형이 있으며 Cloud Firestore의 Codable 지원 덕분에 이를 사용하는 방법은 간단합니다.

1843년에 발명된 모든 프로그래밍 언어의 어머니인 에이다를 보여주는 문서를 살펴보겠습니다.

Firestore 문서에 날짜 저장

이 문서를 매핑하는 Swift 유형은 다음과 같습니다.

struct ProgrammingLanguage: Codable {
  @DocumentID var id: String?
  var name: String
  var year: Date
}

날짜와 시간에 관한 이 섹션은 @ServerTimestamp에 대한 이야기를 빼놓고는 다룰 수 없습니다. 이 속성 래퍼는 앱에서 타임스탬프를 처리하는 데 중요한 역할을 합니다.

모든 분산 시스템에서 개별 시스템의 시계가 항상 완전히 동기화되지 않을 수 있습니다. 이는 별 문제가 아니라고 생각할 수 있지만 증권 거래 시스템에서 시계가 조금이라도 동기화되지 않는 경우에 미치게 될 영향을 상상해 보세요. 밀리초 단위의 편차만으로도 거래 시 수백만 달러의 차이가 발생할 수 있습니다.

Cloud Firestore는 @ServerTimestamp로 표시된 속성을 다음과 같이 처리합니다. 예를 들어 addDocument()를 사용하는 경우 속성을 저장할 때 속성이 nil이면 Cloud Firestore는 데이터베이스에 쓸 때 현재 서버 타임스탬프로 필드를 채웁니다. addDocument() 또는 updateData() 호출 시 필드가 nil이 아닌 경우 Cloud Firestore는 속성 값을 변경하지 않은 상태로 둡니다. 이렇게 하면 createdAtlastUpdatedAt과 같은 필드를 쉽게 구현할 수 있습니다.

지리 좌표

앱에서 위치정보는 항상 존재합니다. 이를 저장하여 여러 흥미로운 기능을 활용할 수 있습니다. 예를 들어, 특정 위치에 도착할 때 앱이 작업에 대해 알려주도록 작업을 수행하는 위치를 저장하는 것이 유용할 수 있습니다.

Cloud Firestore에는 모든 위치의 경도와 위도를 저장할 수 있는 기본 제공 데이터 유형인 GeoPoint가 있습니다. Cloud Firestore 문서에 위치를 매핑하려면 GeoPoint 유형을 사용하면 됩니다.

struct Office: Codable {
  @DocumentID var id: String?
  var name: String
  var location: GeoPoint
}

Swift의 상응하는 유형은 CLLocationCoordinate2D이며 다음 작업을 통해 두 유형 간에 매핑할 수 있습니다.

CLLocationCoordinate2D(latitude: office.location.latitude,
                      longitude: office.location.longitude)

물리적 위치별로 문서를 쿼리하는 방법을 자세히 알아보려면 이 솔루션 가이드를 확인하세요.

Enum

enum은 Swift에서 가장 과소평가된 언어 기능 중 하나일 것입니다. 눈으로 보는 것보다 훨씬 다양한 기능을 갖추고 있습니다. enum의 일반적인 사용 사례는 무언가의 불연속 상태를 모델링하는 것입니다. 예를 들어 기사 관리를 위한 앱을 작성한다고 해보겠습니다. 기사의 상태를 추적하기 위해 enum Status를 사용할 수 있습니다.

enum Status: String, Codable {
  case draft
  case inReview
  case approved
  case published
}

Cloud Firestore는 기본적으로 enum을 지원하지 않습니다(즉, 값 집합을 적용할 수 없음). 하지만 enum을 입력할 수 있다는 점을 활용하고 코딩 가능한 유형을 선택할 수 있습니다. 이 예시에서는 String을 선택했습니다. 즉, Cloud Firestore 문서에 저장될 때 모든 enum 값이 문자열에 매핑됩니다.

Swift는 커스텀 원시 값을 지원하므로 어떤 값이 어떤 enum 사례를 참조하는지 맞춤설정할 수도 있습니다. 예를 들어 Status.inReview 사례를 '검토 중'으로 저장하기로 결정했다면 위의 enum을 다음과 같이 업데이트하면 됩니다.

enum Status: String, Codable {
  case draft
  case inReview = "in review"
  case approved
  case published
}

매핑 맞춤설정

매핑하려는 Cloud Firestore 문서의 속성 이름이 Swift 데이터 모델의 속성 이름과 일치하지 않는 경우가 있습니다. 예를 들어 동료 중 하나가 Python 개발자이고 모든 속성 이름에 snake_case를 선택하기로 했습니다.

걱정하지 마세요. Codable이 도와드립니다.

이러한 경우에는 CodingKeys를 사용할 수 있습니다. 이는 특정 속성이 매핑되는 방식을 지정하기 위해 코딩 가능한 구조체에 추가할 수 있는 enum입니다.

다음 문서를 살펴보세요.

snake_cased 속성 이름이 있는 Firestore 문서

이 문서를 String 유형의 이름 속성이 있는 구조체에 매핑하려면 CodingKeys enum을 ProgrammingLanguage 구조체에 추가하고 문서의 속성 이름을 지정해야 합니다.

struct ProgrammingLanguage: Codable {
  @DocumentID var id: String?
  var name: String
  var year: Date
  
  enum CodingKeys: String, CodingKey {
    case id
    case name = "language_name"
    case year
  }
}

기본적으로 Codable API는 Swift 유형의 속성 이름을 사용하여 매핑하려는 Cloud Firestore 문서의 속성 이름을 결정합니다. 따라서 속성 이름이 일치하는 한 코딩 가능한 유형에 CodingKeys를 추가할 필요가 없습니다. 그러나 특정 유형에 CodingKeys를 사용하고 나면 매핑할 모든 속성 이름을 추가해야 합니다.

위의 코드 스니펫에서는 SwiftUI List 뷰의 식별자로 사용할 수 있는 id 속성을 정의했습니다. CodingKeys에 지정하지 않으면 데이터를 가져올 때 매핑되지 않으므로 nil이 됩니다. 이렇게 하면 List 뷰가 첫 번째 문서로 채워집니다.

CodingKeys enum에 사례로 나열되지 않은 모든 속성은 매핑 프로세스 동안 무시됩니다. 이는 특히 속성 중 일부를 매핑 대상에서 제외하려는 경우에 편리합니다.

예를 들어 reasonWhyILoveThis 속성을 매핑 대상에서 제외하려면 CodingKeys enum에서 속성을 삭제하기만 하면 됩니다.

struct ProgrammingLanguage: Identifiable, Codable {
  @DocumentID var id: String?
  var name: String
  var year: Date
  var reasonWhyILoveThis: String = ""
  
  enum CodingKeys: String, CodingKey {
    case id
    case name = "language_name"
    case year
  }
}

가끔 Cloud Firestore 문서에 빈 속성을 다시 작성해야 할 수 있습니다. Swift에는 값의 부재를 나타내는 선택사항이라는 개념이 있으며 Cloud Firestore는 null 값도 지원합니다. 하지만 nil 값이 있는 선택적 인코딩의 기본 동작은 이를 생략하는 것입니다. @ExplicitNull을 사용하면 Swift 선택사항을 인코딩할 때 처리 방식을 어느 정도 제어할 수 있습니다. 선택적 속성을 @ExplicitNull로 표시하면 속성이 nil 값을 포함하는 경우 Cloud Firestore에 이 속성을 문서에 null 값으로 작성하도록 지시할 수 있습니다.

색상 매핑에 커스텀 인코더 및 디코더 사용

Codable을 사용한 데이터 매핑에 관해 다루는 마지막 주제로 커스텀 인코더와 디코더를 소개하겠습니다. 이 섹션에서는 네이티브 Cloud Firestore 데이터 유형에 대해 다루지 않지만 커스텀 인코더 및 디코더는 Cloud Firestore 앱에서 매우 유용합니다.

'색상을 매핑하는 방법'은 Cloud Firestore뿐만 아니라 개발자가 Swift와 JSON 간에 매핑시에 자주 묻는 질문 중 하나입니다. 많은 솔루션이 있지만 대부분은 JSON에 중점을 두며 거의 모든 솔루션이 RGB 구성요소로 구성된 중첩 사전으로 색상을 매핑합니다.

더 쉽고 간편한 해결 방법이 있을 것 같습니다. 웹 색상(또는 더 구체적으로 말하면 CSS 16진수 색상 표기법)을 사용하면 사용하기 쉽고(기본적으로 문자열만 사용) 투명도를 지원할 수 있습니다.

Swift Color를 16진수 값에 매핑하려면 Color에 Codable을 추가하는 Swift 확장 프로그램을 만들어야 합니다.

extension Color {

 init(hex: String) {
    let rgba = hex.toRGBA()

    self.init(.sRGB,
              red: Double(rgba.r),
              green: Double(rgba.g),
              blue: Double(rgba.b),
              opacity: Double(rgba.alpha))
    }

    //... (code for translating between hex and RGBA omitted for brevity)

}

extension Color: Codable {
  
  public init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    let hex = try container.decode(String.self)

    self.init(hex: hex)
  }
  
  public func encode(to encoder: Encoder) throws {
    var container = encoder.singleValueContainer()
    try container.encode(toHex)
  }

}

decoder.singleValueContainer()를 사용하면 RGBA 구성요소를 중첩하지 않고도 String을 상응하는 Color로 디코딩할 수 있습니다. 또한 이러한 값을 먼저 변환하지 않고도 앱의 웹 UI에서 사용할 수 있습니다.

이를 통해 태그 매핑 코드를 업데이트할 수 있으므로 앱의 UI 코드에서 수동으로 태그를 매핑할 필요 없이 직접 태그 색상을 처리할 수 있습니다.

struct Tag: Codable, Hashable {
  var title: String
  var color: Color
}

struct BookWithTags: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var tags: [Tag]
}

오류 처리

위의 코드 스니펫에서는 의도적으로 오류 처리를 최소한으로 두었습니다. 그러나 프로덕션 앱에서는 모든 오류를 적절하게 처리해야 합니다.

다음은 발생할 수 있는 오류 상황을 처리하는 방법을 보여주는 코드 스니펫입니다.

class MappingSimpleTypesViewModel: ObservableObject {
  @Published var book: Book = .empty
  @Published var errorMessage: String?
  
  private var db = Firestore.firestore()
  
  func fetchAndMap() {
    fetchBook(documentId: "hitchhiker")
  }
  
  func fetchAndMapNonExisting() {
    fetchBook(documentId: "does-not-exist")
  }
  
  func fetchAndTryMappingInvalidData() {
    fetchBook(documentId: "invalid-data")
  }
  
  private func fetchBook(documentId: String) {
    let docRef = db.collection("books").document(documentId)
    
    docRef.getDocument(as: Book.self) { result in
      switch result {
      case .success(let book):
        // A Book value was successfully initialized from the DocumentSnapshot.
        self.book = book
        self.errorMessage = nil
      case .failure(let error):
        // A Book value could not be initialized from the DocumentSnapshot.
        switch error {
        case DecodingError.typeMismatch(_, let context):
          self.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
        case DecodingError.valueNotFound(_, let context):
          self.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
        case DecodingError.keyNotFound(_, let context):
          self.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
        case DecodingError.dataCorrupted(let key):
          self.errorMessage = "\(error.localizedDescription): \(key)"
        default:
          self.errorMessage = "Error decoding document: \(error.localizedDescription)"
        }
      }
    }
  }
}

실시간 업데이트 오류 처리

이전 코드 스니펫은 단일 문서를 가져올 때 오류를 처리하는 방법을 보여줍니다. 데이터를 한 번 가져오는 것 외에도 Cloud Firestore는 소위 스냅샷 리스너를 사용하여 업데이트가 발생하는 즉시 앱에 업데이트를 제공하도록 지원합니다. 컬렉션(또는 쿼리)에 스냅샷 리스너를 등록할 수 있고 업데이트가 있을 때마다 Cloud Firestore에서 리스너를 호출합니다.

다음은 스냅샷 리스너를 등록하고, Codable을 사용하여 데이터를 매핑하고, 발생할 수 있는 오류를 처리하는 방법을 보여주는 코드 스니펫입니다. 컬렉션에 새 문서를 추가하는 방법도 보여줍니다. 보시다시피 매핑된 문서를 보관하는 로컬 배열을 직접 업데이트할 필요가 없습니다. 스냅샷 리스너의 코드가 이 작업을 처리하기 때문입니다.

class MappingColorsViewModel: ObservableObject {
  @Published var colorEntries = [ColorEntry]()
  @Published var newColor = ColorEntry.empty
  @Published var errorMessage: String?
  
  private var db = Firestore.firestore()
  private var listenerRegistration: ListenerRegistration?
  
  public func unsubscribe() {
    if listenerRegistration != nil {
      listenerRegistration?.remove()
      listenerRegistration = nil
    }
  }
  
  func subscribe() {
    if listenerRegistration == nil {
      listenerRegistration = db.collection("colors")
        .addSnapshotListener { [weak self] (querySnapshot, error) in
          guard let documents = querySnapshot?.documents else {
            self?.errorMessage = "No documents in 'colors' collection"
            return
          }
          
          self?.colorEntries = documents.compactMap { queryDocumentSnapshot in
            let result = Result { try queryDocumentSnapshot.data(as: ColorEntry.self) }
            
            switch result {
            case .success(let colorEntry):
              if let colorEntry = colorEntry {
                // A ColorEntry value was successfully initialized from the DocumentSnapshot.
                self?.errorMessage = nil
                return colorEntry
              }
              else {
                // A nil value was successfully initialized from the DocumentSnapshot,
                // or the DocumentSnapshot was nil.
                self?.errorMessage = "Document doesn't exist."
                return nil
              }
            case .failure(let error):
              // A ColorEntry value could not be initialized from the DocumentSnapshot.
              switch error {
              case DecodingError.typeMismatch(_, let context):
                self?.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
              case DecodingError.valueNotFound(_, let context):
                self?.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
              case DecodingError.keyNotFound(_, let context):
                self?.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
              case DecodingError.dataCorrupted(let key):
                self?.errorMessage = "\(error.localizedDescription): \(key)"
              default:
                self?.errorMessage = "Error decoding document: \(error.localizedDescription)"
              }
              return nil
            }
          }
        }
    }
  }
  
  func addColorEntry() {
    let collectionRef = db.collection("colors")
    do {
      let newDocReference = try collectionRef.addDocument(from: newColor)
      print("ColorEntry stored with new document reference: \(newDocReference)")
    }
    catch {
      print(error)
    }
  }
}

이 게시물에 사용된 모든 코드 스니펫은 이 GitHub 저장소에서 다운로드할 수 있는 샘플 애플리케이션의 일부입니다.

Codable을 사용해보세요

Swift의 Codable API는 직렬화된 형식과 애플리케이션 데이터 모델 간에 데이터를 매핑할 수 있는 강력하고 유연한 방법을 제공합니다. 이 가이드에서는 Cloud Firestore를 데이터 스토어로 사용하는 앱에서 얼마나 쉽게 사용할 수 있는지 알아보았습니다.

간단한 데이터 유형을 사용하는 기본적인 예시부터 시작하여 데이터 모델의 복잡성을 점진적으로 높이면서 Codable 및 Firebase의 구현을 활용하여 자동으로 매핑을 수행할 수 있었습니다.

Codable에 관한 자세한 내용은 다음 리소스를 참고하세요.

Google에서는 Cloud Firestore 문서 매핑을 위한 종합 가이드를 만들기 위해 최선을 다했지만 모든 내용이 포함된 것은 아니며 유형을 매핑하기 위해 다른 전략을 사용하고 계실 수도 있습니다. 아래의 의견 보내기 버튼을 사용하여 다른 유형의 Cloud Firestore 데이터를 매핑하거나 Swift로 데이터를 나타내는 데 사용하는 전략을 알려주세요.

Cloud Firestore의 Codable 지원을 사용하지 않을 이유가 없습니다.