-
[OOP] SOLID 원칙 for iOS객체지향프로그래밍 2024. 3. 2. 17:34
개요
학부 시절에 SOLID 원칙에 대해 열심히 암기했던 기억이 있습니다.
예전에 SOLID 원칙에 대해 잘 지키고 있나요?라는 질문을 받았는데 '네'라는 답을 하기가 꽤 어려웠습니다.
iOS 앱을 만들며 느꼈던 SOLID 원칙을 잘 지키고 있는 지 함께 알아봅시다.
SOLID 원칙이란?
로버트 마틴이 2000년대 초반에 명명한 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙
유지 보수와 확장이 쉬운 시스템을 만들고자 할 때 이 원칙들을 함께 적용할 수 있습니다.
(출처: 위키백과)결국 핵심적인 부분은 유지 보수와 확장이 쉬운 시스템인 것 같습니다.
S: 단일 책임 원칙
Single Responsibility Principle
한 클래스는 하나의 책임만 가져야 한다
해당 원칙은 핵심은 복잡성을 줄이는 것 입니다.
iOS 개발을 하다 보면 MVC, MVVM 등 여러 아키텍처를 접하게 됩니다.
(아키텍처에 대한 설명은 따로 하지 않고 진행하겠습니다)
MVC
MVC의 ViewController는 정말 많은 역할을 하고 있습니다.
사용자의 이벤트를 받고, 이벤트에 대한 동작을 진행하고, 라우팅도 진행하고...
많은 일을 하고 있죠
결국 단일 책임 원칙을 위반하고 있습니다.
ViewController는 UI 로직, 비즈니스 로직, 라우팅 로직 등 많은 책임을 지고 있습니다.
ViewController의 역할 MVVM
MVVM은 비즈니스 로직을 ViewModel로 이전하였습니다.
ViewController는 UI 로직, 라우팅 로직을 책임집니다.
이것에 만족하지 않고 MVVM-C라는 Coordinator 패턴이 들어간 아키텍처도 있습니다.
Coordinator가 라우팅 로직을 책임지며 많은 부분이 분리되었습니다.
MVVM-C의 역할 이처럼 단일 책임 원칙은 아키텍처뿐만 아니라 앱 개발을 하며 Custom View를 만들어 Custom View 끼리 합치는 것 또한 단일 책임 원칙을 지키는 것이라 볼 수 있습니다.
O: 개방/폐쇄 원칙
Open/Closed Principle
소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
즉 새로운 기능을 추가할 때 작성된 코드가 깨지지 않아야 합니다.
앱 개발을 하며 개방/폐쇄 원칙을 가장 어겼던 부분이 바로 enum을 입니다.
한 부분에서만 enum을 사용하면 상관이 없으나 많은 부분에서 enum을 사용하게 되면 케이스가 추가/제거될 때 연관된 코드가 모두 깨지게 됩니다.
enum Fruit { case apple case banana } func calculate(fruit: Fruit) -> Double { switch fruit { case .apple: return 1_000 * 1.1 case .banana: return 2_000 * 1.1 } } func store(fruit: Fruit) { switch fruit { case .apple: storageA.append(fruit) case .banana: storageB.append(fruit) } }
예시 코드처럼 작성되었을 때 만약 Fruit에 새로운 과일이 추가되면 어떻게 될까요?
기존에 Fruit과 연관된 코드는 모두 깨지게 됩니다.
Fruit 예시 이러한 경우에는 Protocol을 이용하여 해결할 수 있습니다.
protocol Vegetable { var cost: Int { get } var storageType: Int { get } } func calculate(vegetable: Vegetable) -> Double { return Double(vegetable.cost) * 1.2 } func store(vegetable: Vegetable) { if let storage = storages[vegetable.storageType] { storages[vegetable.storageType] = storage + [vegetable] } else { storages[vegetable.storageType] = [vegetable] } }
이렇게 해결할 수 있다고 enum 대신 Protocol만을 활용하는 것은 좋지 못한 것 같습니다.
현재 상황에서 가장 적절한 것을 사용하는 게 중요하다고 생각합니다.
L: 리스코프 치환 원칙
Liskov Substitution Principle
프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
이는 부모 클래스 대신에 자식 클래스를 넣어도 잘 동작해야 한다는 의미입니다.
즉 자식 클래스가 부모의 동작을 깨뜨리면 안 됩니다.
iOS 개발을 하며 아래와 같은 코드를 많이 보셨을 겁니다.
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
NSCoder를 이용한 초기화를 막는 것입니다.
즉 부모 클래스 동작을 제한하는 것입니다.
required init?(coder: NSCoder) { super.init(coder: coder) }
위와 같이 초기화를 하도록 만들면 해결됩니다.
protocol Animal { var age: Int { get } var isMale: Bool { get } } class Hospital { func doSomething(animal: Animal) { if animal.age > 10 { print("old") } else { print("young") } } } class MyHospital: Hospital { override func doSomething(animal: Animal) { // 사전 조건 강화 if animal.isMale { return } super.doSomething(animal: animal) } }
다른 예시로는 위처럼 부모 클래스의 동작에 사전 조건을 강화하여 위반하는 사례도 있습니다.
Hospital과 다르게 MyHospital은 출력문이 호출되지 않겠죠
I: 인터페이스 분리 원칙
Interface Segregation Principle
특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
자신이 사용하지 않는 메서드가 있으면 안 됩니다.
protocol HomeUseCase { func requestValues() -> [String] func addSomething(value: String) func removeSomething(value: String) } final class HomeDetailViewModel { private let useCase: HomeUseCase init(useCase: HomeUseCase) { self.useCase = useCase } // 모두 접근 가능 func doSomething() { useCase.requestValues() useCase.addSomething(value: "A") useCase.removeSomething(value: "A") } }
홈 상세 화면에서는 requestValues만 필요한데 위와 같이 UseCase를 사용하면 addSomething, removeSomething과 같이 사용하지 않는 메서드도 사용할 수 있게 됩니다.
물론 그냥 '사용 안 하면 되지 않나?'라고 생각할 수 있지만 의도와 다르게 다른 개발자가 홈 상세 화면에서 해당 기능을 사용할 수도 있습니다.
따라서 의도대로 특정 부분만 열어주어야 합니다.
typealias MyUseCase = MyProfileUseCase & FriendUseCase protocol MyProfileUseCase { func requestName() -> String func requestAge() -> Int } protocol FriendUseCase { func requestFriends() -> [String] }
위와 같은 방식으로 인터페이스를 분리하여 typealias로 선언하는 방식도 존재합니다.
final class MyUseCaseImp: MyUseCase { func requestName() -> String { return "A" } func requestAge() -> Int { return 1 } func requestFriends() -> [String] { return ["B", "C"] } } let friendUseCase: FriendUseCase = MyUseCaseImp() let profileUseCase: MyProfileUseCase = MyUseCaseImp()
위와 같이 구현체를 구현한 후 생성하면 열어준 인터페이스만 접근이 가능하게 됩니다.
D: 의존관계 역전 원칙
Dependency Inversion Principle
프로그래머는 "추상화에 의존해야지, 구체화에 의존하면 안 된다"
final class Repository { func create(value: Int) { /* 생략 */ } func read(value: Int) -> Int { 1 } func update(value: Int) { /* 생략 */ } func delete(value: Int) { /* 생략 */ } } final class ViewModel { private let repository: Repository init(repository: Repository) { self.repository = repository } }
ViewModel이 Repository 구현체를 의존 위와 같은 코드가 있다고 가정할 때,
만약 ViewModel에 있는 Repository가 CoreData나 SwiftData 또는 Realm으로 변경된다면 어떻게 해결해야 할까요?
1. Repository 자체를 변경한다.
2. ViewModel 전용 새로운 Repository를 만든다.
일단 Repository가 다른 곳에서도 사용될 수 있으므로 1번보다는 2번이 적합해 보입니다.
그러나 이 방법이 과연 옳은 것일까요?
결국 새롭게 변경되는 것은 Repository 부분인데 이로 인해 ViewModel이 깨지는 상황입니다.
이는 개방/폐쇄 원칙을 위반하는 것입니다.
또한 개발을 할 때 실제 데이터베이스를 사용하기 때문에 옳지 않은 방법입니다.
2만 개 정도의 데이터를 보여주고 싶다면 데이터베이스에 2만 개의 데이터를 하나하나 추가해야 됩니다.
protocol MyRepository { func create(value: Int) func read(value: Int) -> Int func update(value: Int) func delete(value: Int) } final class CoreDataRepository: MyRepository { func create(value: Int) { /* 생략 */ } func read(value: Int) -> Int { 1 } func update(value: Int) { /* 생략 */ } func delete(value: Int) { /* 생략 */ } } final class MemoryRepository: MyRepository { func create(value: Int) { /* 생략 */ } func read(value: Int) -> Int { 1 } func update(value: Int) { /* 생략 */ } func delete(value: Int) { /* 생략 */ } } final class MyViewModel { private let repository: MyRepository // 인터페이스 의존 init(repository: MyRepository) { self.repository = repository } }
ViewModel이 Repository 인터페이스에 의존 구현체가 아닌 인터페이스에 의존하게 하면 됩니다.
먼저 인터페이스를 생성하여 청사진을 그리고 그것에 대한 구현체를 만들면 됩니다.
우리는 MyViewModel에 상황에 적합한 Repository 구현체를 만들어 주입해 주면 됩니다.
이를 통해 MyViewModel과 MyRepository의 의존성이 역전이 됩니다.
결론
SOLID 원칙의 목적은 유지 보수와 확장이 쉬운 시스템이다.
iOS 개발에도 SOLID 원칙을 녹이면 좋다.
그렇다고 모든 부분에 강요하는 것은 좋지 못하다. 상황에 따라 잘 사용해야 한다.
해당 게시글의 전체 소스 코드는 아래 링크에서 보실 수 있습니다.
https://github.com/hogumachu/Laboratory/tree/master/SOLIDSample
참고
https://ko.wikipedia.org/wiki/SOLID_(%EA%B0%9D%EC%B2%B4_%EC%A7%80%ED%96%A5_%EC%84%A4%EA%B3%84)
SOLID (객체 지향 설계) - 위키백과, 우리 모두의 백과사전
위키백과, 우리 모두의 백과사전. 컴퓨터 프로그래밍에서 SOLID란 로버트 마틴[1][2]이 2000년대 초반[3]에 명명한 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙을 마이클 페더스가 두문자어
ko.wikipedia.org
'객체지향프로그래밍' 카테고리의 다른 글
[OOP] 객체지향프로그래밍 기초 (0) 2023.08.13