Swift(스위프트) + Firebase: 사용자 정의 struct(구조체)를 사용한 Firestore CRUD 기초

 

실시간 데이터베이스 vs Cloud Firstore 비교

Firebase에서는 두 가지 형태의 데이터베이스를 제공합니다 이 글에서는 Cloud Firestore에 대해 알아봅니다.

 

Firestore에서 자료를 저장하는 일반적인 형태

컬렉션 – 문서 – 필드

 

문서 내부에 컬랙션 생성

 

  • 컬렉션 – 문서들을 모아 저장하는 곳입니다. 관계형 DB에서 테이블과 비슷한 개념이라고 볼 수 있습니다. (완전히 같지는 않습니다.)
  • 문서 – 문서는 고유의 아이디를 가지며, 다양한 필드 정보를 담을 수 있습니다. 관계형 DB에서 레코드와 비슷한 개념입니다.
  • 문서 안에 또 다른 컬렉션을 추가할 수 있습니다.

 

Xcode 프로젝트에 Firebase 라이브러리 설치하기 (CocoaPods)

# add the Firebase pod for Google Analytics
pod 'Firebase/Analytics'
# or pod ‘Firebase/AnalyticsWithoutAdIdSupport’
# for Analytics without IDFA collection capability
# add pods for any other desired Firebase products
# https://firebase.google.com/docs/ios/setup#available-pods
# Add the pods for any other Firebase products you want to use in your app
# For example, to use Firebase Authentication and Cloud Firestore
pod 'Firebase/Auth'
pod 'Firebase/Firestore'
pod 'FirebaseFirestoreSwift'

위의 디펜던시 목록을 Podfile 내에 추가하고 인스톨합니다.

 

프로젝트에 코드 추가: import 설정 및 클래스 작성

import Foundation
import FirebaseAuth
import FirebaseFirestore
import FirebaseFirestoreSwift

class FirebasePractice {
    
    static let shared = FirebasePractice()
    
    var db: Firestore!
    var personsRef: CollectionReference!
    
    init() {
        // [START setup]
        let settings = FirestoreSettings()
        
        Firestore.firestore().settings = settings
        
        // [END setup]
        db = Firestore.firestore()
        
        personsRef = db.collection("persons")
    }
    
    // ... //
}
  • Firestore.firestore().collection("컬렉션이름") 으로 컬렉션 목록을 불러옵니다.
  • persons라는 이름으로 컬렉션 생성, 읽기 등을 할 예정이므로 이름을 "persons"로 지정합니다.

 

익명 로그인 기능 추가

익명 로그인을 사용하면 아이디, 비밀번호를 지정하지 않고도 특정 앱을 이용하는 사용자가 접근 권한을 획득할 수 있습니다. 굳이 로그인이 필요하지 않지만 외부 접근으로부터 보호가 필요한 간단한 기능 등을 만들 때 사용할 수 있습니다. (예: 쇼핑 앱의 장바구니)

파이어베이스 콘솔에서 익명 인증 절차를 추가합니다.

 

프로젝트에 아래와 같은 메서드를 만들어 인증 절차를 수행할 수 있도록 합니다.

/// 로그인 되어있는 경우 User 반환
var currentUser: User? {
    return Auth.auth().currentUser
}

/// 익명 로그인
func signInAnonymously(completionHandler: @escaping (_ user: User) -> ()) {
    Auth.auth().signInAnonymously { authResult, error in
        guard let user = authResult?.user else { return }
        completionHandler(user)
    }
}

 

위 메서드의 사용예는 다음과 같습니다. (뷰 컨트롤러의 ViewDidLoad(_:)등과 같이 실행 가능한 영역에 추가)

FirebasePractice.shared.signInAnonymously { user in
    
    // ... 작업 추가 ... //
}

 

Firestore 보안 규칙 추가

익명 로그인으로 권한을 획득한 사람만 접근 가능하도록 보안 규칙을 추가합니다. 읽기는 외부인도 모두 가능하도록 하되, 나머지 Create, Update, Delete는 익명 유저만 가능하게 하겠습니다.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /persons/{document=**} {
      allow read: if true
      allow create: if request.auth.uid == request.resource.data.author_uid;
      allow update, delete: if request.auth.uid == resource.data.author_uid;
    }
  }
}
  • request.resource.data.author_uid
    • request.resource.data – 데이터를 Firestore에 요청(request)할 때 보낸 데이터가 담기는 부분입니다.
  • resource.data
    • Firestore에 업로드되어 있는 기존 데이터입니다.

 

사용자 정의 struct 만들기

struct Person: Codable {

    // @DocumentID가 붙은 경우 Read시 해당 문서의 ID를 자동으로 할당
    @DocumentID var documentID: String?
    
    // @ServerTimestamp가 붙은 경우 Create, Update시 서버 시간을 자동으로 입력함 (FirebaseFirestoreSwift 디펜던시 필요)
    @ServerTimestamp var serverTS: Timestamp?

    var name, job: String
    var devices: [String]
    var authorUID: String = ""
    
    // 왼쪽: Swift 내에서 사용하는 변수이름 / 오른쪽: Firebase에서 사용하는 변수이름
    enum CodingKeys: String, CodingKey {
        case documentID = "document_id"
        case serverTS = "server_ts"
        case authorUID = "author_uid"
        
        case name, job, devices
    }
}
  • 기본적으로 Firestore에 업로드할때는 [String:Any] 형태의 사전 타입을 사용하지만, 보다 편리하게 관리하기 위해 사용자 정의 구조체를 만들고 그 구조체만으로 Firestore와 자료를 주고받아 보습니다.
  • 해당 구조체는 Codable 준수가 반드시 필요합니다.
  • <span style="text-decoration: underline;">@DocumentID</span> var documentID: String?
    • 이 부분은 리퀘스트시에는 nil 상태이지만, 서버로부터 read시에 이 부분에 문서 ID가 할당됩니다.
  • <span style="text-decoration: underline;">@ServerTimestamp</span> var serverTS: Timestamp?
    • 이 어노테이션을 추가하면 리퀘스트 할 때 해당 키에 자동으로 Firebase의 서버 시간이 할당됩니다.

 

CRUD 기능 구현

Create
func addPost(personRequest request: Person) {
    
    var ref: DocumentReference? = nil
    
    do {
        ref = personsRef.document()
        
        guard let ref = ref else {
            print("Reference is not exist.")
            return
        }
        
        // 사용자 uid 추가
        guard let currentUser = currentUser else {
            return
        }
        
        var request = request
        request.authorUID = currentUser.uid
        
        try ref.setData(from: request) { err in
            if let err = err {
                print("Firestore>> Error adding document: \(err)")
                return
            }
            
            print("Firestore>> Document added with ID: \(ref.documentID)")
        }
    } catch  {
        print("Firestore>> Error from addPost-setData: ", error)
    }
}
  • ref = personsRef.document()
    • 새로운 문서를 생성합니다. 괄호 안에 아무것도 지정하지 않을 경우 자동으로 문서의 ID가 생성되며, 수동으로 ID를 지정하려면 안에 String 타입의 ID를 삽입합니다.
  • request.authorUID = currentUser.uid
    • 권한 획득을 위해 현재 로그인중인 사용자의 uid를 할당합니다.
  • try ref.setData(from: request)
    • (from: ) 을 사용하면 사용자 정의 구조체를 리퀘스트 데이터로 보낼 수 있습니다.

 

Update
func updatePost(documentID: String, originalPersonRequest request: Person) {
    
    do {
        // serverTS에는 값이 들어있으므로 업데이트시 시간이 바뀌지 않는다.
        // serverTS를 nil로 하면 새로운 시간이 부여된다.
        var request = request
        request.serverTS = nil
        
        try personsRef.document(documentID).setData(from: request) { err in
            if let err = err {
                print("Firestore>> Error updating document: \(err)")
                return
            }
            
            print("Firestore>> Document updating with ID: \(documentID)")
        }
    } catch {
        print("Firestore>> Error from updatePost-setData: ", error)
    }
}
  • documentID를 찾아 해당하는 내용의 기존 데이터를 새로운 구조체 인스턴스의 데이터로 ‘교체’합니다.
    • 따라서 새로운 인스턴스를 생성하면 완전히 새로운 내용으로 교체됩니다.
    • 이를 방지하기 위해 기존의 데이터를 가져온 뒤 (해당 함수는 밑에서 설명) 그 데이터를 구조체 인스턴스로 변환하고, 그 인스턴스의 일부 내용만 변경한 뒤 재업로드하는 방식으로 업데이트가 이루어집니다.
  • var request = request
    • 함수의 파라미터는 기본적으로 let 이므로 var로 바꿔 변경이 가능하도록 조치합니다.
  • request.serverTS = nil
    • 기존 타임스탬프를 삭제하고 업데이트 시점의 타임스탬프로 새롭게 갱신합니다.
    • 예제에서는 구분되어 있지 않지만 만약 생성, 수정 타임스탬프가 있다면 생성 타임스탬프는 그대로 놔두고, 수정 타임스탬프만 nil로 변경하면 생성시점은 그대로, 수정시점은 새롭게 갱신됩니다.

 

Delete
func deletePost(documentID: String) {
    
    personsRef.document(documentID).delete() { err in
        if let err = err {
            print("Firestore>> Error deleting document: \(err)")
            return
        }
        
        print("Firestore>> Document deleted with ID: \(documentID)")
    }
}
  • documentID에 해당하는 문서를 삭제합니다.

 

Read (문서 하나)
func read(documentID: String, completionHandler: ((_ person: Person) -> ())?) {
    personsRef.document(documentID).getDocument { document, err in
        guard let document = document else {
            print("Firestore>> document is nil")
            return
        }
        
        if let person = try? document.data(as: Person.self) {
            print("Firestore>>", #function, person.documentID!, person)
            completionHandler?(person)
        }
    }
}
  • 문서 레퍼런스 personsRef.document(documentID)에서 .getDocument(...)를 실행하면 한 개의 문서 정보를 가져올 수 있습니다.
  • if if let person = try? document.data(as: Person.self) {...}
    • Firestore에서 가져온 데이터를 Person 타입으로 가공한 뒤 해당 인스턴스를 person에 저장합니다.
    • 더 필요한 작업이 있다면 completionHandler 클로저를 실행합니다.

이 글에서는 이 메서드 외에는 completionHandler와 같은 클로저 핸들러가 없지만, 다른 메서드에서도 이와 같은 핸들러를 추가해 사용할 수 있습니다.

 

Read (컬렉션 내의 문서 전체)
func readAll() {
    // 서버 업로드 시간 기준으로 내림차순
    let query: Query = personsRef.order(by: Person.CodingKeys.serverTS.rawValue, descending: true)

    query.getDocuments { snapshot, error in
        if let error = error {
            print("Firestore>> read failed", error)
            return
        }

        guard let snapshot = snapshot else {
            print("Firestore>> QuerySnapshot is nil")
            return
        }

        snapshot.documents.compactMap { documentSnapshot in
            try? documentSnapshot.data(as: Person.self)
        }.forEach {
            // local 저장된 상태에 원격 서버로 업로드되지 않은 경우 timestamp가 nil이 되는 경우가 있음
            print("Firestore>>", #function, $0.documentID!, $0.name, $0.serverTS ?? "-")
        }
    }
}
  • 컬렉션 레퍼런스 personRef에서 getDcouments(....)를 실행하면 컬렉션 내의 문서 전체를 내려받을 수 있습니다.
  • let query: Query = personsRef.order(...)
    • "server_ts" (=>Person.CodingKeys.serverTS.rawValue) 라는 이름의 키를 내림차순으로 정렬하는 쿼리를 만듭니다.
  • documentSnapshot.data(as: Person.self)
    • Firestore에서 가져온 데이터를 Person 타입으로 가공한 뒤 해당 인스턴스를 person에 저장합니다.

 

실행하기

위에서 만든 클래스를 바탕으로 실제 실행 가능한 영역에서 데이터베이스 CRUD를 수행합니다.

override func viewDidLoad() {
    super.viewDidLoad()
    FirebasePractice.shared.signInAnonymously { user in
        
        // 새로운 Person 생성
        let person = Person(name: "Person \(Int.random(in: 1...1000))", job: "engineer", devices: ["driver", "drill"])

        // Create
        FirebasePractice.shared.addPost(personRequest: person)
        
        // Read & Update & Delete 대상 문서의 ID
        let targetDocID = "UIS6MGyb79CY4vscxjag"

        // Read(한 개) & Update
        FirebasePractice.shared.read(documentID: targetDocID) { person in
            var person = person
            // 새로운 서버시간 부여
            person.serverTS = nil
            person.name = "New Person"
            FirebasePractice.shared.updatePost(documentID: targetDocID, originalPersonRequest: person)
        }
        
        // Read(컬렉션 내 전체 문서)
        FirebasePractice.shared.readAll()

        // Delete
        FirebasePractice.shared.deletePost(documentID: targetDocID)
    }
}

 

실행 화면

Create

 

Read(한 개) & Update

업데이트 전

 

업데이트 후

 

Read(컬렉션 내 전체 문서)

참고: Firestore timestamp getting null

 

Delete

 

 

전체 코드


//
// FirebasePractice.swift
//
// Created by yoonbumtae on 2022/06/12.
//
import Foundation
import FirebaseAuth
import FirebaseFirestore
import FirebaseFirestoreSwift
struct Person: Codable {
// @DocumentID가 붙은 경우 Read시 해당 문서의 ID를 자동으로 할당
@DocumentID var documentID: String?
// @ServerTimestamp가 붙은 경우 Create, Update시 서버 시간을 자동으로 입력함 (FirebaseFirestoreSwift 디펜던시 필요)
@ServerTimestamp var serverTS: Timestamp?
var name, job: String
var devices: [String]
var authorUID: String = ""
// 왼쪽: Swift 내에서 사용하는 변수이름 / 오른쪽: Firebase에서 사용하는 변수이름
enum CodingKeys: String, CodingKey {
case documentID = "document_id"
case serverTS = "server_ts"
case authorUID = "author_uid"
case name, job, devices
}
}
class FirebasePractice {
static let shared = FirebasePractice()
var db: Firestore!
var personsRef: CollectionReference!
init() {
// [START setup]
let settings = FirestoreSettings()
Firestore.firestore().settings = settings
// [END setup]
db = Firestore.firestore()
personsRef = db.collection("persons")
}
/// 로그인 되어있는 경우 User 반환
var currentUser: User? {
return Auth.auth().currentUser
}
/// 익명 로그인
func signInAnonymously(completionHandler: @escaping (_ user: User) -> ()) {
Auth.auth().signInAnonymously { authResult, error in
guard let user = authResult?.user else { return }
completionHandler(user)
}
}
func addPost(personRequest request: Person) {
var ref: DocumentReference? = nil
do {
ref = personsRef.document()
guard let ref = ref else {
print("Reference is not exist.")
return
}
// 사용자 uid 추가
guard let currentUser = currentUser else {
return
}
var request = request
request.authorUID = currentUser.uid
try ref.setData(from: request) { err in
if let err = err {
print("Firestore>> Error adding document: \(err)")
return
}
print("Firestore>> Document added with ID: \(ref.documentID)")
}
} catch {
print("Firestore>> Error from addPost-setData: ", error)
}
}
func updatePost(documentID: String, originalPersonRequest request: Person) {
do {
// serverTS에는 값이 들어있으므로 업데이트시 시간이 바뀌지 않는다.
// serverTS를 nil로 하면 새로운 시간이 부여된다.
var request = request
request.serverTS = nil
try personsRef.document(documentID).setData(from: request) { err in
if let err = err {
print("Firestore>> Error updating document: \(err)")
return
}
print("Firestore>> Document updating with ID: \(documentID)")
}
} catch {
print("Firestore>> Error from updatePost-setData: ", error)
}
}
func deletePost(documentID: String) {
personsRef.document(documentID).delete() { err in
if let err = err {
print("Firestore>> Error deleting document: \(err)")
return
}
print("Firestore>> Document deleted with ID: \(documentID)")
}
}
func readAll() {
// 서버 업로드 시간 기준으로 내림차순
let query: Query = personsRef.order(by: Person.CodingKeys.serverTS.rawValue, descending: true)
query.getDocuments { snapshot, error in
if let error = error {
print("Firestore>> read failed", error)
return
}
guard let snapshot = snapshot else {
print("Firestore>> QuerySnapshot is nil")
return
}
snapshot.documents.compactMap { documentSnapshot in
try? documentSnapshot.data(as: Person.self)
}.forEach {
// local 저장된 상태에 원격 서버로 업로드되지 않은 경우 timestamp가 nil이 되는 경우가 있음
print("Firestore>>", #function, $0.documentID!, $0.name, $0.serverTS ?? "-")
}
}
}
func read(documentID: String, completionHandler: ((_ person: Person) -> ())?) {
personsRef.document(documentID).getDocument { document, err in
guard let document = document else {
print("Firestore>> document is nil")
return
}
if let person = try? document.data(as: Person.self) {
print("Firestore>>", #function, person.documentID!, person)
completionHandler?(person)
}
}
}
}


override func viewDidLoad() {
super.viewDidLoad()
FirebasePractice.shared.signInAnonymously { user in
// 새로운 Person 생성
let person = Person(name: "Person \(Int.random(in: 1…1000))", job: "engineer", devices: ["driver", "drill"])
// Create
FirebasePractice.shared.addPost(personRequest: person)
// Read & Update & Delete 대상 문서의 ID
let targetDocID = "UIS6MGyb79CY4vscxjag"
// Read(한 개) & Update
FirebasePractice.shared.read(documentID: targetDocID) { person in
var person = person
// 새로운 서버시간 부여
person.serverTS = nil
person.name = "New Person"
FirebasePractice.shared.updatePost(documentID: targetDocID, originalPersonRequest: person)
}
// Read(컬렉션 내 전체 문서)
FirebasePractice.shared.readAll()
// 삭제
FirebasePractice.shared.deletePost(documentID: targetDocID)
}
}

문의 | 코멘트 또는 yoonbumtae@gmail.com


카테고리: Swift


0개의 댓글

답글 남기기

Avatar placeholder

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다