본문 바로가기

iOS/Swift

Swift에서 DI/DIP 이해하기

💉 DI ?

DI(Dependency Injection)는 의존성 주입의 약자로 상위 모듈에서 하위 모듈을 불러와서 제어하기 위해 사용하는 프로그래밍 기법이다. 하위 모듈의 프로퍼티나 메소드를 상위 모듈에서 사용하기 위해 필요하다. 간단한 SwiftUI 예제를 통해서 알아보자!

// DI: Dependency Injection (의존성 주입)
struct DIView: View {
    @StateObject var viewModel: DIViewModel // 👈 DI!
    
    var body: some View {
        Text(viewModel.data)
    }
}

class DIViewModel: ObservableObject {
    @Published var data = "wait some data..."
    
    let repository: DIRepository
    
    init(repository: DIRepository) { // 👈 DI!
        self.repository = repository
        repository.fetchData { [weak self] in self?.data = $0 }
    }
}

struct DIRepository {
    func fetchData(completion: @escaping (String) -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            completion("data is set")
        }
    }
}

struct DIView_Previews: PreviewProvider {
    static var previews: some View {
        DIView(viewModel: DIViewModel(repository: DIRepository())) // 👈 DI! DI!
    }
}

 

위 코드는 3초 후에 화면에 표시되는 글자가 "wait some data..." 에서 "data is set"으로 변경하는 코드이다. 이 예제에서는 DIView > DIViewModel > DIRepository 순서로 의존성을 주입했고 의존성을 띄고있게된다.

 

❗️ DI의 문제점

위와 같은 코드는 각각 DIViewModel, DIRepository라는 클래스와 객체만을 타입으로 받고 다른 클래스나 객체는 받을 수가 없게 된다. 이러한 상황은 서로 강한 결합을 가지게 되는데 강한 결합을 가진 코드는 상위 모듈이 하위 모듈을 의존하므로 테스트가 어렵거나 불편하게 되고 하위 모듈을 의존하는 상위 모듈을 쉽게 분리할 수 있게 된다. 이러한 강한 결합이 있는 코드는 후술할 DIP로 느슨한 결합으로 바꿀수 있다.

 

💉 DIP ?

DIP(Dependency Inversion Principle)는 SOLID 중 D에 해당하는 원칙으로 해석하면 의존성 역전 원칙이다. 앞선 코드같은 강한 결합의 코드를 구현체에 의존하지 않고 추상화에 의존해서 결합을 느슨하게 만들어준다. DIP는 DI에서 의존성을 주입하는 방법중의 하나이다. 위의 코드는 생성자 주입 방법인데 이 것을 DIP를 사용해 리팩토링 해보자!

struct DIView: View {
    @StateObject var viewModel: DIViewModel

    var body: some View {
        Text(viewModel.data)
    }
}

class DIViewModel: ObservableObject {
    @Published var data = "wait some data..."

    let repository: SomeRepository // 👈 SomeRepository 타입을 변수로 받는다.

    init(repository: SomeRepository) { // 👈 SomeRepository 생성자로 받는다.
        self.repository = repository
        repository.fetchData { [weak self] in self?.data = $0 }
    }
}

// 👇 DIRepository를 추상화한 SomeRepository를 생성
protocol SomeRepository {
    func fetchData(completion: @escaping (String) -> Void)
}

struct DIRepository: SomeRepository { // 👈 SomeRepository를 채택
    func fetchData(completion: @escaping (String) -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            completion("data is set")
        }
    }
}

먼저 DIViewModel과 DIRepository의 의존성을 느슨하게 해주었다. DIP의 핵심은 추상화에 의존하는 것이기 때문에 SomeRepository라는 프로토콜을 만들어 주었고 이 것을 DIRepository가 채택하게 하였다. DIViewModel은 SomeRepository를 변수로 받아 추상화를 의존해서 DIRepository와는 완전히 분리된 코드가 되었다.

struct DIView: View {
    @StateObject var viewModel: SomeViewModel // 👈 SomeViewModel을 채택
    
    var body: some View {
        Text(viewModel.data)
    }
}

// 👇 DIViewModel을 추상화한 SomeViewModel를 생성
protocol SomeViewModel: ObservableObject {
    var data: String { get set }
}

class DIViewModel: SomeViewModel { // 👈 SomeViewModel을 채택
    @Published var data = "wait some data..."

    let repository: SomeRepository

    init(repository: SomeRepository) {
        self.repository = repository
        repository.fetchData { [weak self] in self?.data = $0 }
    }
}

protocol SomeRepository {
    func fetchData(completion: @escaping (String) -> Void)
}

struct DIRepository: SomeRepository {
    func fetchData(completion: @escaping (String) -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            completion("data is set")
        }
    }
}

다음은 DIView와 DIViewModel을 분리하는 코드이다. 위와 마찬가지로 DIViewModel을 추상화한 SomeViewModel을 만들어 주었고 이것을 DIViewModel은 채택하고 DIView는 의존하게 하였다. DIView에서 @StateObject 변수로 받아야 하므로 SomeViewModel 프르토콜 자체를 ObservableObject를 채택하였다. 하지만 이렇게 하면...!

ObservableObject는 준수했으므로 첫 번째 오류는 가볍게 무시!

구조체는 추상화를 타입으로 직접 받을수가 없으므로 아래와 같이 DIView에 Generic을 추가하여 오류를 해결할 수 있다!

struct DIView<VM: SomeViewModel>: View { // 👈 SomeViewModel을 준수하는 VM을 정의
    @StateObject var viewModel: VM // 👈 VM이라는 구현체를 변수로 받는다.
    
    var body: some View {
        Text(viewModel.data)
    }
}

 

❓ Why DIP ?

DIP는 각 클래스나 구조체 간의 결합도를 낮추고 테스트를 용이하게 하기위한 방법이다. 다음 상황으로 DIP의 장점을 알아보자!

protocol SomeRepository {
    func fetchData(completion: @escaping (String) -> Void)
}

struct DIRepository: SomeRepository {
    func fetchData(completion: @escaping (String) -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            completion("data is set")
        }
    }
}

// -- 여기까지가 기존 코드 --

struct DebugDIRepository: SomeRepository {
    func fetchData(completion: @escaping (String) -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            completion("debug data is set")
        }
    }
}

struct TestDIRepository: SomeRepository {
    func fetchData(completion: @escaping (String) -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            completion("test data is set")
        }
    }
}

struct DIView_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            // For release
            DIView(viewModel: DIViewModel(repository: DIRepository()))
            // For debug
            DIView(viewModel: DIViewModel(repository: DebugDIRepository()))
            // For test
            DIView(viewModel: DIViewModel(repository: TestDIRepository()))
        }
    }
}

DIViewModel는 SomeRepository를 준수하는 구현체를 넘겨 주면 되므로 쉽게 DIRepository, DebugDIRepository, TestDIRepository로 구분해서 손쉽게 Repository를 갈아 끼워 넣을 수 있게 되었다. 같은 방법으로 DIViewModel도 디버그와 테스트 구현체를 만들어보자

protocol SomeViewModel: ObservableObject {
    var data: String { get set }
}

class DIViewModel: SomeViewModel {
    @Published var data = "wait some data..."

    let repository: SomeRepository

    init(repository: SomeRepository) {
        self.repository = repository
        repository.fetchData { [weak self] in self?.data = $0 }
    }
}

// -- 여기까지가 기존 코드 --

class DebugDIViewModel: SomeViewModel {
    @Published var data = "debug wait some data..."

    let repository: SomeRepository

    init(repository: SomeRepository) {
        self.repository = repository
        repository.fetchData { [weak self] in self?.data = $0 }
    }
}

class TestDIViewModel: SomeViewModel {
    @Published var data = "test wait some data..."

    let repository: SomeRepository

    init(repository: SomeRepository) {
        self.repository = repository
        repository.fetchData { [weak self] in self?.data = $0 }
    }
}

struct DIView_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            // For release
            DIView(viewModel: DIViewModel(repository: DIRepository()))
            // For debug
            DIView(viewModel: DebugDIViewModel(repository: DebugDIRepository()))
            // For test
            DIView(viewModel: TestDIViewModel(repository: TestDIRepository()))
        }
    }
}

DIViewModel도 똑같이 SomeViewModel을 준수하는 DebugDIViewModel과 TestDIViewModel을 만들어서 DIView에 구현체를 주입시켰다. 이처럼 DIP를 적용하여 DI를 한다면은 각 구현체 간의 결합도를 낮춰 테스트가 용이한 코드를 작성할 수 있게 된다!