Protocol 구현하기 - Service layer

SteadySlower·2022년 9월 15일
0

Phase 2의 목적은 기존에 있는 코드들의 Unit test를 작성하는 일입니다. UITest도 함께 작성하려고 했으나 일단은 Unit test에만 집중할 수 있도록 해보겠습니다.

Unit test를 작성의 대상은 대부분 각 View의 ViewModel들이 될 텐데요. Unit test는 해당 객체만 독립적으로 테스트 해야 하므로 다른 객체에 의존하지 않기 위해서 Mock 객체를 활용해야 합니다.

하지만 지금 코드의 상태는 기능 구현의 속도만을 강조했기 때문에 모두 Singleton으로 짜여져있습니다. 한마디로 ViewModel과 API 호출을 담당하는 객체를 분리할 수 없다는 것입니다.

이 두 객체를 분리하기 위해서는 ViewModel을 init할 때 API를 담당하는 객체를 인자로 받아서 init하는 구조로 바꾸어야 합니다.

더불어서 Mock 객체를 만들기 위해서는 ViewModel 안에서 API를 담당하는 객체의 타입을 그대로 사용하는 것이 아니라 Protocol로 정의해 두어야 합니다. 그래야 같은 Protocol을 준수하는 Mock 객체를 가지고 테스트를 할 수 있게 됩니다.

이번 포스팅에서는 기존의 Singleton으로 구현된 WordService를 Protocol로 구현하여 Testable한 구조로 바꾸어 보도록 하겠습니다.

😵‍💫 기존의 코드

singleton이라고는 했지만 사실 엄격한 의미의 singleton은 아닙니다. 왜냐하면 아래 코드를 보면 WordService 객체는 코드를 실행하는 동안 한 번도 init이 되는 것이 아니기 때문입니다. 그저 type method들이 메모리에 올라가고 그 method들을 가져다가 사용하는 것입니다. (결과적으로 보면 유사하기는 합니다.)

class WordService {
    static func getWordBooks(completionHandler: @escaping ([WordBook]?, Error?) -> Void) {
        Constants.Collections.wordBooks.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)
        }
    }

    static func getWords(wordBookID id: String, completionHandler: @escaping ([Word]?, Error?) -> Void) {
        Constants.Collections.word(id).order(by: "timestamp").getDocuments { snapshot, error in
            if let error = error {
                completionHandler(nil, error)
            }
            guard let documents = snapshot?.documents else { return }
            let words = documents.compactMap({ try? $0.data(as: Word.self) })
            completionHandler(words, nil)
        }
    }

    static func saveBook(title: String, completionHandler: FireStoreCompletion) {
        let data: [String : Any] = [
            "title": title,
            "timestamp": Timestamp(date: Date())]
        Constants.Collections.wordBooks.addDocument(data: data, completion: completionHandler)
    }

    static func saveWord(wordInput: WordInput, wordBookID: String, completionHandler: FireStoreCompletion) {
        let group = DispatchGroup()
        var meaningImageURL = ""
        var ganaImageURL = ""
        var kanjiImageURL = ""

        if let meaningImage = wordInput.meaningImage {
            ImageUploader.uploadImage(image: meaningImage, group: group) { url in
                meaningImageURL = url
            }
        }

        if let ganaImage = wordInput.ganaImage {
            ImageUploader.uploadImage(image: ganaImage, group: group) { url in
                ganaImageURL = url
            }
        }

        if let kanjiImage = wordInput.kanjiImage {
            ImageUploader.uploadImage(image: kanjiImage, group: group) { url in
                kanjiImageURL = url
            }
        }

        group.notify(queue: .global()) {
            let data: [String : Any] = ["timestamp": Timestamp(date: Date()),
                                        "meaningText": wordInput.meaningText,
                                        "meaningImageURL": meaningImageURL,
                                        "ganaText": wordInput.ganaText,
                                        "ganaImageURL": ganaImageURL,
                                        "kanjiText": wordInput.kanjiText,
                                        "kanjiImageURL": kanjiImageURL,
                                        "studyState": wordInput.studyState.rawValue]
            Constants.Collections.word(wordBookID).addDocument(data: data, completion: completionHandler)
        }
    }

    static func saveExample(wordInput: WordInput) {
        let data: [String : Any] = ["timestamp": Timestamp(date: Date()),
                                    "meaningText": wordInput.meaningText,
                                    "meaningImageURL": "",
                                    "ganaText": wordInput.ganaText,
                                    "ganaImageURL": "",
                                    "kanjiText": wordInput.kanjiText,
                                    "kanjiImageURL": "",
                                    "used": 0]
        Constants.Collections.examples.addDocument(data: data)
    }

    static func updateStudyState(wordBookID: String, wordID: String, newState: StudyState,  completionHandler: @escaping (Error?) -> Void) {
        Constants.Collections.word(wordBookID).document(wordID).updateData(["studyState" : newState.rawValue]) { error in
            completionHandler(error)
        }
    }

    static func checkIfOverlap(wordBookID: String, meaningText: String, completionHandler: @escaping ((Bool?, Error?) -> Void)) {
        Constants.Collections.word(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)
        }
    }

    static func closeWordBook(of id: String, to: String?, toMoveWords: [Word], completionHandler: FireStoreCompletion) {
        if let to = to {
            copyWords(toMoveWords, to: to) {
                closeBook(id, completionHandler: completionHandler)
            }
        } else {
            closeBook(id, completionHandler: completionHandler)
        }

    }

    // Examples를 검색하는 함수
    static func fetchExamples(_ query: String, completionHandler: @escaping ([WordExample]?, Error?) -> Void) {
        Constants.Collections.examples
            .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 examples = documents
                        .compactMap { try? $0.data(as: WordExample.self) }
                        .sorted(by: { $0.used > $1.used })
                completionHandler(examples, nil)
            }
    }

    // Examples 사용되서 used에 + 1하는 함수
    static func updateUsed(of example: WordExample) {
        guard let id = example.id else {
            print("No id of example in updateUsed")
            return
        }
        Constants.Collections.examples.document(id).updateData(["used" : example.used + 1])
    }

    // 단어장의 _closed field를 업데이트하는 함수
    static private func closeBook(_ id: String, completionHandler: FireStoreCompletion) {
        let field = ["_closed": true]
        Constants.Collections.wordBooks.document(id).updateData(field, completion: completionHandler)
    }

    // 단어 여러개를 copy하는 기능 (dispatch group)
    static private func copyWords(_ words: [Word], to id: String, completionHandler: @escaping () -> Void) {
        let group = DispatchGroup()
        for word in words {
            copyWord(word, to: id, group: group)
        }
        group.notify(queue: .global()) {
            completionHandler()
        }
    }

    // 단어 1개 이동하는 기능
    static private func copyWord(_ word: Word, to id: String, group: DispatchGroup) {
        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]
        Constants.Collections.word(id).addDocument(data: data) { error in
            //TODO: handle error
            if let error = error { print(error) }
            group.leave()
        }
    }

}

💡 리팩토링

1. WordService를 3개로 분리

일단 WordService에 너무나 많은 메소드가 들어가 있습니다. 아마 기능이 추가되면 계속 기능이 추가될텐데 하나의 클래스가 너무나 많은 부담을 지고 있으므로 클래스를 3개로 분리하도록 하겠습니다.

이렇게 하면 일단 하나의 클래스가 너무 길어지지 않으므로 코드가 보기 편해집니다. 그리고 새로운 영역이 생기면 새로운 객체를 만들 수도 있습니다.

protocol WordService {

}
protocol SampleService {

}
protocol WordBookService {

}

2. Protocol 안에 필요한 메소드를 정의

protocol WordService {
    func getWords(wordBook: WordBook, completionHandler: @escaping CompletionWithData<[Word]>)
    func saveWord(wordInput: WordInput, completionHandler: @escaping CompletionWithoutData)
    func updateStudyState(word: Word, newState: StudyState, completionHandler: @escaping CompletionWithoutData)
    func copyWords(_ words: [Word], to wordBook: WordBook, completionHandler: @escaping CompletionWithoutData)
}

3. DB와 Service를 분리

Serivce는 ViewModel에서 ViewModel에서 바로 접근하는 부분을 의미합니다. 그리고 DB의 경우 그것보다 하나 아래 단계에 있는 객체로 실제 DB에 접속해서 데이터를 불러오고 저장하는 부분을 의미합니다. 만약에 DB에 접근하는 것을 Service 객체에서 모두 해버리면 몇 가지 단점이 있습니다.

일단 Service 객체를 테스트할 수 없게 됩니다. DB (이 앱에서는 Firebase)는 외부의 코드이기 때문에 테스트를 할 수 없는데요. 만약에 Service가 해당 객체에 의존하게 된다면 테스트를 할 수가 없게 되겠죠.

그리고 다른 DB로 교체할 수 없습니다. 만약에 유료 회원에게는 실시간 동기화와 기기간 데이터 공유를 지원하는 Firebase를 사용하도록 하고 무료 회원에게는 내부 DB인 CoreData를 사용하도록 하고 싶다고 해봅시다. 만약에 Service 객체와 Firebase 객체가 하나라면 Service 객체를 각각 두 개 따로 만들어야 하겠죠? 하지만 DB와 분리되어 있다면 Service를 init할 때 DB만 바꾸어서 init하면 되므로 같은 Service 코드를 활용할 수 있습니다.

그래서 아래처럼 Service protocol을 구현한 ServiceImpl 객체를 만들 때는 외부에서 db 객체를 받아오도록 해서 두 객체를 분리하도록 합니다. (imageUploader는 이미지를 저장하는 역할을 하는 객체입니다.)

final class WordServiceImpl: WordService {
    
    // DB
    let db: Database
    let iu: ImageUploader
    
    // Initializer
    init(database: Database, imageUploader: ImageUploader) {
        self.db = database
        self.iu = imageUploader
    }
}

4. Service 안에 프로토콜에 정의된 메소드들을 구현

final class WordServiceImpl: WordService {
    
    // DB
    let db: Database
    let iu: ImageUploader
    
    // Initializer
    init(database: Database, imageUploader: ImageUploader) {
        self.db = database
        self.iu = imageUploader
    }
    
    // functions
    
    func getWords(wordBook: WordBook, completionHandler: @escaping CompletionWithData<[Word]>) {
        db.fetchWords(wordBook, completionHandler: completionHandler)
    }
    
    func saveWord(wordInput: WordInput, completionHandler: @escaping CompletionWithoutData) {
        let group = DispatchGroup()
        
        var wordInput = wordInput
        
        // 동시성 이슈를 해결하기 위해서 따로 변수를 사용하고 나중에 완료되면 wordInput에 접근하는 것으로
        var meaningImageURL = ""
        var ganaImageURL = ""
        var kanjiImageURL = ""
        
        if let meaningImage = wordInput.meaningImage {
            iu.uploadImage(image: meaningImage, group: group) { url in
                meaningImageURL = url
            }
        }
        
        if let ganaImage = wordInput.ganaImage {
            iu.uploadImage(image: ganaImage, group: group) { url in
                ganaImageURL = url
            }
        }
        
        if let kanjiImage = wordInput.kanjiImage {
            iu.uploadImage(image: kanjiImage, group: group) { url in
                kanjiImageURL = url
            }
        }
        
        group.notify(queue: .global()) { [weak self] in
            wordInput.meaningImageURL = meaningImageURL
            wordInput.ganaImageURL = ganaImageURL
            wordInput.kanjiImageURL = kanjiImageURL
            
            self?.db.insertWord(wordInput: wordInput, completionHandler: completionHandler)
        }
    }
    
    func updateStudyState(word: Word, newState: StudyState,  completionHandler: @escaping CompletionWithoutData) {
        db.updateStudyState(word: word, newState: newState, completionHandler: completionHandler)
    }
    
    func copyWords(_ words: [Word], to wordBook: WordBook, completionHandler: @escaping CompletionWithoutData) {
        let group = DispatchGroup()
        
        var copyWordError: Error? = nil
        
        // word를 옮기는 과정에서 에러가 나면 copyWordError에 할당
        for word in words {
            db.copyWord(word, to: wordBook, group: group) { error in
                copyWordError = error
            }
        }
        group.notify(queue: .global()) {
            completionHandler(copyWordError)
        }
    }
}

마치며…

뭐 간단한 작업이라고 생각할 수도 있겠지만 실제로는 상당히 시간이 오래 걸리는 과정이었습니다. 단 하나의 추가되는 기능도 없는데 이렇게 많은 시간이 소모되는 일이라니… 기술 부채를 갚아가는 과정이었을까요? 더 큰 빚쟁이가 되기 전에 지금이라고 갚아나가서 다행이라고 생각했습니다ㅎㅎ😅

profile
백과사전 보다 항해일지(혹은 표류일지)를 지향합니다.

0개의 댓글