Test 준비: Dependency Injection 배워보기

SteadySlower·2022년 9월 13일
0

Dependency Injection이란?

한 객체에서 사용할 객체를 외부에서 주입하는 것을 의미합니다.

DI가 필요한 이유 = Singleton의 단점

DI를 사용하기 이전에는 어떤 객체가 다른 객체가 필요하면 Singleton 객체를 활용해서 접근을 했었는데요. DI는 이런 싱글톤 방식의 단점을 극복하기 위해서 사용합니다.

1. Singleton은 Global (전역)이다.

Singleton은 코드의 어느 scope에서나 접근이 가능한 객체입니다. 앱이 작을 때는 큰 의미가 없지만 전역 변수가 너무 많아지면 나중에 변수명을 짓는 것 조차도 어려워집니다.

또한 멀티쓰레딩 환경에서 같은 객체에 여러 쓰레드가 접근할 때 발생할 수 있는 문제들을 예측하는 것도 어렵습니다.

2. Customizing이 불가능하다.

객체를 initializer를 통해서 커스터마이징 하는 것이 불가능합니다.

3. 다른 객체로 바꿀 수 없다.

테스트 상황에서 주로 문제를 일으키는 단점인데요. 싱글톤 객체를 사용할 경우 전역에 선언된 하나의 객체만을 사용해야 하므로 테스트를 위한 Mock 객체를 주입하는 것이 불가능합니다.

예제) 싱글톤으로 구현한 코드

제가 진행하고 있는 프로젝트의 코드의 일부로 간단하게 예제코드를 만들어 보았습니다. 아래 코드는 간단하게 Singleton을 활용해서 구현한 방식입니다. 제가 아직 테스트를 만들기 전에 프로젝트를 진행하면서 구현한 방법이기도 합니다.

아래 코드를 보면 싱글톤 객체인 WordBookService 안에서 사용할 객체를 init하고 전역에서 사용할 수 있도록 했습니다. ViewModel 안에서 해당 객체를 사용하고 있습니다.

아래의 코드를 DI가 필요한 이유를 염두에 두면서 리팩토링을 해보도록 하겠습니다.

import SwiftUI
import Firebase

struct DI: View {
    @ObservedObject var vm = ViewModel()
    
    var body: some View {
        ScrollView {
            ForEach(vm.wordBooks) { book in
                Text(book.title)
            }
        }
    }
}

struct DI_Previews: PreviewProvider {
    static var previews: some View {
        DI()
    }
}

// DI가 없는 ViewModel
class ViewModel: ObservableObject {
    @Published var wordBooks: [WordBook] = []
    
    init() {
        loadBooks()
    }
    
    func loadBooks() {
        WordBookService.shared.fetchWordBooks { [weak self] wordbook, error in
            if let wordbook = wordbook {
                self?.wordBooks = wordbook
            }
        }
    }
}

// 싱글톤을 사용한 데이터 서비스
class WordBookService {
    
    static let shared = WordBookService()
    
    func fetchWordBooks(completionHandler: @escaping ([WordBook]?, Error?) -> Void) {
        Firestore.firestore()
            .collection("develop").document("data").collection("wordBooks")
            .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)
            }
    }
    
}

싱글톤을 DI로 대체하기

WordBookService 객체 수정

내부에 싱글톤 객체를 없애줍니다.

class WordBookService {
    
    func fetchWordBooks(completionHandler: @escaping ([WordBook]?, Error?) -> Void) {
        Firestore.firestore()
            .collection("develop").document("data").collection("wordBooks")
            .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)
            }
    }
    
}

VM 수정

내부에서 전역에 선언된 Singleton 객체를 바로 가져다가 쓰지 않고 외부에서 init을 통해서 받은 객체로 데이터를 불러오도록 합니다.

내부에 객체를 가지고 있을 변수를 선언하고 init을 통해서 외부에서 받은 객체를 전달합니다.

class ViewModel: ObservableObject {
    @Published var wordBooks: [WordBook] = []
	
		//✅ 외부에서 받은 객체를 가지고 있을 변수
    let dataService: WordBookService
    
		//✅ 외부에서 initializer를 활용해서 객체 주입
    init(dataService: WordBookService) {
        self.dataService = dataService
        loadBooks()
    }
    
    func loadBooks() {
        dataService.fetchWordBooks { [weak self] wordbook, error in
            if let wordbook = wordbook {
                self?.wordBooks = wordbook
            }
        }
    }
}

View 수정

ViewModel의 initializer를 수정했으므로 VM을 init하는 View 역시도 수정해야 합니다. View도 마찬가지로 외부에서 dataService를 주입받아서 VM에 전달합니다.

struct DI: View {
    @ObservedObject var vm: ViewModel
    
		//✅ 외부에서 주입한 객체를 활용해서 vm을 init
    init(dataService: WordBookService) {
        self.vm = ViewModel(dataService: dataService)
    }
    
    var body: some View {
        ScrollView {
            ForEach(vm.wordBooks) { book in
                Text(book.title)
            }
        }
    }
}

최초에 객체를 주입

이제 가장 상위에서 의존성을 주입합니다. 보통은 App 객체가 되겠지만 예제 코드는 일단 Preview 만으로 실행되고 있으므로 PreviewProvider에 객체를 만들어서 주입하면 되겠습니다.

결국 최상위 객체에서 단 1번 init되어서 하위 객체로 전달, 전달되며 사용하므로 singleton과 동일한 장점을 누릴 수 있으면서도 싱글톤의 단점들을 극복할 수 있게 해줍니다.

struct DI_Previews: PreviewProvider {
    static let dataService = WordBookService()
    
    static var previews: some View {
        DI(dataService: dataService)
    }
}

DI의 장점: 객체 커스터마이징

싱글톤 객체와 다르게 DI를 사용하는 객체는 init을 통한 커스터마이징이 가능합니다. 아래 코드처럼 Firebase에서 데이터를 가져오는 계정이 develop이 아닐 때 sington에서는 대처가 불가능했지만 DI를 사용하면 아래 처러머 init을 통해 다른 계정의 이름을 받아서 객체를 init할 수 있습니다.

class WordBookService {
    
    let account: String
    
    init(account: String = "develop") {
        self.account = account
    }
    
    func fetchWordBooks(completionHandler: @escaping ([WordBook]?, Error?) -> Void) {
        Firestore.firestore()
            .collection(account).document("data").collection("wordBooks")
            .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)
            }
    }
    
}

다른 Dependency로 바꾸기 (Mock 객체 만들기)

이 기법을 Testing을 위해서 필수적인 기능입니다. 바로 Testable한 코드를 위해서 DI를 사용한다고 해도 과언이 아닙니다.

만약에 ViewModel의 Unit Test를 만들고 싶다고 가정해봅시다. 하지만 싱글톤을 사용한다면 테스트에서 fail이 떴을 때 이게 ViewModel에서 발생한 에러인지 싱글톤 객체에서 발생한 에러인지 알 방법이 없습니다.

하지만 DI를 사용한다면 ViewModel의 UnitTest할 때 Mock 객체를 전달하므로서 ViewModel만 독립적으로 테스트를 수행할 수 있게 됩니다.

Protocol 정의하기

Mock 객체를 전달할 수 있도록 만들기 위해서는 Protocol을 정의하고 WordBookService가 해당 protocol을 준수하도록 합니다. 그리고 마지막으로 init에서 받는 타입을 클래스가 아니라 Protocol로 바꾸어 주어야 합니다.

//✅ 프로토콜 정의
protocol WordBookServiceProtocol {
    func fetchWordBooks(completionHandler: @escaping ([WordBook]?, Error?) -> Void)
}
class WordBookService: WordBookServiceProtocol { //👉 프로토콜 준수
    
    let account: String
    
    init(account: String = "develop") {
        self.account = account
    }
    
    func fetchWordBooks(completionHandler: @escaping ([WordBook]?, Error?) -> Void) {
        Firestore.firestore()
            .collection(account).document("data").collection("wordBooks")
            .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)
            }
    }
    
}
class ViewModel: ObservableObject {
    @Published var wordBooks: [WordBook] = []
    let dataService: WordBookServiceProtocol //👉 클래스가 아니라 프로토콜로 정의
    
    init(dataService: WordBookServiceProtocol) { //👉 클래스가 아니라 프로토콜로 정의
        self.dataService = dataService
        loadBooks()
    }
    
    func loadBooks() {
        dataService.fetchWordBooks { [weak self] wordbook, error in
            if let wordbook = wordbook {
                self?.wordBooks = wordbook
            }
        }
    }
}

Mock 객체 만들기

이제 테스트에 사용할 Mock 객체를 만들어 봅시다. 임의의 data를 넣어서 테스트에 사용할 수 있습니다. Mock 객체의 역할은 받은 데이터를 단순하게 return (여기서는 completionHandler에 넣어서 실행)하는 역할입니다.

이 객체에서는 에러가 발생하지 않으므로 ViewModel을 테스트할 때 사용하면 발생하는 오류는 모두 ViewModel에서 발생하는 것이라고 단정할 수 있기 때문에 독립적인 테스트가 가능할 수 있게 됩니다.

class MockWordBookService: WordBookServiceProtocol {
    
    let data: [WordBook]
    
    init(data: [WordBook]) {
        self.data = data
    }
    
    func fetchWordBooks(completionHandler: @escaping ([WordBook]?, Error?) -> Void) {
        completionHandler(data, nil)
    }
}

Tip) 주입해야 하는 Dependency가 많을 때

제 앱은 아직 Service 객체가 하나 밖에 없습니다만… 앱이 고도화 되는 경우에는 여러개의 dependency가 생길 수 있습니다. 이 경우에는 Dependency 전체를 가지는 하나의 부모 클래스를 만들어서 주입할 수도 있습니다.

class Dependencies {
    
    let dataService: WordBookServiceProtocol
    
    init(dataService: WordBookServiceProtocol) {
        self.dataService = dataService
    }
    
}
profile
백과사전 보다 항해일지(혹은 표류일지)를 지향합니다.

0개의 댓글