Protocol 구현하기 - DB layer

SteadySlower·2022년 9월 17일
0
post-custom-banner

이번 포스팅에서는 외부 모듈 (Firebase)를 Protocol로 구현해보겠습니다. 지금 앱에서 사용하는 DB는 Firebase인데요. Firebase 안에 있는 메소드들을 그대로 가져다가 사용하게 되면 나중에 DB를 다른 것으로 교체하고자 할 때 Service 단의 메소드들을 전부 다 다시 구현해야할 수 있습니다. 따라서 Protocol을 활용해서 Firebase를 한단계 감싸는 객체를 만들어 보도록 하겠습니다.

Database에 필요한 protocol 정의하기

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)
}

Firebase에 protocol을 구현하는 방법

extension을 활용하는 방법

DB에 Protocol을 적용하는 첫번째 방법은 extension을 활용하는 방법입니다. 바로 Firebase에서 제공하는 객체 안에 필요한 메소드들을 정의하는 방법입니다.

extension Firestore: Database {
    
}

Firebase를 한 단계 감싸는 객체를 만드는 방법

직접 extension을 활용하는 방법도 있지만 Firebase를 한단계 감싸는 새로운 객체를 만드는 방법도 있습니다. 내부에 Firestore 객체를 가지고 있고 해당 객체를 활용해서 필요한 메소드들을 구현하면 됩니다. 저는 이 방법을 택했습니다.

final class FirestoreDB: Database {
    
    private lazy var firestore: Firestore  = {
        Firestore.firestore()
    }()

}

Protocol을 구현하기 위한 Property 만들기

예전에는 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에 정의된 메소드 구현하기

이제 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])
    }
}
profile
백과사전 보다 항해일지(혹은 표류일지)를 지향합니다.
post-custom-banner

0개의 댓글