관리 메뉴

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

[Clean Architecture] 2. 코드로 알아보는 SOLID - OCP(Open Close Principle) 개방 폐쇄 원칙 본문

Clean Architecture/Clean Architecture 코드

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

jake-kim 2021. 9. 18. 00:52

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)

OCP

  • Open Close Principle
  • 개방 폐쇄 원칙
  • 요구사항을 받았을 때 "확장에는 열려있어야 하고 변경에는 닫혀있어야 하는 원칙"

OCP를 준수하는 방법

  • 우선순위가 높은 것은 다른것에 의존하지 않도록 컴포넌트 계층구조를 설계

  • Interactor가 가장 우선순위가 높은 형태

코드에서의 OCP

  • 실제 코드에서는 Interactor에서도 결국 financial 데이터에 관해서 주입 받아서 처리해야하는데, 이 때 결국 Database영역을 사용하는 꼴이 되므로, 참조는 Interface를 통해서 참조하도록 설계 (타입은 protocol이고 외부에서 주입해주는 것은 구현체를 주입)

예제) 사용자가 color버튼을 탭하는 횟수 계산하는 앱

  • Actor의 요구사항: 한쪽 버튼이 다른쪽 버튼보다 2배 이상 많이 누른 경우, 작은쪽 버튼을 2배한 다음에 +1을 하라는 요구사항 존재
  • 설계
    • Interactor인 UseCase들이 가장 중요하므로, ColorUseCase는 그 누구에게도 의존하지 않는 상태

  • Entity(business logic에서 바로 사용되는 모델)인 ColorModel 정의
struct ColorModel {
    enum ColorType: String {
        case blue = "blue"
        case red = "red"
    }
    let color: ColorType
    var currentBlueColor: Int = 0
    var currentRedColor: Int = 0
}
  • Keychain을 사용할 수 있는 KeychainRepository 정의
import KeychainAccess

public protocol KeychainRepository {
    func save(_ key: String, _ value: String)
    func get(_ key: String) -> String?
    func delete(_ key: String)
    func removeAll()
}

class KeychainRepositoryImpl: KeychainRepository {
    public static let shared = KeychainRepositoryImpl()
    private init() {}

    let keychain = Keychain()

    func save(_ key: String, _ value: String) {
        do {
            try keychain.set(value, key: key)
        } catch {
            print(error.localizedDescription)
            return
        }
    }

    func get(_ key: String) -> String? {
        do {
            guard let key = try keychain.get(key) else { return nil }
            return key
        } catch {
            print(error.localizedDescription)
            return nil
        }
    }

    func delete(_ key: String) {
        do {
            try keychain.remove(key)
        } catch {
            print(error.localizedDescription)
        }
    }

    func removeAll() {
        do {
            try keychain.removeAll()
        } catch {
            print(error.localizedDescription)
        }
    }
}
  • KeychinService와 KeychainServiceImpl 정의
import KeychainAccess

fileprivate struct KeychainKey {
    static let blueCount = "blueCount"
    static let redCount = "redCount"
}

protocol KeychainService {
    func saveBlueCount(_ count: Int)
    func saveRedCount(_ count: Int)

    func getBlueCount() -> Int?
    func getRedCount() -> Int?
}

class KeychainServiceImpl: KeychainService {
    private let keychainRepository: KeychainRepository

    init(keychainRepository: KeychainRepository) {
        self.keychainRepository = keychainRepository
    }

    // Save

    public func saveBlueCount(_ count: Int) {
        keychainRepository.save(KeychainKey.blueCount, String(count))
    }

    public func saveRedCount(_ count: Int) {
        keychainRepository.save(KeychainKey.redCount, String(count))
    }

    // Get

    public func getBlueCount() -> Int? {
        return Int(keychainRepository.get(KeychainKey.blueCount) ?? "0")
    }

    public func getRedCount() -> Int? {
        return Int(keychainRepository.get(KeychainKey.redCount) ?? "0")
    }
}
  • ColorUseCase와 ColorUseCaseImpl 정의
protocol ColorUseCase {
    func updateColor(with: ColorModel, completion: (ColorModel) -> Void)
    func getCurrentColorCount(completion: (ColorModel) -> Void)
}

final class ColorUseCaseImpl: ColorUseCase {
    private let keychainService: KeychainService

    init(keychainService: KeychainService) {
        self.keychainService = keychainService
    }

    func updateColor(with colorModel: ColorModel, completion: (ColorModel) -> Void) {
        var currentBlueCnt = keychainService.getBlueCount() ?? 0
        var currentRedCnt = keychainService.getRedCount() ?? 0

        // Business logic: 서로 두 배 이상 차이나면 적은 수를 *2 후 추가하는 로직
        switch colorModel.color {
        case .blue:
            if currentBlueCnt * 2 < currentRedCnt {
                currentBlueCnt = currentBlueCnt * 2 + 1
            } else {
                currentBlueCnt += 1
            }
        case .red:
            if currentRedCnt * 2 < currentBlueCnt {
                currentRedCnt = currentRedCnt * 2 + 1
            } else {
                currentRedCnt += 1
            }
        }

        updateColor(currentBlueCnt, currentRedCnt)

        let colorModel = ColorModel(color: colorModel.color,
                                    currentBlueColor: currentBlueCnt,
                                    currentRedColor: currentRedCnt)
        completion(colorModel)
    }

    func getCurrentColorCount(completion: (ColorModel) -> Void) {
        let currentBlueCnt = keychainService.getBlueCount() ?? 0
        let currentRedCnt = keychainService.getRedCount() ?? 0
        let colorModel = ColorModel(color: .blue,
                                    currentBlueColor: currentBlueCnt,
                                    currentRedColor: currentRedCnt)
        completion(colorModel)
    }

    private func updateColor(_ blueColorCount: Int, _ redColorCount: Int) {
        keychainService.saveBlueCount(blueColorCount)
        keychainService.saveRedCount(redColorCount)
    }
}
  • ViewModel 정의
import RxSwift
import RxCocoa

protocol ColorViewModelInput {
    func viewDidLoad()
    func didTapBlueButton()
    func didTapRedButton()
}

protocol ColorViewModelOutput {
    var buttonInfo: BehaviorRelay<String> { get }
}

protocol ColorViewModel: ColorViewModelInput, ColorViewModelOutput {}

final class ColorViewModelImpl: ColorViewModel {

    // Output

    var buttonInfo: BehaviorRelay<String> = .init(value: "")

    private var colorUseCase: ColorUseCase?

    init(colorUseCase: ColorUseCase) {
        self.colorUseCase = colorUseCase
    }

    // Input

    func viewDidLoad() {
        loadColorInfo()
    }

    func didTapBlueButton() {
        colorUseCase?.updateColor(with: ColorModel(color: .blue), completion: { colorModel in
            buttonInfo.accept("파란 버튼 누적 카운트 = \(colorModel.currentBlueColor),\n빨간 버튼 누적 카운트 = \(colorModel.currentRedColor)")
        })
    }

    func didTapRedButton() {
        colorUseCase?.updateColor(with: ColorModel(color: .red), completion: { colorModel in
            buttonInfo.accept("파란 버튼 누적 카운트 = \(colorModel.currentBlueColor),\n빨간 버튼 누적 카운트 = \(colorModel.currentRedColor)")
        })
    }

    // Private

    private func loadColorInfo() {
        colorUseCase?.getCurrentColorCount(completion: { colorModel in
            buttonInfo.accept("파란 버튼 누적 카운트 = \(colorModel.currentBlueColor),\n빨간 버튼 누적 카운트 = \(colorModel.currentRedColor)")
        })
    }
}
  • ColorViewController 정의
import SnapKit
import RxSwift

class ColorViewController: UIViewController {

    private let disposeBag = DisposeBag()
    var viewModel: ColorViewModel!

    lazy var buttonContainerStackView: UIStackView = {
        let view = UIStackView()
        view.spacing = 16.0

        return view
    }()

    lazy var leftButton: UIButton = {
        let button = UIButton()
        button.backgroundColor = .blue
        button.setTitle("파란색 버튼", for: .normal)

        return button
    }()

    lazy var rightButton: UIButton = {
        let button = UIButton()
        button.backgroundColor = .red
        button.setTitle("빨간색 버튼", for: .normal)

        return button
    }()

    lazy var infoLabel: UILabel = {
        let label = UILabel()
        label.textColor = .black
        label.font = .systemFont(ofSize: 20)
        label.numberOfLines = 0

        return label
    }()

    init(viewModel: ColorViewModel) {
        self.viewModel = viewModel

        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init?(coder: NSCoder) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        setupViews()
        addSubviews()
        makeConstraints()
        bindInput()
        bindOutput()
    }

    private func setupViews() {
        view.backgroundColor = .white
    }

    private func addSubviews() {
        view.addSubview(buttonContainerStackView)
        [leftButton, rightButton].forEach { buttonContainerStackView.addArrangedSubview($0) }
        view.addSubview(infoLabel)
    }

    private func makeConstraints() {
        buttonContainerStackView.snp.makeConstraints { maker in
            maker.centerX.centerY.equalToSuperview()
        }

        infoLabel.snp.makeConstraints { maker in
            maker.top.equalTo(buttonContainerStackView.snp.bottom).offset(16)
            maker.centerX.equalTo(view.snp.centerX)
        }
    }

    // Input

    private func bindInput() {
        leftButton.rx.tap
            .subscribe(onNext: { [weak self] in
                self?.viewModel.didTapBlueButton()
            }).disposed(by: disposeBag)

        rightButton.rx.tap
            .subscribe(onNext: { [weak self] in
                self?.viewModel.didTapRedButton()
            }).disposed(by: disposeBag)
    }

    // Output

    private func bindOutput() {
        viewModel.buttonInfo.subscribe(onNext: { [weak self] in self?.updateInfoLabel(to: $0) }).disposed(by: disposeBag)
    }

    private func updateInfoLabel(to contents: String) {
        infoLabel.text = contents
    }
}

* 전체 소스 코드 `OCP` 그룹 참고: https://github.com/JK0369/SOLID

Comments