본문 바로가기

iOS

[iOS] 객체지향과 SOLID 원칙 - Swift

모든 객체지향 프로그래밍에서 사용되는 원칙인 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가 있습니다. 세가지 일을 하는것을 볼 수 있는데요,

  1. API 호출(요청)
  2. 데이터 파싱
  3. 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)에 대한 내용은 다음 포스팅에 더 자세히 다루겠습니다!

감사합니다.^_^ 공부한 대로 개념을 담아 봤는데, 부족한 내용이 있다면 댓글 부탁드려요!