관리 메뉴

김종권의 iOS 앱 개발 알아가기

[Clean Architecture] 5. 코드로 알아보는 SOLID - DIP(Dependency Inversion Principle, testable) 의존성 역전 원칙 본문

Clean Architecture/Clean Architecture 코드

[Clean Architecture] 5. 코드로 알아보는 SOLID - DIP(Dependency Inversion Principle, testable) 의존성 역전 원칙

jake-kim 2021. 9. 20. 18:26

0. 코드로 알아보는 SOLID - 클래스 다이어그램 필수 표현

1. 코드로 알아보는 SOLID - SRP(Single Responsibility Principle) 단일 책임 원칙

2. 코드로 알아보는 SOLID - OCP(Open Close Principle) 개방 폐쇄 원칙

3. 코드로 알아보는 SOLID - LSP(Liskov Substitution Principle) 리스코프 치환 원칙

4. 코드로 알아보는 SOLID - ISP(Interface Segregation Principle) 인터페이스 분리 원칙

5. 코드로 알아보는 SOLID - DIP(Dependency Inversion Principle, testable) 의존성 역전 원칙

6. 코드로 알아보는 SOLID - Coordinator 패턴 화면전환

7. 코드로 알아보는 SOLID - Network, REST (URLSession, URLRequest, URLSessionDataTask)

8. 코드로 알아보는 SOLID - 캐싱 Disk Cache (UserDefeaults, CoreData)

DIP

  • Dependency Inversion Principle
  • 의존성 역전 원칙
  • 개념: 핵심 부분을 담당하는 모듈의 제어흐름과 다른 모듈들의 의존 방향이 반대
  • 방법: 변동성이 큰 구현체에 의존하지 않고 추상 클래스에만 의존해야 한다는 정의
    • interface는 구현체보다 변동성이 낮은점을 이용

Abstract Factory를 통해 구현

  • 이상적인 의존관계: 핵심체인 Application의 제어 흐름과 다른 클래스의 의존 방향이 반대
    • 좌측: factory를 사용하지 않은 경우
    • 우측: factory를 사용한 경우

  • 좌측 Factory를 안쓴 경우 코드
// 구현체 service

class ServiceConcrete_NotFactory {
    func service() {}
}
// 핵심체인 Application에서 구현체에 의존하는 상태

class Application_NotFactory {
    var service: ServiceConcrete_NotFactory?

    init() {
        service = makeService()
    }

    /// 구현체에 의존하는 상태
    func makeService() -> ServiceConcrete_NotFactory {
        return ServiceConcrete_NotFactory()
    }
}
  • Factory를 사용한 경우

  • Fafctory
// Service도 protocol이므로 추상 클래스끼리 의존성

protocol ServiceFactory {
    func makeService() -> Service
}
// 핵심체가 아닌 Factory클래스의 구현체에서는 다른 구현체 의존해도 무방

class ServiceConcrete_ApplyFactory: ServiceFactory {
    func makeService() -> Service {
        return ServiceConcreteImpl()
    }
}
  • Service
protocol Service {
    func service()
}
class ServiceConcreteImpl: Service {
    func service() {

    }
}
  • Application
// 핵심체인 Application에서는 모두 protocol에만 의존

class Application_ApplyFactory {
    let serviceFactory: ServiceFactory
    var service: Service?

    init(serviceFactory: ServiceFactory) {
        self.serviceFactory = serviceFactory
        service = makeService()
    }

    func makeService() -> Service {
        return serviceFactory.makeService()
    }
}

DIP를 이용한 코드의 장점

  • 변경되기 쉬운것에 의존하지 않기 때문에, 변경이 잦아도 코드에서 변경해야할 부분이 적은 장점
  • testable한 코드 작성에 용이: 핵심체는 protocol에만 의존하기 때문에 protocol의 구현체를 갈아끼워, testable 코드 작성에 용이

DIP를 이용한 testable 코드 작성 예제

  • 구매할 상품을 고르고, 특가에 따라 금액을 달리하여 계산하도록 하는 앱

  • business logic 요구사항: "특가"이면 원가의 * 0.8만 계산 (useCase에 반영)

  • 요구사항에서 사용되는 Entity 정의
struct Product {
    let title: String
    let price: Double
    let isSale: Bool
    var isSelected: Bool = false
}
  • ProductListUseCase: 요구사항에 따른 business logic이 존재
protocol ProductListUseCase {
    func calc(_ productList: [Product]) -> Double
}

final class ProductListUseCaseImpl: ProductListUseCase {
    func calc(_ productList: [Product]) -> Double {
        var totalFee = 0.0
        productList.forEach { product in
            totalFee += product.isSale ? product.price * 0.8 : product.price
        }
        return totalFee
    }
}
  • ProductListViewModel: UseCase를 가지고 있고 Input, Output에 따라 useCase호출하고 viewController에 값 던져주는 역할
protocol ProductListViewModelInput {
    func didTapCalButton()
    func didTapBuyButton()
    func didTapCellForRow(at index: Int)
}

protocol ProductListViewModelOutput {
    var totalFee: BehaviorRelay<String> { get }
    var sampleDataSource: [Product] { get }
}

protocol ProductListViewModel: ProductListViewModelInput, ProductListViewModelOutput {}

class ProductListViewModelImpl: ProductListViewModel {

    let productListUseCase: ProductListUseCase
    init(productListUseCase: ProductListUseCase) {
        self.productListUseCase = productListUseCase
    }

    // Outout

    var totalFee: BehaviorRelay<String> = .init(value: "총 금액 = \(0)")
    var sampleDataSource = [Product(title: "아이패드 프로 5세대(13`)", price: 300, isSale: true),
                            Product(title: "아이폰13", price: 200, isSale: false),
                            Product(title: "애플워치7", price: 100, isSale: false),
                            Product(title: "아이폰SE", price: 50, isSale: true),
                            Product(title: "아이폰12", price: 130, isSale: true),
                            Product(title: "애플워치6", price: 70, isSale: true),]

    // Input

    func didTapCalButton() {
        let resultFee = productListUseCase.calc(sampleDataSource.filter { $0.isSelected })
        totalFee.accept("총 금액 = \(resultFee)")
    }

    func didTapBuyButton() {
        totalFee.accept("구매 완료")
    }

    func didTapCellForRow(at index: Int) {
        sampleDataSource[index].isSelected.toggle()
    }
}
  • DIP를 위하여 Abstract Factory패턴인 ProductListDIContainer
final class ProductListDIContainer {

    lazy var productListViewController: ProductListViewController = {
        return ProductListViewController.create(with: productListViewModel)
    }()

    // Private

    private lazy var productListUseCaseImpl: ProductListUseCase = {
        return ProductListUseCaseImpl()
    }()

    private lazy var productListViewModel: ProductListViewModel = {
    return ProductListViewModelImpl(productListUseCase: productListUseCaseImpl)
    }()
}

Test 코드 작성

  • 현재 핵심적인 모듈인 ViewModel, UseCase는 protocol로 의존하게 되어있으므로 testable 코드
  • ProductListUseCase 테스트 코드 작성: UseCase를 테스트할 땐 UseCase를 두고 나머지 요인을 변경해가며 기대하는 값이 잘 나오는지 체크하는 것
import XCTest
@testable import SOLID

// useCase를 테스트하는 경우 - useCase는 그대로 두고 다른 요인을 바꾸어도 기대하는 값이 나오는지 체크
class ProductListUseCaseTests: XCTestCase {
    var sampleDataSource = [Product(title: "아이패드 프로 5세대(13`)", price: 300, isSale: true),
                            Product(title: "아이폰13", price: 200, isSale: false),
                            Product(title: "애플워치7", price: 100, isSale: false),
                            Product(title: "아이폰SE", price: 50, isSale: true),
                            Product(title: "아이폰12", price: 130, isSale: true),
                            Product(title: "애플워치6", price: 70, isSale: true),]

    func testProductListUseCase_whenAllIsNotSaleProduct_thenCalculateAccuracy() {
        // given
        let useCase = ProductListUseCaseImpl()

        // when
        let data = sampleDataSource.map { Product(title: $0.title, price: $0.price, isSale: false) }

        // then
        let result = useCase.calc(data)
        let expectValue = data.map { $0.price }.reduce(0, +)

        XCTAssertTrue(expectValue == result)
    }

    func testProductListUseCase_whenAllIsAllSaleProduct_thenCalculateAccuracy() {
        // given
        let useCase = ProductListUseCaseImpl()

        // when
        let data = sampleDataSource.map { Product(title: $0.title, price: $0.price, isSale: true) }

        // then
        let result = useCase.calc(data)
        let expectValue = data.map { $0.price * 0.8 }.reduce(0, +)

        XCTAssertTrue(expectValue == result)
    }

}
  • ViewModel 테스트: ViewModel은 그대로 두고, ViewModel에서 사용하는 UseCase, api, repository등을 변경해가며 viewModel의 input과 ouput을 주면서 기대하는 값이 나오는지 테스트
@testable import SOLID
import XCTest

// ViewModel 테스트 - ViewModel은 그대로 두고, 안에서 사용하는 UseCase를 mock으로 바꾸어 가며 테스트
class ProductListViewModelTests: XCTestCase {

    let sampleDataSourceForTest = [Product(title: "아이패드 프로 5세대(13`)", price: 300, isSale: true),
                                   Product(title: "아이폰13", price: 200, isSale: false),
                                   Product(title: "애플워치7", price: 100, isSale: false),
                                   Product(title: "아이폰SE", price: 50, isSale: true),
                                   Product(title: "아이폰12", price: 130, isSale: true),
                                   Product(title: "애플워치6", price: 70, isSale: true),]

    class ProductListUseCaseMock: ProductListUseCase {
        func calc(_ productList: [Product]) -> Double {
            var totalFee = 0.0
            productList.forEach { product in
                totalFee += product.isSale ? product.price * 0.5 : product.price
            }
            return totalFee
        }
    }

    func test_whenUseCaseChanged_thenSuccessAccuracy() {
        // given
        let useCase = ProductListUseCaseMock()
        let viewModel = ProductListViewModelImpl(productListUseCase: useCase)

        // when (= input)
        viewModel.didTapCellForRow(at: 0)
        viewModel.didTapCellForRow(at: 1)
        viewModel.didTapCalButton()

        // then (= output)
        XCTAssertEqual(viewModel.totalFee.value, "총 금액 = \(300 * 0.5 + 200)")
    }
}

테스트 성공

* 이 밖의 ProductViewController, ProductTableViewCell 및 전체 소스 코드: https://github.com/JK0369/SOLID

Comments