이번 포스팅에서는 외부 모듈 (Firebase)를 Protocol로 구현해보겠습니다. 지금 앱에서 사용하는 DB는 Firebase인데요. Firebase 안에 있는 메소드들을 그대로 가져다가 사용하게 되면 나중에 DB를 다른 것으로 교체하고자 할 때 Service 단의 메소드들을 전부 다 다시 구현해야할 수 있습니다. 따라서 Protocol을 활용해서 Firebase를 한단계 감싸는 객체를 만들어 보도록 하겠습니다.
Service layer에서 DB에 필요로 하는 메소드들을 정의해둡니다. 해당 메소드들은 DB가 Firebase에서 다른 서비스로 바꾸더라도 그 서비스가 아래 protocol을 채택한다면 Service의 코드를 그대로 사용할 수 있도록 해줍니다.
protocol Database {
// WordBook 관련
func fetchWordBooks(completionHandler: @escaping CompletionWithData<[WordBook]>)
func insertWordBook(title: String, completionHandler: @escaping CompletionWithoutData)
func checkIfOverlap(wordBook: WordBook, meaningText: String, completionHandler: @escaping CompletionWithData<Bool>)
func closeWordBook(of toClose: WordBook, completionHandler: @escaping CompletionWithoutData)
// Word 관련
func fetchWords(_ wordBook: WordBook, completionHandler: @escaping CompletionWithData<[Word]>)
func insertWord(wordInput: WordInput, completionHandler: @escaping CompletionWithoutData)
func updateStudyState(word: Word, newState: StudyState, completionHandler: @escaping CompletionWithoutData)
func copyWord(_ word: Word, to wordBook: WordBook, group: DispatchGroup, completionHandler: @escaping CompletionWithoutData)
// Sample 관련
func insertSample(_ wordInput: WordInput)
func fetchSample(_ query: String, completionHandler: @escaping CompletionWithData<[Sample]>)
func updateUsed(of sample: Sample, to used: Int)
}
DB에 Protocol을 적용하는 첫번째 방법은 extension을 활용하는 방법입니다. 바로 Firebase에서 제공하는 객체 안에 필요한 메소드들을 정의하는 방법입니다.
extension Firestore: Database {
}
직접 extension을 활용하는 방법도 있지만 Firebase를 한단계 감싸는 새로운 객체를 만드는 방법도 있습니다. 내부에 Firestore 객체를 가지고 있고 해당 객체를 활용해서 필요한 메소드들을 구현하면 됩니다. 저는 이 방법을 택했습니다.
final class FirestoreDB: Database {
private lazy var firestore: Firestore = {
Firestore.firestore()
}()
}
예전에는 Constant로 전역에 선언해두었던 CollectionReference (Firestore DB의 경로를 나타내는 class입니다.)를 FirestoreDB 내부에 선언합니다. 해당 property들은 protocol에 정의되어 있지 않기 때문에 꼭 만들 필요는 없지만 protocol에 정의된 메소드들을 구현하는데 편하게 활용하기 위해서 정의해둡니다.
final class FirestoreDB: Database {
private lazy var firestore: Firestore = {
Firestore.firestore()
}()
// CollectionReferences
private lazy var wordBookRef = {
firestore
.collection("develop")
.document("data")
.collection("wordBooks")
}()
private func wordRef(of bookID: String) -> CollectionReference {
firestore
.collection("develop")
.document("data")
.collection("wordBooks")
.document(bookID)
.collection("words")
}
private lazy var sampleRef = {
firestore
.collection("develop")
.document("data")
.collection("examples")
}()
}
예전에는 전역에 아래와 같이 선언해서 사용했는데요. 이렇게 하는 경우 Firestore 클래스의 객체가 3개나 생기게 됩니다. 굳이 같은 객체를 3개나 메모리에 둘 필요는 없겠죠? 위의 코드처럼 구현하면 Firestore 객체는 모든 reference가 공유하므로 1개의 인스턴스 만으로도 충분합니다.
import Firebase
extension Constants {
enum Collections {
static let wordBooks =
Firestore.firestore()
.collection("develop")
.document("data")
.collection("wordBooks")
static func word(_ bookID: String) -> CollectionReference {
Firestore.firestore()
.collection("develop")
.document("data")
.collection("wordBooks")
.document(bookID)
.collection("words")
}
static let samples =
Firestore.firestore()
.collection("develop")
.document("data")
.collection("examples")
}
}
이제 protocol에 정의된 메소드들을 구현하면 됩니다. 내부에 선언된 property들을 사용했기 때문에 메소드마다 데이터의 경로인 reference를 정의해주지 않아도 됩니다.
또학 각각의 메소드들이 하나의 firestore 객체를 공유해서 사용하므로 메모리의 낭비가 없습니다.
// MARK: WordbookDatabase
extension FirestoreDB {
func fetchWordBooks(completionHandler: @escaping CompletionWithData<[WordBook]>) {
wordBookRef.order(by: "timestamp", descending: true).getDocuments { snapshot, error in
if let error = error {
completionHandler(nil, error)
}
guard let documents = snapshot?.documents else { return }
let wordBooks = documents.compactMap({ try? $0.data(as: WordBook.self) })
completionHandler(wordBooks, nil)
}
}
func insertWordBook(title: String, completionHandler: @escaping CompletionWithoutData) {
let data: [String : Any] = [
"title": title,
"timestamp": Timestamp(date: Date())]
wordBookRef.addDocument(data: data, completion: completionHandler)
}
func checkIfOverlap(wordBook: WordBook, meaningText: String, completionHandler: @escaping CompletionWithData<Bool>) {
guard let wordBookID = wordBook.id else {
// TODO: handle Error
let error = AppError.generic(massage: "No id in wordBook")
completionHandler(nil, error)
return
}
wordRef(of: wordBookID).whereField("meaningText", isEqualTo: meaningText).getDocuments { snapshot, error in
if let error = error {
completionHandler(nil, error)
return
}
guard let documents = snapshot?.documents else { return }
completionHandler(documents.count != 0 ? true : false, nil)
}
}
func closeWordBook(of toClose: WordBook, completionHandler: @escaping CompletionWithoutData) {
guard let id = toClose.id else {
let error = AppError.generic(massage: "No wordBook ID")
completionHandler(error)
return
}
let field = ["_closed": true]
wordBookRef.document(id).updateData(field, completion: completionHandler)
}
}
// MARK: WordDatabase
extension FirestoreDB {
func fetchWords(_ wordBook: WordBook, completionHandler: @escaping CompletionWithData<[Word]>) {
guard let id = wordBook.id else {
let error = AppError.generic(massage: "No wordBook ID")
completionHandler(nil, error)
return
}
wordRef(of: id).order(by: "timestamp").getDocuments { snapshot, error in
if let error = error {
completionHandler(nil, error)
}
guard let documents = snapshot?.documents else { return }
var words = documents.compactMap({ try? $0.data(as: Word.self) })
for i in 0..<words.count {
words[i].wordBookID = id
}
completionHandler(words, nil)
}
}
func insertWord(wordInput: WordInput, completionHandler: @escaping CompletionWithoutData) {
let data: [String : Any] = ["timestamp": Timestamp(date: Date()),
"meaningText": wordInput.meaningText,
"meaningImageURL": wordInput.meaningImageURL,
"ganaText": wordInput.ganaText,
"ganaImageURL": wordInput.ganaImageURL,
"kanjiText": wordInput.kanjiText,
"kanjiImageURL": wordInput.kanjiImageURL,
"studyState": wordInput.studyState.rawValue]
wordRef(of: wordInput.wordBookID).addDocument(data: data, completion: completionHandler)
}
func updateStudyState(word: Word, newState: StudyState, completionHandler: @escaping CompletionWithoutData) {
guard let wordID = word.id else {
print("Failed in Database updateStudyState")
let error = AppError.generic(massage: "No ID in Word")
completionHandler(error)
return
}
guard let wordBookID = word.wordBookID else {
print("Failed in Database updateStudyState")
let error = AppError.generic(massage: "No WordBookID in Word")
completionHandler(error)
return
}
wordRef(of: wordBookID).document(wordID).updateData(["studyState" : newState.rawValue]) { error in
completionHandler(error)
}
}
func copyWord(_ word: Word, to wordBook: WordBook, group: DispatchGroup, completionHandler: @escaping CompletionWithoutData) {
guard let wordBookID = wordBook.id else {
let error = AppError.generic(massage: "No wordBookID")
completionHandler(error)
return
}
group.enter()
let data: [String : Any] = ["timestamp": Timestamp(date: Date()),
"meaningText": word.meaningText,
"meaningImageURL": word.meaningImageURL,
"ganaText": word.ganaText,
"ganaImageURL": word.ganaImageURL,
"kanjiText": word.kanjiText,
"kanjiImageURL": word.kanjiImageURL,
"studyState": StudyState.undefined.rawValue]
wordRef(of: wordBookID).addDocument(data: data) { error in
if let error = error {
completionHandler(error)
}
group.leave()
}
}
}
// MARK: SampleDatabase
extension FirestoreDB {
func insertSample(_ wordInput: WordInput) {
let data: [String : Any] = ["timestamp": Timestamp(date: Date()),
"meaningText": wordInput.meaningText,
"meaningImageURL": "",
"ganaText": wordInput.ganaText,
"ganaImageURL": "",
"kanjiText": wordInput.kanjiText,
"kanjiImageURL": "",
"used": 0]
sampleRef.addDocument(data: data)
}
func fetchSample(_ query: String, completionHandler: @escaping CompletionWithData<[Sample]>) {
sampleRef
.whereField("meaningText", isGreaterThanOrEqualTo: query)
.whereField("meaningText", isLessThan: query + "힣")
.getDocuments { snapshot, error in
if let error = error {
completionHandler(nil, error)
}
guard let documents = snapshot?.documents else { return }
let samples = documents
.compactMap { try? $0.data(as: Sample.self) }
.sorted(by: { $0.used > $1.used })
completionHandler(samples, nil)
}
}
func updateUsed(of sample: Sample, to used: Int) {
guard let id = sample.id else {
print("No id in sample")
return
}
sampleRef.document(id).updateData(["used" : used])
}
}