모든 객체지향 프로그래밍에서 사용되는 원칙인 SOLID를 공부해 볼 것입니다.
열심히 읽고, 열심히 공부하고 코드에 100% 반영은 어렵겠지만, 점차 코드에 객체지향맛이 나도록 코딩을 해 보는것이 목적입니다.
Swift는 객체지향이고, 프로토콜형 언어이며, 함수형 언어라는 점에서 SOLID를 어떻게 적용해야 할 지를 정리해 보겠습니다.
우선, SOLID는 약자인데요,
- S (Single Responsibility Principle) : 단일 책임 원칙
- O (Open/Close Principle) : 개방/폐쇄 원칙
- L (Liscov Substitution Principle) : 리스코브 치환 원칙
- I (Interface Segregation Principle) : 인터페이스 분리 원칙
- D (Dependency Inversion Principle) : 의존성 역전 원칙
위와 같은 뜻을 가지고 있습니다. SOLID 원칙을 적용하면 무엇이 좋은지를 알아보고 공부해 보겠습니다.
ref : https://marcosantadev.com/solid-principles-applied-swift/
장점
1. 재사용과 유지관리가 쉬운, 변경에 유연한 코드를 가지게 됩니다.
이는 튼튼한 소프트웨어를 만들 수 있게 하며, 높은 확장성을 가지게 합니다.
2. 높은 응집력과 낮은 결합도 ( High Cohesion, Low coupling ) 을 전공 책에서 보는게 아니고 내 코드에서 볼 수 있음!
단점 개선
아래와 같은 단점을 개선할 수 있습니다.
1. Fragility : 작은 변화가 큰 버그를 일으킬 수 있다. 테스트가 용이하지 않다
2. Immobility : 재사용성 저하. 불필요하게 묶여있는 의존성을 분리 (detach)
3. Rigidity : 여러곳에 묶여 있어서 조그만 변화에도 많은 노력을 들여야 함
# Single Responsibility Principle (단일 책임 원칙 - SRP)
단일 클래스는 단일 책임을 가지고 있어야 합니다. 이는 코드의 응집성과 연관이 있습니다.
class Handler {
func handle() {
let data = requestDataToAPI()
let array = parse(data: data)
saveToDB(array: array)
}
private func requestDataToAPI() -> Data {
// send API request and wait the response
}
private func parse(data: Data) -> [String] {
// parse the data and create the array
}
private func saveToDB(array: [String]) {
// save the array in a database (CoreData/Realm/...)
}
}
다음과 같은 Handler Class가 있습니다. 세가지 일을 하는것을 볼 수 있는데요,
- API 호출(요청)
- 데이터 파싱
- DB에 저장
이 책임들을 각각의 작은 클래스로 분리해주어야 합니다.
class LoginUser {
let apiHandler: APIHandler
let decodeHandler: DecodeHandler
let databaseHandler: DataBaseHandler
init(apiHandler: APIHandler, decodeHandler: DecodeHandler, databaseHandler: DataBaseHandler) {
self.apiHandler = apiHandler
self.decodeHandler = decodeHandler
self.databaseHandler = databaseHandler
}
func login() {
let data = apiHandler.reqeuestAPI()
let dbArray = decodeHandler.decodeData(data: data)
databaseHandler.saveDB(user: user)
}
}
class APIHandler {
func reqeuestAPI() -> Data {
// Call server to authenticate and return user's info
}
}
class DecodeHandler {
func decodeData(data: Data) -> User {
// Decode data (Codable protocol) into User object
}
}
class DataBaseHandler {
func saveDB(user: User) {
// Save User info onto database
}
}
1. initializer 방식은 Dependency를 다룰 때 추가 설명하며
2. 그림과 같이 세가지 클래스로 분리해 주어야 합니다
# Open/Close Principle (개방 / 폐쇄 원칙 - OCP)
확장하는 경우 클래스의 행동 변화 없이 가능해야 합니다 :
>> 확장에는 열려있고 변경에는 닫혀있어야 합니다 : (Open for extension, Closed for modification)
추상화를 도입하는 경우 가능해집니다. (by 프로토콜)
다음과 같은 예제는, Car 배열을 순회하며 각각의 detail을 프린트하는 역할을 합니다.
class Logger {
func printData() {
let cars = [
Car(name: "Batmobile", color: "Black"),
Car(name: "SuperCar", color: "Gold"),
Car(name: "FamilyCar", color: "Grey")
]
cars.forEach { car in
print(car.printDetails())
}
}
}
class Car {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "I'm \(name) and my color is \(color)"
}
}
다음과 같은 코드는 새로운 이동수단을 추가한다면, PrintData를 매번 수정해주어야 합니다.
class Logger {
func printData() {
let cars = [
Car(name: "Batmobile", color: "Black"),
Car(name: "SuperCar", color: "Gold"),
Car(name: "FamilyCar", color: "Grey")
]
cars.forEach { car in
print(car.printDetails())
}
let bicycles = [
Bicycle(type: "BMX"),
Bicycle(type: "Tandem")
]
bicycles.forEach { bicycles in
print(bicycles.printDetails())
}
}
}
class Car {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "I'm \(name) and my color is \(color)"
}
}
class Bicycle {
let type: String
init(type: String) {
self.type = type
}
func printDetails() -> String {
return "I'm a \(type)"
}
}
위와 같은 코드로 bicycle을 추가했습니다. 교통수단이 생길 때 마다 printData가 불필요하게 계속 커질 것입니다.
코드를 보면 , printDetails() 메소드를 Car와 Bicycle 모두 가지고 있는데,
이를 하나의 인터페이스(프로토콜)로 묶으면 Logger Class를 변경할 필요가 없게 됩니다.
protocol Printable {
func printDetails() -> String
}
class Logger {
func printData() {
let printables: [Printable] = [
Car(name: "Batmobile", color: "Black"),
Car(name: "SuperCar", color: "Gold"),
Car(name: "FamilyCar", color: "Grey"),
Bicycle(type: "BMX"),
Bicycle(type: "Tandem")
]
printables.forEach { printable in
print(printable.printDetails())
}
}
}
class Car: Printable {
let name: String
let color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
func printDetails() -> String {
return "I'm \(name) and my color is \(color)"
}
}
class Bicycle: Printable {
let type: String
init(type: String) {
self.type = type
}
func printDetails() -> String {
return "I'm a \(type)"
}
}
Printable 프로토콜을 추가했고, Car와 Bicycle은 Printable 프로토콜을 채택했으므로
~printData~ 메소드를 변경 없이 탈것을 추가할 수 있게 됩니다.
( 코드량이 얼마 차이나지 않아 보이지만, 나중에 모듈 단위라고 한다면 차이가 많이 나는것을 예측 가능 )
# Liskov Substitution Principle ( 리스코프 치환 원칙 - LSP)
프로그램 객체(인스턴스)는 프로그램의 정확성을 해치지 않으며,
하위 타입의 인스턴스로 바꿀 수 있어야 합니다
>> 부모 클래스의 자리에 자식 클래스를 그대로 치환해도 문제없이 돌아가야 한다
다음과 같은 코드를 살펴보겠습니다.
class Rectangle {
var width: Float = 0
var length: Float = 0
var area: Float {
return width * length
}
}
class Square: Rectangle {
override var width: Float {
didSet {
length = width
}
}
}
직사각형의 넓이만 구하던 프로젝트에서 이제는 정사각형의 넓이까지 구하라고 클라이언트께서 그랬다고 합니다.
구하는 코드를 열심히 적었습니다. 그런데..
func printArea(of rectangle: Rectangle) {
rectangle.length = 5
rectangle.width = 2
print(rectangle.area)
}
let rectangle = Rectangle()
printArea(of: rectangle) // 10
// -------------------------------
let square = Square()
printArea(of: square) // 4
위와 같은 코드로는 자식의 넓이를 구하는 로직으로는 부모의 넓이를 구할수 없게 되겠습니다.
이를 해결하는 방법은 넓이를 구하는 로직을
프로토콜로 두고, 다르게 구현하면 됩니다.
protocol Polygon {
var area: Float { get }
}
class Rectangle: Polygon {
private let width: Float
private let length: Float
init(width: Float, length: Float) {
self.width = width
self.length = length
}
var area: Float {
return width * length
}
}
class Square: Polygon {
private let side: Float
init(side: Float) {
self.side = side
}
var area: Float {
return pow(side, 2)
}
}
넓이가 이제는 잘 나올 예정입니다.
정답 : 우리는 프로토콜이 있으니까 프로토콜로 만들자.
# Interface Segregation Principle (인터페이스 분리 원칙 - ISP)
1. 사용하지 않는 메서드는 구현할 필요가 없다
2. 일반적인 인터페이스보다 각각의 구체적인 인터페이스를 가지는 것이 낫다
>> Fat Interface를 만들지 말자
이해가 어렵지 않습니다 ^ㅠ^......
protocol GestureProtocol {
func didTap()
func didDoubleTap()
func didLongPress()
}
처음 만든 GestureProtocol은 다음과 같은 세 메소드를 가지고 있었습니다.
SuperButton은 GestureProtocol을 준수해서 세가지 메소드를 모두 구현해 주었습니다.
class SuperButton: GestureProtocol {
func didTap() {
// send tap action
}
func didDoubleTap() {
// send double tap action
}
func didLongPress() {
// send long press action
}
}
하지만, 다른 PoorButton은 Tap Event만 받고싶어 합니다. 따라서 GestureProtocol을 채택하면 불필요한 메소드를 구현해야 합니다.
class PoorButton: GestureProtocol {
func didTap() {
// send tap action
}
func didDoubleTap() { }
func didLongPress() { }
}
다음과 같이 프로토콜을 세분화하면 해결할 수 있습니다.
protocol TapProtocol {
func didTap()
}
protocol DoubleTapProtocol {
func didDoubleTap()
}
protocol LongPressProtocol {
func didLongPress()
}
class SuperButton: TapProtocol, DoubleTapProtocol, LongPressProtocol {
func didTap() {
// send tap action
}
func didDoubleTap() {
// send double tap action
}
func didLongPress() {
// send long press action
}
}
class PoorButton: TapProtocol {
func didTap() {
// send tap action
}
}
# Dependency Inversion Principle (의존관계 역전 원칙 - DIP)
원래 의존성만 다루려고 했는데,,, 의존성에 대한 개념을 다루려면 SOLID Swift 버전을 잘 알아야 하니까...
그럼 SOLID를 잠깐 알아볼까??ㅎㅎ 했는데 기네요..ㅎㅎ. 좋은 기회에 좋은 공부 했나보다 싶습니다.
또한 한번 읽고 따라한다 해서 그대로 적용하는 천재는 없으니, 계속 유념하며 코딩해야겠다 싶습니다.
더 깊은 이야기인 IoC, DI 등과 같은 자세한 이야기는 다음주에 이어서 하겠습니다. 오늘은 DIP를 위반하는 경우와 교정 정도만!
원칙 : 상위 클래스가 하위 클래스에 의존하지 않도록 한다.
구체 타입(클래스) 에 대한 의존도를 낮추고, 추상 타입(프로토콜)에 의존도를 높입니다.
의존성이란 다음과 같은 경우입니다.
추상화를 잘 이해해야 하는데, Swift에서는 protocol로 추상화를 진행하게 됩니다.
다음과 같은 코드가 있습니다.
class Handler {
let fm = FilesystemManager()
// 의존성 +
func handle(string: String) {
fm.save(string: string)
}
}
class FilesystemManager {
func save(string: String) {
// Open a file
// Save the string in this file
// Close the file
}
}
Handler 클래스는 파일 시스템에 문자열을 저장하는 역할을 합니다.
이 때 FileSystemManager 인스턴스를 직접 만드는 것은 FilesystemManager Class와의 의존성을 만듭니다.
> Handler Class를 재사용하기 어려워집니다.
이 경우, 추상화 수단인 프로토콜에 의존하는것으로 해결할 수 있습니다.
class Handler {
let storage: StorageProtocol
init(storage: Storage) {
self.storage = storage
}
func handle(string: String) {
storage.save(string: string)
}
}
protocol StorageProtocol {
func save(string: String)
}
class FilesystemManager: Storage {
func save(string: String) {
// Open a file in read-mode
// Save the string in this file
// Close the file
}
}
class DatabaseManager: Storage {
func save(string: String) {
// Connect to the database
// Execute the query to save the string in a table
// Close the connection
}
}
Storage 프로토콜을 채택함으로서 구현을 저 수준의 모듈에게 맡깁니다. >> Handler의 재 사용성이 높아짐
위의 코드와는 다르게, StorageProtocol을 인스턴스로 선언하며 의존성을 클래스에게 두지 않는다는 것이 가장 큰 차이점입니다.
위와 같이 Dependency Injection (DI)에 대한 내용은 다음 포스팅에 더 자세히 다루겠습니다!
감사합니다.^_^ 공부한 대로 개념을 담아 봤는데, 부족한 내용이 있다면 댓글 부탁드려요!
'iOS' 카테고리의 다른 글
[iOS] Swift Attributes를 배워보자 (0) | 2020.09.04 |
---|---|
[iOS] Dependency Injection, (IoC, DI, DIP) (0) | 2020.08.21 |
[iOS] throttle, debounce (0) | 2020.07.31 |
[iOS] Custom Popup, AlertView (0) | 2020.06.20 |
[iOS] 개인 개발자 계정 구매하기, 12만 9천원 내기 (0) | 2020.06.19 |